v1.1.1 - big upgrade

Includes:
1) Better error handling and move support
2) An upgradeable launcher with signatures to avoid having to download 70MB just
   to get the latest 200kb of javascript
3) Data stored in a data file instead of in the program, to allow for modifications
main
Mari 3 years ago
parent d26b916890
commit 70d0bb7491
  1. 3
      .gitignore
  2. 12
      .idea/runConfigurations/build_signer.xml
  3. 24
      .idea/runConfigurations/sign.xml
  4. 1
      .idea/smva-indexer.iml
  5. 112
      default-data.json
  6. 460
      indexer.mjs
  7. 9088
      package-lock.json
  8. 30
      package.json
  9. 8
      publish-commands.sftp
  10. 36
      rollup.config.js
  11. 62
      sign-bundle.mjs
  12. 32
      signer-rollup.config.js
  13. 16
      signing-common.mjs
  14. 20
      smva-indexer-release.pub.pem
  15. 84
      updateable-launcher.mjs

3
.gitignore vendored

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

@ -0,0 +1,12 @@
<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>

@ -0,0 +1,24 @@
<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,6 +5,7 @@
<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" />

@ -0,0 +1,112 @@
{
"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,56 +1,206 @@
import enquirer from "enquirer" import enquirer from "enquirer"
import {watch} from "fs" import {watch, constants as fsConstants} from "fs"
import {access, readFile, opendir, rename, stat, writeFile} from "fs/promises" import {access, readFile, opendir, stat, writeFile} from "fs/promises"
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} from "path" import {isAbsolute, join, normalize, basename, sep} 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 "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} = enquirer const {Toggle, Select, Input, AutoComplete} = enquirer
const {R_OK, W_OK} = fsConstants
function log(message) { function log(message) {
process.stderr.write(message + "\n") process.stderr.write(message + "\n")
} }
async function getDirectoryFromUser(message, initial) { function validateFilenamePart(part, {allowEmpty = false} = {}) {
while (true) { if (typeof part !== "string") {
const newDir = normalize(await new Input({ return `must be a string, but was ${part === null ? "null" : typeof part}`
message, initial }
}).run()) if (part.length === 0) {
if (!isAbsolute(newDir)) { if (allowEmpty) {
log(chalk.red("Can't use that folder: This path must be absolute. Try again.")) return true
continue } 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 { try {
const statResult = await stat(newDir) const statResult = await stat(path)
if (!statResult.isDirectory()) { if (!statResult.isDirectory()) {
log(chalk.red("Can't use that folder: This path must be a directory. Try again.")) return DIRECTORY_EXISTS_NOT_DIRECTORY
} }
return newDir await access(path, R_OK | W_OK)
} catch (ex) { } catch (ex) {
if (ex.code === "ENOENT") { 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) {
let dirState = null
let newDir = initial
while (dirState !== DIRECTORY_OK) {
newDir = normalize(await new Input({
message,
initial: newDir,
validate: (text) => {
const pathErr = validatePath(text)
if (pathErr === DIRECTORY_OK) {
return true
} else {
return pathErr
}
}
}).run())
try {
dirState = await validateDirectory(newDir)
} catch (ex) {
log(chalk.red(`Couldn't check on that folder: ${chalk.redBright.bold(ex)}. Try again.`))
dirState = null
}
if (dirState === DIRECTORY_NOT_EXISTS) {
const create = await new Toggle({ const create = await new Toggle({
message: "That folder doesn't exist. Want me to create it?", message: "That folder doesn't exist. Want me to create it?",
enabled: "Create it", enabled: "Yes, create it",
disabled: "No, I'll enter the path again", disabled: "No, I'll enter the path again",
}).run() }).run()
if (!create) { if (create) {
continue
}
try { try {
await makeDir(newDir) await makeDir(newDir)
return newDir try {
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) {
@ -74,24 +224,62 @@ async function loadPaths(config, home) {
} }
} }
} }
if (!(sourceDir && destDir)) { const sourceDirOK = (await validateDirectory(sourceDir)) === DIRECTORY_OK
log(chalk.yellow(`Configuration at ${chalk.yellowBright.bold(configPath)} is missing or incomplete. Configure now.`)) const destDirOK = (await validateDirectory(destDir)) === DIRECTORY_OK
if (!(sourceDirOK && destDirOK)) {
log(chalk.yellow(`Configuration at ${chalk.yellowBright.bold(configPath)} is missing, incomplete, or damaged. Configuring now.`))
if (!sourceDirOK) {
sourceDir = await getDirectoryFromUser( sourceDir = await getDirectoryFromUser(
"Where are SMVA saves downloaded to? (e.g., your Downloads folder)", "Where are SMVA saves downloaded to? (e.g., your Downloads folder)",
sourceDir || join(home, "Downloads")) sourceDir || join(home, "Downloads"))
}
if (!destDirOK) {
destDir = await getDirectoryFromUser( destDir = await getDirectoryFromUser(
"Where do you want to store your organized SMVA saves? (e.g., your Documents folder)", "Where do you want to store your organized SMVA saves? (e.g., your Documents folder)",
destDir || join(home, "Documents", "SMVA Saves") destDir || join(home, "Documents", "SMVA Saves")
) )
}
try {
await makeDir(config) await makeDir(config)
await writeFile(configPath, ini.encode({paths: {source: sourceDir, dest: destDir}}), {encoding: "utf-8"}) await writeFile(configPath, ini.encode({paths: {source: sourceDir, dest: destDir}}), {encoding: "utf-8"})
log(chalk.cyan(`Wrote configuration to ${chalk.cyanBright.bold(configPath)}`)) 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
} }
return {sourceDir, destDir} } 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) { async function* watchNewSaves(sourceDir, sourceRegexpString) {
const sourceRe = /^saint-miluinas-vore-academy-.*\.save$/ const sourceRe = new RegExp(`^${sourceRegexpString}\$`)
let resolveNext let resolveNext
let nextPromise let nextPromise
let active = true let active = true
@ -104,6 +292,7 @@ async function* watchNewSaves(sourceDir) {
} }
}) })
} }
makeNewPromise() makeNewPromise()
watch(sourceDir, {}, (eventType, filename) => { watch(sourceDir, {}, (eventType, filename) => {
@ -112,7 +301,7 @@ async function* watchNewSaves(sourceDir) {
} }
}) })
log(chalk.green(`Waiting for new saves to appear in ${chalk.greenBright.bold(sourceDir)}`)) log(chalk.green(`Waiting for new saves with filenames like ${chalk.greenBright.bold(`/${sourceRegexpString}/`)} to appear in ${chalk.greenBright.bold(sourceDir)}`))
while (true) { while (true) {
const next = await nextPromise const next = await nextPromise
@ -122,10 +311,10 @@ async function* watchNewSaves(sourceDir) {
} }
} }
async function* findExistingSaves(sourceDir) { async function* findExistingSaves(sourceDir, sourceRegexpString) {
const sourceRe = /^saint-miluinas-vore-academy-.*\.save$/ const sourceRe = new RegExp(`^${sourceRegexpString}\$`)
log(chalk.green(`Scanning for existing saves in ${chalk.greenBright.bold(sourceDir)}`)) log(chalk.green(`Scanning for existing saves with filenames like ${chalk.greenBright.bold(`/${sourceRegexpString}/`)} in ${chalk.greenBright.bold(sourceDir)}`))
const dir = await opendir(sourceDir); const dir = await opendir(sourceDir)
for await (const dirent of dir) { for await (const dirent of dir) {
if (dirent.isFile() && sourceRe.test(dirent.name)) { if (dirent.isFile() && sourceRe.test(dirent.name)) {
yield dirent.name yield dirent.name
@ -134,70 +323,113 @@ async function* findExistingSaves(sourceDir) {
log(chalk.cyan(`Finished scanning for existing saves`)) log(chalk.cyan(`Finished scanning for existing saves`))
} }
async function promptForFilename() { async function promptForFilename({
const saveType = await new Select({ times,
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?",
choices: ["Useful Save", "Vore Scene", "Skip This Save"], limit: 10,
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 dayPrompt = new Select({ const timePrompt = times.length > 1 ? new AutoComplete({
message: "When is this save from?", message: "When is this save from?",
choices: ["Day 1", "Day 2", "Day 3", "Day 4", "Day 5", "Day 6"] limit: 10,
}) choices: times
}) : {
async run() {
return times[0]
}
}
if (saveType === "Vore Scene") { if (saveType === "Vore Scene") {
const voreType = await new Select({ const voreType = voreTypes.length > 1 ? await new AutoComplete({
message: "What type of vore is involved in this save?", message: "What type of vore is involved in this save?",
choices: ["Oral", "Anal", "Unbirth"] limit: 10,
}).run() choices: voreTypes
const voreRole = await new Select({ }).run() : voreTypes[0]
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?",
choices: ["Predator", "Prey", "Observer"] limit: 10,
}).run() choices: voreRoles
const voreResult = await new Select({ }).run() : voreRoles[0]
const voreResult = voreResults.length > 1 ? await new AutoComplete({
message: "What is the result of the vore?", message: "What is the result of the vore?",
choices: ["Fatal", "Always Fatal", "Non-fatal"], limit: 10,
}).run() choices: voreResults,
const day = await dayPrompt.run() }).run() : voreResults[0]
const vorePartner = await new Input({ const time = await timePrompt.run()
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?",
}).run() limit: 10,
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? (leave blank to skip)", message: `What is the variant name/comment for this scene?${vorePartners.length > 0 ? " (leave blank to skip)" : ""}`,
validate: (filename) => validateFilenamePart(filename, {allowEmpty: vorePartners.length > 0})
}).run() }).run()
return `${voreType} ${voreRole} - ${voreResult} - ${day} ${vorePartner}${voreVariant ? ` (${voreVariant})` : ""}.save` return `${voreType} ${voreRole} - ${voreResult} - ${time} ${vorePartners.join(" + ")}${voreVariant ? vorePartners.length > 0 ? ` (${voreVariant})` : voreVariant : ""}.save`
} else if (saveType === "Useful Save") { } else if (saveType === "Useful Save") {
const day = await dayPrompt.run() const time = await timePrompt.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 title of this useful save?" message: `What is the comment for this ${sceneType} scene?`,
validate: validateFilenamePart
}).run() }).run()
return `Useful Saves - ${day} - ${title}.save` return `${sceneType} - ${time} - ${title}.save`
} else { } else {
return null return null
} }
} }
async function shouldDoRename(destPath) { async function askIfOverwrite(destPath) {
try {
await access(destPath)
console.log(chalk.yellow(`A file named ${chalk.yellowBright.bold(destPath)} already exists.`)) console.log(chalk.yellow(`A file named ${chalk.yellowBright.bold(destPath)} already exists.`))
} catch (ex) { const result = await new Select({
if (ex.code === "ENOENT") { message: "What should we do?",
return true choices: ["Skip it", "Rename it", "Overwrite it"]
} else { }).run()
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({message: "What would you like to name the file?", initial: basename(destPath, ".save")}).run()) + ".save" return (await new Input({
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) { function queueMovingTo(destDir, filenameData) {
let resolvePromise, rejectPromise let resolvePromise, rejectPromise
const completionPromise = new Promise((resolve, reject) => { const completionPromise = new Promise((resolve, reject) => {
resolvePromise = resolve resolvePromise = resolve
@ -205,6 +437,7 @@ function queueMovingTo(destDir) {
}) })
const filequeue = [] const filequeue = []
let processingFiles = false let processingFiles = false
let endAfterCompleting = false
async function processFiles() { async function processFiles() {
if (processingFiles) { if (processingFiles) {
@ -223,37 +456,52 @@ function queueMovingTo(destDir) {
continue continue
} }
log(chalk.green(`For save ${chalk.greenBright.bold(nextFile)}:`)) log(chalk.green(`For save ${chalk.greenBright.bold(nextFile)}:`))
const destFilename = await promptForFilename() const destFilename = await promptForFilename(filenameData)
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 confirmed = false; let overwrite = false
while (!confirmed) { while (true) {
const promptResult = await shouldDoRename(destPath) try {
await moveFile(nextFile, destPath, {overwrite})
log(chalk.cyan(`Moved to ${chalk.cyanBright.bold(destPath)}`))
break
} catch (ex) {
if (ex.message.startsWith("The destination file exists:")) {
const promptResult = await askIfOverwrite(destPath)
if (typeof promptResult === "string") { if (typeof promptResult === "string") {
destPath = join(destDir, promptResult) destPath = join(destDir, promptResult)
} else if (!promptResult) { } else if (promptResult === true) {
destPath = null overwrite = true
confirmed = true
} else { } else {
confirmed = true 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) {
@ -273,19 +521,34 @@ function queueMovingTo(destDir) {
rejectPromise = null rejectPromise = null
}) })
}, },
shutDown() {
if (!resolvePromise) {
return
}
if (processingFiles) {
endAfterCompleting = true
} else {
resolvePromise()
}
},
completionPromise completionPromise
} }
} }
async function main() { export async function main() {
const {config} = envPaths("SMVA-Indexer") log(chalk.green(`${packageData.name} ${chalk.greenBright.bold(`v${packageData.version}`)} - built at ${chalk.greenBright.bold(new Date(buildTimestamp).toISOString())}`))
const {config} = envPaths(configName)
const home = homeDir() const home = homeDir()
const {sourceDir, destDir} = await loadPaths(config, home) const {
const queue = queueMovingTo(destDir) sourceDir,
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)) { for await (const oldSave of findExistingSaves(sourceDir, fileRegExp)) {
queue.push(join(sourceDir, oldSave)) queue.push(join(sourceDir, oldSave))
} }
} catch (ex) { } catch (ex) {
@ -294,17 +557,24 @@ async function main() {
})() })()
const newSavesPromise = (async () => { const newSavesPromise = (async () => {
for await (const newSave of watchNewSaves(sourceDir)) { try {
for await (const newSave of watchNewSaves(sourceDir, fileRegExp)) {
queue.push(join(sourceDir, 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())
try { try {
await Promise.all([newSavesPromise, existingSavesPromise, queue.completionPromise]) await queue.completionPromise
} catch (err) { } catch (err) {
console.log(chalk.redBright.bold(err)); console.log(chalk.redBright.bold(err))
process.exit(1); process.exit(1)
} }
process.exit(0); process.exit(0)
} }
main() main.packageName = packageData.name
main.version = packageData.version
main.buildTimestamp = packageData.buildTimestamp

9088
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -1,19 +1,26 @@
{ {
"name": "smva-indexer", "name": "smva-indexer",
"version": "1.0.1", "version": "1.1.1",
"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 build/smva-indexer-bundle.js",
"build": "npm run build-rollup && npm run build-nexe", "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-rollup": "rollup -c rollup.config.js -f commonjs indexer.mjs", "build-signed": "concurrently -n Rollup,Signer -c green,red \"npm run build-rollup\" \"npm run build-signer\" && npm run sign",
"build-nexe": "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-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": {
@ -23,19 +30,28 @@
"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",
"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",
"source-map-support": "^0.5.21" "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"
} }
} }

@ -0,0 +1,8 @@
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,23 +1,45 @@
import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import alias from '@rollup/plugin-alias'; 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 {signatureGuardStart, signatureGuardEnd} from "./signing-common.mjs"
export default { export default {
input: 'indexer.js', input: 'updateable-launcher.mjs',
output: { output: {
format: 'cjs', format: 'cjs',
file: 'build/smva-indexer-bundle.js', file: 'build/smva-indexer-bundle.js',
sourcemap: 'inline', 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', 'fs', 'path', 'process', 'os', 'tty'],
plugins: [nodeResolve({ plugins: [
exportConditions: ["node"], alias({
}), commonjs(), alias({
entries: [ entries: [
{ find: 'node:path', replacement: 'path' }, { find: 'node:path', replacement: 'path' },
{ find: 'node:process', replacement: 'process' }, { find: 'node:process', replacement: 'process' },
{ find: 'node:os', replacement: 'os' }, { find: 'node:os', replacement: 'os' },
{ find: 'node:tty', replacement: 'tty' }, { find: 'node:tty', replacement: 'tty' },
{ find: 'node:fs', replacement: 'fs' },
] ]
})] }),
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,
}),]
}; };

@ -0,0 +1,62 @@
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()
}

@ -0,0 +1,32 @@
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,
}),]
};

@ -0,0 +1,16 @@
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")

@ -0,0 +1,20 @@
-----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-----

@ -0,0 +1,84 @@
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