Compare commits

..

No commits in common. 'main' and 'v1.0.0' have entirely different histories.
main ... v1.0.0

  1. 3
      .gitignore
  2. 12
      .idea/runConfigurations/build_rollup.xml
  3. 12
      .idea/runConfigurations/build_signer.xml
  4. 6
      .idea/runConfigurations/indexer.xml
  5. 24
      .idea/runConfigurations/sign.xml
  6. 1
      .idea/smva-indexer.iml
  7. 112
      default-data.json
  8. 533
      indexer.mjs
  9. 6355
      package-lock.json
  10. 33
      package.json
  11. 8
      publish-commands.sftp
  12. 53
      rollup.config.js
  13. 62
      sign-bundle.mjs
  14. 32
      signer-rollup.config.js
  15. 16
      signing-common.mjs
  16. 20
      smva-indexer-release.pub.pem
  17. 84
      updateable-launcher.mjs

3
.gitignore vendored

@ -1,4 +1,3 @@
/node_modules/ /node_modules/
/build/ /build/
/smva-indexer-release.key.pem /rollup/
/smva-indexer-release.params.pem

@ -1,12 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="build-rollup" type="js.build_tools.npm" nameIsGenerated="true">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="build-rollup" />
</scripts>
<node-interpreter value="project" />
<envs />
<method v="2" />
</configuration>
</component>

@ -1,12 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="build-signer" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="build-signer" />
</scripts>
<node-interpreter value="project" />
<envs />
<method v="2" />
</configuration>
</component>

@ -1,7 +1,5 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="indexer" type="NodeJSConfigurationType" path-to-js-file="build/smva-indexer-bundle.js" working-dir="$PROJECT_DIR$"> <configuration default="false" name="indexer" type="NodeJSConfigurationType" path-to-js-file="$PROJECT_DIR$/indexer.mjs" working-dir="$PROJECT_DIR$">
<method v="2"> <method v="2" />
<option name="RunConfigurationTask" enabled="true" run_configuration_name="build-rollup" run_configuration_type="js.build_tools.npm" />
</method>
</configuration> </configuration>
</component> </component>

@ -1,24 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="sign" type="NodeJSConfigurationType" application-parameters="build/smva-indexer-bundle.js build/smva-indexer-bundle-signed.js smva-indexer-release.key.pem" path-to-js-file="build/sign-bundle.js" working-dir="$PROJECT_DIR$">
<method v="2">
<option name="NpmBeforeRunTask" enabled="true">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="build-signer" />
</scripts>
<node-interpreter value="project" />
<envs />
</option>
<option name="NpmBeforeRunTask" enabled="true">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="build-rollup" />
</scripts>
<node-interpreter value="project" />
<envs />
</option>
</method>
</configuration>
</component>

@ -5,7 +5,6 @@
<excludeFolder url="file://$MODULE_DIR$/temp" /> <excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/.tmp" /> <excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" /> <excludeFolder url="file://$MODULE_DIR$/tmp" />
<excludeFolder url="file://$MODULE_DIR$/build" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />

@ -1,112 +0,0 @@
{
"fileRegExp": "saint-miluinas-vore-academy-.*\\.save",
"times": [
"Day 1",
"Day 2",
"Day 3",
"Day 4",
"Day 5",
"Day 6"
],
"voreTypes": [
"Oral",
"Anal",
"Unbirth"
],
"voreRoles": [
"Predator",
"Prey",
"Observer"
],
"voreResults": [
"Fatal",
"Always Fatal",
"Non-fatal"
],
"characterSceneTypes": [
],
"otherSceneTypes": [
],
"characters": [
"Emma",
"Leah",
"Mallory",
"Mina",
"Alice",
"Sam",
"Alesha",
"Sarah",
"Kayla",
"Cherry",
"Cherry",
"Leila",
"Beli",
"Nadia",
"Sunny",
"Charlotte",
"Mandy",
"Betty",
"Cassidy",
"Claire",
"Erika",
"Miranda",
"Janet",
"Zoey",
"Kim",
"Matilda",
"Maeve",
"Bree",
"Morgan",
"Kira",
"Izumi",
"Calina",
"Marie",
"Joan",
"Professor Juniper",
"Professor Slate",
"Professor Steria",
"Professor Florida",
"Professor Bora",
"Professor Stars",
"Nora (The Custodian)",
"Anita",
"Chandra",
"Hannah",
"Gabby",
"Pat",
"Alex",
"Mia",
"Tia",
"Olivia",
"Penny",
"Gina",
"Kris",
"Haley",
"Jackie",
"Madison",
"Sadie",
"Karen",
"Bella",
"Donna",
"Brit",
"Ashley",
"Rachal",
"Bethany",
"Carol",
"Trisha",
"Lily",
"Meghan",
"Candice",
"Emily",
"Kamile",
"Nikki",
"Tara",
"April",
"Catherine",
"Iris",
"Nicole",
"Amelia",
"Maria",
"Liz"
]
}

@ -1,206 +1,56 @@
import enquirer from "enquirer" import enquirer from "enquirer"
import {watch, constants as fsConstants} from "fs" import {watch} from "fs"
import {access, readFile, opendir, stat, writeFile} from "fs/promises" import {access, readFile, rename, stat, writeFile} from "fs/promises"
import glob from "fast-glob"
import ini from "ini" import ini from "ini"
import envPaths from "env-paths" import envPaths from "env-paths"
import chalk from "chalk" import chalk from "chalk"
import {isAbsolute, join, normalize, basename, sep} from "path" import {isAbsolute, join, normalize, basename} from "path"
import {moveFile} from "move-file"
import isValidFilename from "valid-filename"
import makeDir from "make-dir" import makeDir from "make-dir"
import homeDir from "home-dir" import homeDir from "home-dir"
import "source-map-support/register"
import defaultData from "./default-data.json"
import packageData from "./package.json"
import buildTimestamp from "consts:buildTimestamp"
import configName from "consts:configName"
const {Toggle, Select, Input, AutoComplete} = enquirer const {Toggle, Select, Input} = enquirer
const {R_OK, W_OK} = fsConstants
function log(message) { function log(message) {
process.stderr.write(message + "\n") process.stderr.write(message + "\n")
} }
function validateFilenamePart(part, {allowEmpty = false} = {}) {
if (typeof part !== "string") {
return `must be a string, but was ${part === null ? "null" : typeof part}`
}
if (part.length === 0) {
if (allowEmpty) {
return true
} else {
return "must not be empty, but was empty"
}
}
if (!isValidFilename(part + ".save")) {
return `cannot contain any of the following characters: <>:"/\\|?*`
}
return true
}
const DIRECTORY_OK = "OK"
const DIRECTORY_EMPTY = "Path is empty"
const DIRECTORY_NOT_ABSOLUTE = "Path must be absolute"
const DIRECTORY_NOT_VALID = "Path must contain segments which are valid filenames"
const DIRECTORY_EXISTS_NOT_DIRECTORY = "Path exists, but is not a directory"
const DIRECTORY_NOT_EXISTS = "Path does not exist"
const DIRECTORY_NOT_ACCESSIBLE = "Path is a directory, but is not both readable and writable"
function validatePath(path) {
if (typeof path !== "string" || path.length === 0) {
return DIRECTORY_EMPTY
} else if (!isAbsolute(path)) {
return DIRECTORY_NOT_ABSOLUTE
} else if (!path.split(sep).slice(1).every(isValidFilename)) {
return DIRECTORY_NOT_VALID
}
return DIRECTORY_OK
}
async function validateDirectory(path) {
const pathValidity = validatePath(path)
if (pathValidity !== DIRECTORY_OK) {
return pathValidity
}
try {
const statResult = await stat(path)
if (!statResult.isDirectory()) {
return DIRECTORY_EXISTS_NOT_DIRECTORY
}
await access(path, R_OK | W_OK)
} catch (ex) {
if (ex.code === "ENOENT") {
return DIRECTORY_NOT_EXISTS
} else if (ex.code === "EACCES") {
return DIRECTORY_NOT_ACCESSIBLE
} else {
throw ex
}
}
return DIRECTORY_OK
}
function validateRegExp(text) {
if (typeof text !== "string") {
return `must be a string, but was ${text === null ? "null" : typeof text}`
}
try {
new RegExp(text)
return true
} catch (ex) {
return `must be a valid regular expression, but couldn't construct a RegExp with it: ${ex}`
}
}
function validateFilenamePartList(list, {minLength = 0, allowMissing = false} = {}) {
if (typeof list === "undefined") {
if (allowMissing) {
return true
} else {
return `must be present, but was absent`
}
}
if (!Array.isArray(list)) {
return `must be an array, but was ${list === null ? typeof list : "null"}`
}
if (list.length < minLength) {
return `must have at least ${minLength} ${minLength === 1 ? "element" : "elements"}, but had ${list.length}`
}
const firstNonmatching = list.findIndex((item) => !(typeof item === "string" && item.length > 0 && validateFilenamePart(item) === true))
if (firstNonmatching !== -1) {
return `element at index ${firstNonmatching} ${validateFilenamePart(list[firstNonmatching])}`
}
return true
}
function validateGameData(data) {
if (typeof data !== "object" || data === null) {
return `must be an object, but was ${typeof data}`
}
const fileRegExpError = validateRegExp(data.fileRegExp)
if (validateRegExp(data.fileRegExp) !== true) {
return `fileRegExp ${fileRegExpError}`
}
const charactersError = validateFilenamePartList(data.characters, {allowMissing: true})
if (charactersError !== true) {
return `characters ${charactersError}`
}
const timesError = validateFilenamePartList(data.times, {minLength: 1})
if (timesError !== true) {
return `times ${timesError}`
}
const voreRolesError = validateFilenamePartList(data.voreRoles, {minLength: 1})
if (voreRolesError !== true) {
return `voreRoles ${voreRolesError}`
}
const voreTypesError = validateFilenamePartList(data.voreTypes, {minLength: 1})
if (voreTypesError !== true) {
return `voreTypes ${voreTypesError}`
}
const voreResultsError = validateFilenamePartList(data.voreResults, {minLength: 1})
if (voreResultsError !== true) {
return `voreResults ${voreResultsError}`
}
const characterSceneTypesError = validateFilenamePartList(data.characterSceneTypes, {allowMissing: true})
if (characterSceneTypesError !== true) {
return `characterSceneTypes ${characterSceneTypesError}`
}
const otherSceneTypesError = validateFilenamePartList(data.otherSceneTypes, {allowMissing: true})
if (otherSceneTypesError !== true) {
return `otherSceneTypes ${otherSceneTypesError}`
}
return true
}
async function getDirectoryFromUser(message, initial) { async function getDirectoryFromUser(message, initial) {
let dirState = null while (true) {
let newDir = initial const newDir = normalize(await new Input({
while (dirState !== DIRECTORY_OK) { message, initial
newDir = normalize(await new Input({
message,
initial: newDir,
validate: (text) => {
const pathErr = validatePath(text)
if (pathErr === DIRECTORY_OK) {
return true
} else {
return pathErr
}
}
}).run()) }).run())
if (!isAbsolute(newDir)) {
log(chalk.red("Can't use that folder: This path must be absolute. Try again."))
continue
}
try { try {
dirState = await validateDirectory(newDir) const statResult = await stat(newDir)
if (!statResult.isDirectory()) {
log(chalk.red("Can't use that folder: This path must be a directory. Try again."))
}
return newDir
} catch (ex) { } catch (ex) {
log(chalk.red(`Couldn't check on that folder: ${chalk.redBright.bold(ex)}. Try again.`)) if (ex.code === "ENOENT") {
dirState = null const create = await new Toggle({
} message: "That folder doesn't exist. Want me to create it?",
if (dirState === DIRECTORY_NOT_EXISTS) { enabled: "Create it",
const create = await new Toggle({ disabled: "No, I'll enter the path again",
message: "That folder doesn't exist. Want me to create it?", }).run()
enabled: "Yes, create it", if (!create) {
disabled: "No, I'll enter the path again", continue
}).run() }
if (create) {
try { try {
await makeDir(newDir) await makeDir(newDir)
try { return newDir
dirState = await validateDirectory(newDir)
if (dirState !== DIRECTORY_OK) {
log(chalk.red(`Creating the new folder led to an unusable directory: ${chalk.redBright.bold(dirState)}. Try again.`))
}
} catch (ex) {
log(chalk.red(`Couldn't check on the new folder: ${chalk.redBright.bold(ex)}. Try again.`))
}
} catch (ex) { } catch (ex) {
log(chalk.red(`Couldn't create that folder: ${chalk.redBright.bold(ex)}. Try again.`)) log(chalk.red(`Couldn't create that folder: ${chalk.redBright.bold(ex)}. Try again.`))
} }
} else {
log(chalk.red(`Couldn't check on that folder: ${chalk.redBright.bold(ex)}. Try again.`))
} }
} else if (dirState !== null && dirState !== DIRECTORY_OK) {
log(chalk.red(`Can't use that folder: ${dirState}. Try again.`))
} }
} }
return newDir
} }
async function loadPaths(config, home) { async function loadPaths(config, home) {
@ -224,65 +74,26 @@ async function loadPaths(config, home) {
} }
} }
} }
const sourceDirOK = (await validateDirectory(sourceDir)) === DIRECTORY_OK if (!(sourceDir && destDir)) {
const destDirOK = (await validateDirectory(destDir)) === DIRECTORY_OK log(chalk.yellow(`Configuration at ${chalk.yellowBright.bold(configPath)} is missing or incomplete. Configure now.`))
if (!(sourceDirOK && destDirOK)) { sourceDir = await getDirectoryFromUser(
log(chalk.yellow(`Configuration at ${chalk.yellowBright.bold(configPath)} is missing, incomplete, or damaged. Configuring now.`)) "Where are SMVA saves downloaded to? (e.g., your Downloads folder)",
if (!sourceDirOK) { sourceDir || join(home, "Downloads"))
sourceDir = await getDirectoryFromUser( destDir = await getDirectoryFromUser(
"Where are SMVA saves downloaded to? (e.g., your Downloads folder)", "Where do you want to store your organized SMVA saves? (e.g., your Documents folder)",
sourceDir || join(home, "Downloads")) destDir || join(home, "Documents", "SMVA Saves")
} )
if (!destDirOK) { await makeDir(config)
destDir = await getDirectoryFromUser( await writeFile(configPath, ini.encode({paths: {source: sourceDir, dest: destDir}}), {encoding: "utf-8"})
"Where do you want to store your organized SMVA saves? (e.g., your Documents folder)", log(chalk.cyan(`Wrote configuration to ${chalk.cyanBright.bold(configPath)}`))
destDir || join(home, "Documents", "SMVA Saves") }
) return {sourceDir, destDir}
}
try {
await makeDir(config)
await writeFile(configPath, ini.encode({paths: {source: sourceDir, dest: destDir}}), {encoding: "utf-8"})
log(chalk.cyan(`Wrote configuration to ${chalk.cyanBright.bold(configPath)}`))
} catch (ex) {
log(chalk.red(`Could not write configuration to ${chalk.redBright.bold(configPath)}`))
}
}
const dataPath = join(config, "game-data.json")
let data = defaultData
try {
data = JSON.parse(await readFile(dataPath, {encoding: "utf-8"}))
const err = validateGameData(data)
if (err === true) {
log(chalk.cyan(`Read game data from ${chalk.cyanBright.bold(dataPath)}`))
} else {
log(chalk.red(`${chalk.redBright.bold(dataPath)} had a problem: ${chalk.redBright.bold(err)}`))
log(chalk.yellow(`Using default game data.`))
data = defaultData
}
} catch (ex) {
switch (ex.code) {
case "ENOENT":
log(chalk.yellow(`${chalk.yellowBright.bold(dataPath)} not found. Using default game data.`))
try {
await writeFile(dataPath, JSON.stringify(defaultData, null, 4), {encoding: "utf-8"})
log(chalk.cyan(`Wrote default game data to ${chalk.cyanBright.bold(dataPath)}`))
} catch (ex) {
log(chalk.red(`Could not write default game data to ${chalk.redBright.bold(dataPath)}: ${chalk.redBright.bold(ex)}`))
}
break
default:
log(chalk.red(`Could not read game data from ${chalk.redBright.bold(dataPath)}: ${chalk.redBright.bold(ex)}`))
log(chalk.yellow(`Using default game data.`))
}
}
return {sourceDir, destDir, data}
} }
async function* watchNewSaves(sourceDir, sourceRegexpString) { async function* watchNewSaves(sourceDir) {
const sourceRe = new RegExp(`^${sourceRegexpString}\$`) const sourceRe = /^saint-miluinas-vore-academy-.*\.save$/
let resolveNext let resolveNext
let nextPromise let nextPromise
let active = true
function makeNewPromise() { function makeNewPromise() {
nextPromise = new Promise((resolve) => { nextPromise = new Promise((resolve) => {
@ -292,144 +103,90 @@ async function* watchNewSaves(sourceDir, sourceRegexpString) {
} }
}) })
} }
makeNewPromise() makeNewPromise()
watch(sourceDir, {}, (eventType, filename) => { watch(sourceDir, {}, (eventType, filename) => {
if (eventType === "rename" && sourceRe.test(filename)) { if (eventType === "rename" && sourceRe.test(filename)) {
resolveNext(filename) resolveNext(join(sourceDir, filename))
} }
}) })
log(chalk.green(`Waiting for new saves with filenames like ${chalk.greenBright.bold(`/${sourceRegexpString}/`)} to appear in ${chalk.greenBright.bold(sourceDir)}`)) log(chalk.cyan(`Waiting for new saves to appear in ${chalk.cyanBright.bold(sourceDir)}`))
while (true) { while (true) {
const next = await nextPromise yield await nextPromise
if (next && active) {
yield next
}
} }
} }
async function* findExistingSaves(sourceDir, sourceRegexpString) { async function findExistingSaves(sourceDir) {
const sourceRe = new RegExp(`^${sourceRegexpString}\$`) const sourceGlob = `${glob.escapePath(sourceDir)}/saint-miluinas-vore-academy-*.save`
log(chalk.green(`Scanning for existing saves with filenames like ${chalk.greenBright.bold(`/${sourceRegexpString}/`)} in ${chalk.greenBright.bold(sourceDir)}`)) return glob(sourceGlob, {"onlyFiles": true})
const dir = await opendir(sourceDir)
for await (const dirent of dir) {
if (dirent.isFile() && sourceRe.test(dirent.name)) {
yield dirent.name
}
}
log(chalk.cyan(`Finished scanning for existing saves`))
} }
async function promptForFilename({ async function promptForFilename() {
times, const saveType = await new Select({
voreTypes,
voreRoles,
voreResults,
characterSceneTypes,
otherSceneTypes,
characters
}) {
const saveType = await new AutoComplete({
message: "What type of save is this?", message: "What type of save is this?",
limit: 10, choices: ["Useful Save", "Vore Scene", "Skip This Save"],
choices: ["Useful Save", "Vore Scene", ...(Array.isArray(characterSceneTypes) && characterSceneTypes.length > 0 ? characterSceneTypes.map((item) => `${item} (Character Scene)`) : []), ...(Array.isArray(otherSceneTypes) && otherSceneTypes.length > 0 ? otherSceneTypes.map((item) => `${item} (Other Scene)`) : []), "Skip This Save"],
}).run() }).run()
const timePrompt = times.length > 1 ? new AutoComplete({ const dayPrompt = new Select({
message: "When is this save from?", message: "When is this save from?",
limit: 10, choices: ["Day 1", "Day 2", "Day 3", "Day 4", "Day 5", "Day 6"]
choices: times })
}) : {
async run() {
return times[0]
}
}
if (saveType === "Vore Scene") { if (saveType === "Vore Scene") {
const voreType = voreTypes.length > 1 ? await new AutoComplete({ const voreType = await new Select({
message: "What type of vore is involved in this save?", message: "What type of vore is involved in this save?",
limit: 10, choices: ["Oral", "Anal", "Unbirth"]
choices: voreTypes }).run()
}).run() : voreTypes[0] const voreRole = await new Select({
const voreRole = voreRoles.length > 1 ? await new AutoComplete({
message: "What role does the player play in this scene?", message: "What role does the player play in this scene?",
limit: 10, choices: ["Predator", "Prey", "Observer"]
choices: voreRoles }).run()
}).run() : voreRoles[0] const voreResult = await new Select({
const voreResult = voreResults.length > 1 ? await new AutoComplete({
message: "What is the result of the vore?", message: "What is the result of the vore?",
limit: 10, choices: ["Fatal", "Always Fatal", "Non-fatal"],
choices: voreResults, }).run()
}).run() : voreResults[0] const day = await dayPrompt.run()
const time = await timePrompt.run() const vorePartner = await new Input({
const vorePartners = Array.isArray(characters) && characters.length > 0 ? await new AutoComplete({
message: "Who is/are the partner(s) the player is with in this scene?", message: "Who is/are the partner(s) the player is with in this scene?",
limit: 10, }).run()
choices: characters,
multiple: true,
}).run() : []
const voreVariant = await new Input({ const voreVariant = await new Input({
message: `What is the variant name/comment for this scene?${vorePartners.length > 0 ? " (leave blank to skip)" : ""}`, message: "What is the variant name/comment for this scene? (leave blank to skip)",
validate: (filename) => validateFilenamePart(filename, {allowEmpty: vorePartners.length > 0})
}).run() }).run()
return `${voreType} ${voreRole} - ${voreResult} - ${time} ${vorePartners.join(" + ")}${voreVariant ? vorePartners.length > 0 ? ` (${voreVariant})` : voreVariant : ""}.save` return `${voreType} ${voreRole} - ${voreResult} - ${day} ${vorePartner}${voreVariant ? ` (${voreVariant})` : ""}.save`
} else if (saveType === "Useful Save") { } else if (saveType === "Useful Save") {
const time = await timePrompt.run() const day = await dayPrompt.run()
const title = await new Input({
message: "What is the title of this useful save?",
validate: validateFilenamePart
}).run()
return `Useful Saves - ${time} - ${title}.save`
} else if (saveType.endsWith(" (Character Scene)")) {
const sceneType = saveType.substring(0, saveType.length - " (Character Scene)".length)
const time = await timePrompt.run()
const scenePartners = Array.isArray(characters) && characters.length > 0 ? await new AutoComplete({
message: "Who is/are the partner(s) the player is with in this scene?",
limit: 10,
choices: characters,
multiple: true,
validate: (list) => list.length > 0,
}).run() : []
const title = await new Input({
message: `What is the comment for this ${sceneType} scene?`,
validate: validateFilenamePart
}).run()
return `${sceneType} with ${scenePartners.join("+")} - ${time} - ${title}.save`
} else if (saveType.endsWith(" (Other Scene)")) {
const sceneType = saveType.substring(0, saveType.length - " (Other Scene)".length)
const time = await timePrompt.run()
const title = await new Input({ const title = await new Input({
message: `What is the comment for this ${sceneType} scene?`, message: "What is the title of this useful save?"
validate: validateFilenamePart
}).run() }).run()
return `${sceneType} - ${time} - ${title}.save` return `Useful Saves - ${day} - ${title}.save`
} else { } else {
return null return null
} }
} }
async function askIfOverwrite(destPath) { async function shouldDoRename(destPath) {
console.log(chalk.yellow(`A file named ${chalk.yellowBright.bold(destPath)} already exists.`)) try {
const result = await new Select({ await access(destPath)
message: "What should we do?", console.log(chalk.yellow(`A file named ${chalk.yellowBright.bold(destPath)} already exists.`))
choices: ["Skip it", "Rename it", "Overwrite it"] } catch (ex) {
}).run() if (ex.code === "ENOENT") {
return true
} else {
console.log(chalk.red(`The file named ${chalk.redBright.bold(destPath)} could not be accessed: ${chalk.redBright.bold(ex)}`))
}
}
const result = await new Select({message: "What should we do?", choices: ["Skip it", "Rename it", "Overwrite it"]}).run();
switch (result) { switch (result) {
case "Skip it": case "Skip it":
return false return false;
case "Overwrite it": case "Overwrite it":
return true return true;
case "Rename it": case "Rename it":
return (await new Input({ return (await new Input({message: "What would you like to name the file?", initial: basename(destPath, ".save")}).run()) + ".save"
message: "What would you like to name the file?",
initial: basename(destPath, ".save"),
validate: (name) => name.length > 0 && isValidFilename(name) ? true : "must be a nonempty string not ending in trailing periods or containing any of the characters <>:\"/\\|?*"
}).run()) + ".save"
} }
} }
function queueMovingTo(destDir, filenameData) { function queueMovingTo(destDir) {
let resolvePromise, rejectPromise let resolvePromise, rejectPromise
const completionPromise = new Promise((resolve, reject) => { const completionPromise = new Promise((resolve, reject) => {
resolvePromise = resolve resolvePromise = resolve
@ -437,7 +194,6 @@ function queueMovingTo(destDir, filenameData) {
}) })
const filequeue = [] const filequeue = []
let processingFiles = false let processingFiles = false
let endAfterCompleting = false
async function processFiles() { async function processFiles() {
if (processingFiles) { if (processingFiles) {
@ -447,7 +203,7 @@ function queueMovingTo(destDir, filenameData) {
while (filequeue.length > 0) { while (filequeue.length > 0) {
const nextFile = filequeue[0] const nextFile = filequeue[0]
try { try {
await access(nextFile) access(nextFile)
} catch (ex) { } catch (ex) {
if (ex.code !== "ENOENT") { if (ex.code !== "ENOENT") {
log(chalk.red(`failed accessing ${chalk.redBright.bold(nextFile)}: ${chalk.redBright.bold(ex)}`)) log(chalk.red(`failed accessing ${chalk.redBright.bold(nextFile)}: ${chalk.redBright.bold(ex)}`))
@ -456,52 +212,37 @@ function queueMovingTo(destDir, filenameData) {
continue continue
} }
log(chalk.green(`For save ${chalk.greenBright.bold(nextFile)}:`)) log(chalk.green(`For save ${chalk.greenBright.bold(nextFile)}:`))
const destFilename = await promptForFilename(filenameData) const destFilename = await promptForFilename()
if (!destFilename) { if (!destFilename) {
log(chalk.yellow("Skipped.")) log(chalk.yellow("Skipped."))
filequeue.shift() filequeue.shift()
continue continue
} }
let destPath = join(destDir, destFilename) let destPath = join(destDir, destFilename)
let overwrite = false let confirmed = false;
while (true) { while (!confirmed) {
try { const promptResult = await shouldDoRename(destPath)
await moveFile(nextFile, destPath, {overwrite}) if (typeof promptResult === "string") {
log(chalk.cyan(`Moved to ${chalk.cyanBright.bold(destPath)}`)) destPath = join(destDir, promptResult)
break } else if (!promptResult) {
} catch (ex) { destPath = null
if (ex.message.startsWith("The destination file exists:")) { confirmed = true
const promptResult = await askIfOverwrite(destPath) } else {
if (typeof promptResult === "string") { confirmed = true
destPath = join(destDir, promptResult)
} else if (promptResult === true) {
overwrite = true
} else {
break
}
} else {
log(chalk.red(`Something went wrong: ${chalk.redBright.bold(ex)}`))
const giveUp = await new Toggle({
message: "Want to keep trying?",
disabled: "Yes, try again",
enabled: "No, skip this save"
}).run()
if (giveUp) {
break
}
}
} }
} }
if (!destPath) {
log(chalk.yellow("Skipped."))
filequeue.shift()
continue
}
await rename(nextFile, destPath)
log(chalk.cyan(`Moved to ${chalk.cyanBright.bold(destPath)}`))
filequeue.shift() filequeue.shift()
} }
processingFiles = false processingFiles = false
if (endAfterCompleting) {
resolvePromise()
}
} }
log(chalk.green(`Ready to move renamed saves to ${chalk.greenBright.bold(destDir)}`))
return { return {
push(...files) { push(...files) {
if (!rejectPromise) { if (!rejectPromise) {
@ -512,69 +253,49 @@ function queueMovingTo(destDir, filenameData) {
filequeue.push(file) filequeue.push(file)
} }
} }
processFiles().catch((err) => { processFiles().catch(() => {
if (!rejectPromise) { if (!rejectPromise) {
return return
} }
rejectPromise(err) rejectPromise()
resolvePromise = null resolvePromise = null
rejectPromise = null rejectPromise = null
}) })
}, },
shutDown() { complete() {
if (!resolvePromise) { if (!resolvePromise) {
return throw Error("Already stopped the queue")
}
if (processingFiles) {
endAfterCompleting = true
} else {
resolvePromise()
} }
resolvePromise()
resolvePromise = null
rejectPromise = null
}, },
completionPromise completionPromise
} }
} }
export async function main() { async function main() {
log(chalk.green(`${packageData.name} ${chalk.greenBright.bold(`v${packageData.version}`)} - built at ${chalk.greenBright.bold(new Date(buildTimestamp).toISOString())}`)) const {config} = envPaths("SMVA-Indexer")
const {config} = envPaths(configName)
const home = homeDir() const home = homeDir()
const { const {sourceDir, destDir} = await loadPaths(config, home)
sourceDir, const queue = queueMovingTo(destDir)
destDir,
data: {fileRegExp, ...filenameData}
} = await loadPaths(config, home)
const queue = queueMovingTo(destDir, filenameData)
const existingSavesPromise = (async () => { const existingSavesPromise = (async () => {
try { try {
for await (const oldSave of findExistingSaves(sourceDir, fileRegExp)) { const existingSaves = await findExistingSaves(sourceDir)
queue.push(join(sourceDir, oldSave)) log(chalk.cyan(`found ${chalk.cyanBright.bold(existingSaves.length)} existing saves`))
} queue.push(...existingSaves)
} catch (ex) { } catch (ex) {
log(chalk.red(`failed searching for existing saves: ${chalk.redBright.bold(ex)}`)) log(chalk.red(`failed searching for existing saves: ${chalk.redBright.bold(ex)}`))
} }
})() })()
const newSavesPromise = (async () => { const newSavesPromise = (async () => {
try { for await (const newSave of watchNewSaves(sourceDir)) {
for await (const newSave of watchNewSaves(sourceDir, fileRegExp)) { queue.push(newSave)
queue.push(join(sourceDir, newSave))
}
} catch (ex) {
log(chalk.red(`failed waiting for new saves: ${chalk.redBright.bold(ex)}`))
} }
})() })()
Promise.allSettled([newSavesPromise, existingSavesPromise]).finally(() => queue.shutDown()) await Promise.all([newSavesPromise, existingSavesPromise, queue.completionPromise])
try {
await queue.completionPromise
} catch (err) {
console.log(chalk.redBright.bold(err))
process.exit(1)
}
process.exit(0)
} }
main.packageName = packageData.name main()
main.version = packageData.version
main.buildTimestamp = packageData.buildTimestamp

6355
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -1,26 +1,18 @@
{ {
"name": "smva-indexer", "name": "smva-indexer",
"version": "1.1.1", "version": "1.0.0",
"description": "Simple SMVA save file templating system.", "description": "Simple SMVA save file templating system.",
"bin": "indexer.mjs", "bin": "indexer.mjs",
"scripts": { "scripts": {
"start": "node build/smva-indexer-bundle.js", "start": "node indexer.mjs",
"build": "concurrently -n Rollup,Data,Signer -s all -c green,yellow,red \"npm run build-rollup\" \"npm run build-data\" \"npm run build-signer\" && npm run build-nexe && npm run sign", "build": "npm run build-rollup && concurrently -i -n Win,Mac,Lin -c blue,white,yellow \"npm run build-win-nexe\" \"npm run build-mac-nexe\" \"npm run build-lin-nexe\"",
"build-signed": "concurrently -n Rollup,Signer -c green,red \"npm run build-rollup\" \"npm run build-signer\" && npm run sign", "build-rollup": "rollup -c rollup.config.js -f commonjs indexer.mjs",
"build-rollup": "rollup -c rollup.config.js",
"build-signer": "rollup -c signer-rollup.config.js",
"build-data": "cpy default-data.json smva-indexer-release.pub.pem build && move-file build/default-data.json build/smva-indexer-game-data.json",
"build-nexe": "concurrently -i -s all -n Win,Mac,Lin -c blue,white,yellow \"npm run build-win-nexe\" \"npm run build-mac-nexe\" \"npm run build-lin-nexe\"",
"build-win-nexe": "nexe -t windows-x86-14.15.3 -i build/smva-indexer-bundle.js -n indexer -o build/smva-indexer-win.exe --verbose", "build-win-nexe": "nexe -t windows-x86-14.15.3 -i build/smva-indexer-bundle.js -n indexer -o build/smva-indexer-win.exe --verbose",
"build-mac-nexe": "nexe -t mac-x64-14.15.3 -i build/smva-indexer-bundle.js -n indexer -o build/smva-indexer-macos --verbose", "build-mac-nexe": "nexe -t mac-x64-14.15.3 -i build/smva-indexer-bundle.js -n indexer -o build/smva-indexer-macos --verbose",
"build-lin-nexe": "nexe -t linux-x64-14.15.3 -i build/smva-indexer-bundle.js -n indexer -o build/smva-indexer-linux --verbose", "build-lin-nexe": "nexe -t linux-x64-14.15.3 -i build/smva-indexer-bundle.js -n indexer -o build/smva-indexer-linux --verbose",
"build-win": "npm run build-rollup && npm run build-win-nexe", "build-win": "npm run build-rollup && npm run build-win-nexe",
"build-mac": "npm run build-rollup && npm run build-mac-nexe", "build-mac": "npm run build-rollup && npm run build-mac-nexe",
"build-lin": "npm run build-rollup && npm run build-lin-nexe", "build-lin": "npm run build-rollup && npm run build-lin-nexe"
"sign": "NODE_PATH=node_modules node build/sign-bundle.js build/smva-indexer-bundle.js build/smva-indexer-bundle-signed.js smva-indexer-release.key.pem",
"publish": "sftp -b publish-commands.sftp kink-games.deliciousreya.net",
"release": "npm run build && npm run publish",
"generate-release-keys": "openssl genpkey -genparam -algorithm dsa -pkeyopt dsa_paramgen_bits:2048 -outform PEM --out smva-indexer-release.params.pem && openssl genpkey -paramfile smva-indexer-release.params.pem -aes-256-cbc -out smva-indexer-release.key.pem && openssl pkey -inform PEM -in smva-indexer-release.key.pem -outform PEM -pubout -out smva-indexer-release.pub.pem"
}, },
"keywords": [], "keywords": [],
"author": { "author": {
@ -30,28 +22,19 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"chalk": "^5.0.0", "chalk": "^5.0.0",
"data-urls": "^3.0.1",
"enquirer": "^2.3.6", "enquirer": "^2.3.6",
"env-paths": "^3.0.0", "env-paths": "^3.0.0",
"filenamify": "^5.1.0", "fast-glob": "^3.2.11",
"home-dir": "^1.0.0", "home-dir": "^1.0.0",
"ini": "^2.0.0", "ini": "^2.0.0",
"make-dir": "^3.1.0", "make-dir": "^3.1.0"
"move-file": "^3.0.0",
"source-map-support": "^0.5.21",
"valid-filename": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-alias": "^3.1.9", "@rollup/plugin-alias": "^3.1.9",
"@rollup/plugin-commonjs": "^21.0.1", "@rollup/plugin-commonjs": "^21.0.1",
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^13.1.3", "@rollup/plugin-node-resolve": "^13.1.3",
"@rollup/plugin-url": "^6.1.0",
"concurrently": "^7.0.0", "concurrently": "^7.0.0",
"cpy-cli": "^3.1.1",
"move-file-cli": "^3.0.0",
"nexe": "^4.0.0-beta.19", "nexe": "^4.0.0-beta.19",
"rollup": "^2.67.2", "rollup": "^2.67.2"
"rollup-plugin-consts": "^1.0.2"
} }
} }

@ -1,8 +0,0 @@
chdir "Shadows/Games/Web Games/Saint Miluina's Vore Academy/indexer"
lchdir build
put smva-indexer-win.exe
put smva-indexer-macos
put smva-indexer-linux
put smva-indexer-bundle-signed.js
put smva-indexer-game-data.json
put smva-indexer-release.pub.pem

@ -1,45 +1,22 @@
import alias from '@rollup/plugin-alias';
import consts from 'rollup-plugin-consts';
import url from '@rollup/plugin-url';
import commonjs from '@rollup/plugin-commonjs';
import json from '@rollup/plugin-json';
import { nodeResolve } from '@rollup/plugin-node-resolve'; import { nodeResolve } from '@rollup/plugin-node-resolve';
import {signatureGuardStart, signatureGuardEnd} from "./signing-common.mjs" import commonjs from '@rollup/plugin-commonjs';
import alias from '@rollup/plugin-alias';
export default { export default {
input: 'updateable-launcher.mjs', input: 'indexer.js',
output: { output: {
format: 'cjs', format: 'cjs',
file: 'build/smva-indexer-bundle.js', file: 'build/smva-indexer-bundle.js'
sourcemap: 'inline',
banner: Buffer.from(signatureGuardStart).toString("utf-8") + "(Unsigned)" + Buffer.from(signatureGuardEnd).toString("utf-8")
}, },
external: ['fs/promises', 'fs', 'path', 'process', 'os', 'tty'], external: ['fs/promises'],
plugins: [ plugins: [nodeResolve({
alias({ exportConditions: ["node"],
entries: [ }), commonjs(), alias({
{ find: 'node:path', replacement: 'path' }, entries: [
{ find: 'node:process', replacement: 'process' }, { find: 'node:path', replacement: 'path' },
{ find: 'node:os', replacement: 'os' }, { find: 'node:process', replacement: 'process' },
{ find: 'node:tty', replacement: 'tty' }, { find: 'node:os', replacement: 'os' },
{ find: 'node:fs', replacement: 'fs' }, { find: 'node:tty', replacement: 'tty' },
] ]
}), })]
consts({
buildTimestamp: new Date().getTime(),
configName: "SMVA-Indexer"
}),
url({
limit: 4096,
include: ["./smva-indexer-release.pub.pem"],
emitFiles: false,
}),
commonjs(),
json({
compact: true,
}),
nodeResolve({
exportConditions: ["node"],
preferBuiltins: true,
}),]
}; };

@ -1,62 +0,0 @@
import {readFile, open} from "fs/promises"
import enquirer from "enquirer"
import {createPrivateKey, createSign} from "crypto"
import chalk from "chalk"
import {algorithm, signatureGuardEnd, signatureGuardStart} from "./signing-common.mjs"
const {Invisible} = enquirer
async function getPassword() {
return await new Invisible({
message: "Enter the passphrase for smva-indexer-release.key.pem:"
}).run()
}
async function decryptSecretKey(keyPath) {
return createPrivateKey({ key: await readFile(keyPath), passphrase: await getPassword() })
}
async function signBundle(bundlePath, destinationPath, keyPath) {
const bundle = await readFile(bundlePath)
if (!bundle.subarray(0, signatureGuardStart.length).equals(signatureGuardStart)) {
throw new Error("No placeholder for the signature guard start found")
}
const signatureLength = bundle.subarray(signatureGuardStart.length).indexOf(signatureGuardEnd)
if (signatureLength === -1) {
throw new Error("No placeholder for the signature guard end found")
}
const sign = createSign(algorithm)
const signedContent = bundle.subarray(signatureGuardStart.length + signatureLength + signatureGuardEnd.length)
const secretKey = await decryptSecretKey(keyPath)
sign.update(signedContent)
const signature = sign.sign(secretKey, 'base64')
let destination
try {
destination = await open(destinationPath, "w")
await destination.write(signatureGuardStart)
await destination.write(signature, null, "utf-8")
await destination.write(signatureGuardEnd)
await destination.write(signedContent)
} finally {
await destination?.close()
}
}
async function main() {
const [bundlePath, destinationPath, keyPath] = process.argv.slice(2)
if (!bundlePath || !destinationPath || !keyPath) {
process.stderr.write(chalk.red("Required arguments: bundlePath destinationPath keyPath\n"))
process.exit(1)
}
try {
await signBundle(bundlePath, destinationPath, keyPath)
process.stderr.write(chalk.cyan(`Successfully signed ${chalk.cyanBright.bold(bundlePath)} as ${chalk.cyanBright.bold(destinationPath)} with ${chalk.cyanBright.bold(keyPath)}\n`))
} catch (ex) {
process.stderr.write(chalk.red(`Failed to sign ${chalk.redBright.bold(bundlePath)} as ${chalk.redBright.bold(destinationPath)} with ${chalk.redBright.bold(keyPath)}: ${chalk.redBright.bold(ex)}\n`))
}
}
if (!global.main) {
global.main = true
main()
}

@ -1,32 +0,0 @@
import alias from '@rollup/plugin-alias';
import commonjs from '@rollup/plugin-commonjs';
import json from '@rollup/plugin-json';
import { nodeResolve } from '@rollup/plugin-node-resolve';
export default {
input: 'sign-bundle.mjs',
output: {
format: 'cjs',
file: 'build/sign-bundle.js',
sourcemap: 'inline',
},
external: ['fs/promises', 'fs', 'path', 'process', 'os', 'tty', 'minisign'],
plugins: [
alias({
entries: [
{ find: 'node:path', replacement: 'path' },
{ find: 'node:process', replacement: 'process' },
{ find: 'node:os', replacement: 'os' },
{ find: 'node:tty', replacement: 'tty' },
{ find: 'node:fs', replacement: 'fs' },
]
}),
commonjs(),
json({
compact: true,
}),
nodeResolve({
exportConditions: ["node"],
preferBuiltins: true,
}),]
};

@ -1,16 +0,0 @@
import {isAbsolute, join} from "path"
export function absolutize(path) {
if (isAbsolute(path)) {
return path
} else {
return join(process.cwd(), path)
}
}
const encoder = new TextEncoder()
export const algorithm = "SHA3-512"
export const signatureGuardStart = encoder.encode("//// signature: <")
export const signatureGuardEnd = encoder.encode("> ////\n\n")

@ -1,20 +0,0 @@
-----BEGIN PUBLIC KEY-----
MIIDQjCCAjUGByqGSM44BAEwggIoAoIBAQCtdpbx5B26JDovYLaz8xTDDgnUk6kr
b6tBYIrYuAuqigNTvvUeg7UOGMQuZcrqiPgwEtrnKdVhV1WIY4wMYfM80cSeNuH5
ySHIS/JzMXls2704oeJeQtULIhxIvCLtaoQzHEAZrU3M+EBXnBviaztmWJQnlETh
z9CXWiRV1WlNWKo3xh7p18hw8NtpBMR7m0h6el9BVEXl+vXcmr+nzUIxAcVnurC6
x4AKoUDhZ1ZBux+FagKjP10/Xgas9q0WHA9FMhQlWoj2G7Yki1+ab7SxXgNF2QWg
qmk6paq81vexML4ZuWkFEsNWUgUthEBXtdiYrJCkTfrMxMPyOGCJ8VrdAh0ApeOr
1X5AZOvw9rqzu7VuLbLstInrW87e98nQAQKCAQA6wpsWw6vR6bjTCqEP8veH71Co
rghx/wQV4SJnZYxHF056LojxYfJd9XOjcr2Wi7o/EaZ1aeuQU5BlV4W98AUgWS0r
Ex8KEWuQ1jcbxF2E6APFmK2MVcFfisNpeNF6srkIQwwYyZZwkQcdZ0VOfknYGm6t
0lF98pl4eiPc5o9O+x+JyRiAKKo0RdzgIs0VLX/C48JWZBu7AD54Rn6hW3xiNn2n
oJQUkVuQTdDcrm69wtxpAGlteVmrHeS8MsijW35JyH7/t4K7PGgreEQTjFI7c9eR
RnAzM8aw0x4yzoKPWd2pi26XZtqRnw41l2nrPIjE0qicRFE+2MQgeAggDdqxA4IB
BQACggEAcwfo6hAcV2mzSAETAz9qveBu3emtUjd78nbdfDowkJhzzuCUiBwvZGoe
V8NkNOMFYmQbTtledb5v6llVtbKYS3y30rsj8g4HCxqB9zmIPMnEW8ZhtE4LjKXa
7oMUMB6pJWOWIwyyAT+BFNqJ7ObbB1amXhPlcJe1eSg1NN26bQ4jqiEzg3kCbAOC
hWR0hZinopAekCwYzseR7zhU2PimXvcdYhg3J1nmuOpPeFfUqKM7qzsRLZny3eOz
igTFEbo0XDx09Hfx6eLEEi4eAt6UOT6rozdJvqba+fjQCT3xWc0FzI/Mq2i/LQqq
DXT6eKt+fG92xURh3KPJbt2E9H/T3Q==
-----END PUBLIC KEY-----

@ -1,84 +0,0 @@
import {main as bakedInMain} from './indexer.mjs'
import {readFile} from 'fs/promises'
import {join} from "path"
import chalk from "chalk"
import parseDataUrl from "data-urls"
import publicKeyDataUrl from "./smva-indexer-release.pub.pem"
import envPaths from "env-paths"
import configName from "consts:configName"
import {signatureGuardStart, signatureGuardEnd, algorithm, absolutize} from "./signing-common.mjs"
import {createPublicKey, createVerify} from "crypto"
const publicKey = createPublicKey({
key: parseDataUrl(publicKeyDataUrl).body,
format: "pem"
})
async function validateSignature(input) {
if (!input.subarray(0, signatureGuardStart.length).equals(signatureGuardStart)) {
throw new Error(`File does not start with ${JSON.stringify(signatureGuardStart.toString())}`)
}
const signatureLength = input.subarray(signatureGuardStart.length).indexOf(signatureGuardEnd)
if (signatureLength === -1) {
throw new Error(`Signature does not end with ${JSON.stringify(signatureGuardEnd.toString())}`)
}
const verify = createVerify(algorithm)
const signedContent = input.subarray(signatureGuardStart.length + signatureLength + signatureGuardEnd.length)
verify.update(signedContent)
const signature = input.subarray(signatureGuardStart.length, signatureGuardStart.length + signatureLength).toString("utf-8")
if (!verify.verify(publicKey, signature, "base64")) {
throw new Error(`Signature was incorrect`)
}
}
async function tryFiles(paths) {
for (const path of paths) {
try {
const input = await readFile(path)
if (!process.env.SMVA_INDEXER_IGNORE_SIGNATURES) {
await validateSignature(input)
}
const {main: result} = await import(absolutize(path))
if (result !== bakedInMain) {
process.stderr.write(chalk.cyan(`Loaded code for ${chalk.cyanBright.bold(`v${result.version}`)} from ${chalk.cyanBright.bold(path)}\n`))
return result
}
} catch (ex) {
if (ex.code === "ENOENT" || ex.code === "EACCES") {
// That's okay, just move on to the next file
} else {
process.stderr.write(chalk.red(`Error while checking for an updated bundle at ${chalk.redBright.bold(path)}: ${chalk.redBright.bold(ex)}\n`))
}
}
}
// None of the files both existed AND were valid.
return null
}
const bundleFilename = "smva-indexer-bundle-signed.js"
async function start() {
const bundlesToTry = []
const cwdBundle = join(process.cwd(), bundleFilename)
if (!bundlesToTry.includes(cwdBundle) && !process.env.SMVA_INDEXER_IGNORE_CWD_BUNDLE) {
bundlesToTry.push(cwdBundle)
}
const besideBundle = join(module.path, bundleFilename)
if (!bundlesToTry.includes(besideBundle) && !process.env.SMVA_INDEXER_IGNORE_NEARBY_BUNDLE) {
bundlesToTry.push(besideBundle)
}
const configBundle = join(envPaths(configName).config, bundleFilename)
if (!bundlesToTry.includes(configBundle) && !process.env.SMVA_INDEXER_IGNORE_CONFIG_BUNDLE) {
bundlesToTry.push(configBundle)
}
const loadedMain = process.env.SMVA_INDEXER_IGNORE_ALL_BUNDLES ? null : await tryFiles(bundlesToTry);
await (loadedMain || bakedInMain)()
}
if (!global.main) {
global.main = true
start()
}
export const main = bakedInMain;
Loading…
Cancel
Save