@roster-lock/config-editor-cli 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/.claude-plugin/plugin.json +6 -0
  2. package/LICENSE.md +11 -0
  3. package/README.md +40 -0
  4. package/dist/commands/draft/from-lock.js +31 -0
  5. package/dist/commands/draft/from-lock.js.map +1 -0
  6. package/dist/commands/draft/index.js +22 -0
  7. package/dist/commands/draft/index.js.map +1 -0
  8. package/dist/commands/draft/init.js +25 -0
  9. package/dist/commands/draft/init.js.map +1 -0
  10. package/dist/commands/draft/promote.js +24 -0
  11. package/dist/commands/draft/promote.js.map +1 -0
  12. package/dist/commands/draft/publish.js +26 -0
  13. package/dist/commands/draft/publish.js.map +1 -0
  14. package/dist/commands/draft/validate.js +30 -0
  15. package/dist/commands/draft/validate.js.map +1 -0
  16. package/dist/commands/engine/add-asset.js +44 -0
  17. package/dist/commands/engine/add-asset.js.map +1 -0
  18. package/dist/commands/engine/add-piece-type.js +47 -0
  19. package/dist/commands/engine/add-piece-type.js.map +1 -0
  20. package/dist/commands/engine/edit-definition.js +49 -0
  21. package/dist/commands/engine/edit-definition.js.map +1 -0
  22. package/dist/commands/engine/index.js +24 -0
  23. package/dist/commands/engine/index.js.map +1 -0
  24. package/dist/commands/engine/remove-asset.js +27 -0
  25. package/dist/commands/engine/remove-asset.js.map +1 -0
  26. package/dist/commands/engine/remove-piece-type.js +28 -0
  27. package/dist/commands/engine/remove-piece-type.js.map +1 -0
  28. package/dist/commands/engine/set-info.js +22 -0
  29. package/dist/commands/engine/set-info.js.map +1 -0
  30. package/dist/commands/engine/show.js +23 -0
  31. package/dist/commands/engine/show.js.map +1 -0
  32. package/dist/commands/piece-meta/index.js +19 -0
  33. package/dist/commands/piece-meta/index.js.map +1 -0
  34. package/dist/commands/piece-meta/set.js +27 -0
  35. package/dist/commands/piece-meta/set.js.map +1 -0
  36. package/dist/commands/piece-meta/show.js +15 -0
  37. package/dist/commands/piece-meta/show.js.map +1 -0
  38. package/dist/commands/roster/add-piece.js +93 -0
  39. package/dist/commands/roster/add-piece.js.map +1 -0
  40. package/dist/commands/roster/edit-piece.js +92 -0
  41. package/dist/commands/roster/edit-piece.js.map +1 -0
  42. package/dist/commands/roster/import-piece.js +83 -0
  43. package/dist/commands/roster/import-piece.js.map +1 -0
  44. package/dist/commands/roster/index.js +24 -0
  45. package/dist/commands/roster/index.js.map +1 -0
  46. package/dist/commands/roster/list.js +24 -0
  47. package/dist/commands/roster/list.js.map +1 -0
  48. package/dist/commands/roster/remove-piece.js +30 -0
  49. package/dist/commands/roster/remove-piece.js.map +1 -0
  50. package/dist/commands/roster/rescan.js +55 -0
  51. package/dist/commands/roster/rescan.js.map +1 -0
  52. package/dist/commands/roster/test-download.js +72 -0
  53. package/dist/commands/roster/test-download.js.map +1 -0
  54. package/dist/commands/selection/add-script.js +43 -0
  55. package/dist/commands/selection/add-script.js.map +1 -0
  56. package/dist/commands/selection/index.js +21 -0
  57. package/dist/commands/selection/index.js.map +1 -0
  58. package/dist/commands/selection/run-script.js +27 -0
  59. package/dist/commands/selection/run-script.js.map +1 -0
  60. package/dist/commands/selection/set.js +41 -0
  61. package/dist/commands/selection/set.js.map +1 -0
  62. package/dist/commands/selection/show.js +15 -0
  63. package/dist/commands/selection/show.js.map +1 -0
  64. package/dist/commands/show.js +15 -0
  65. package/dist/commands/show.js.map +1 -0
  66. package/dist/commands/validate.js +31 -0
  67. package/dist/commands/validate.js.map +1 -0
  68. package/dist/index.js +55 -0
  69. package/dist/index.js.map +1 -0
  70. package/dist/lib/cli-options.js +7 -0
  71. package/dist/lib/cli-options.js.map +1 -0
  72. package/dist/lib/draft-io.js +38 -0
  73. package/dist/lib/draft-io.js.map +1 -0
  74. package/dist/lib/errors.js +32 -0
  75. package/dist/lib/errors.js.map +1 -0
  76. package/dist/lib/fs-walk.js +28 -0
  77. package/dist/lib/fs-walk.js.map +1 -0
  78. package/dist/lib/json-input.js +16 -0
  79. package/dist/lib/json-input.js.map +1 -0
  80. package/dist/lib/lock-io.js +20 -0
  81. package/dist/lib/lock-io.js.map +1 -0
  82. package/dist/lib/parse.js +52 -0
  83. package/dist/lib/parse.js.map +1 -0
  84. package/dist/lib/piece-scan.js +12 -0
  85. package/dist/lib/piece-scan.js.map +1 -0
  86. package/dist/lib/schema-help.js +38 -0
  87. package/dist/lib/schema-help.js.map +1 -0
  88. package/dist/lib/schemas.js +71 -0
  89. package/dist/lib/schemas.js.map +1 -0
  90. package/dist/lib/semver-report.js +13 -0
  91. package/dist/lib/semver-report.js.map +1 -0
  92. package/dist/lib/slugify.js +13 -0
  93. package/dist/lib/slugify.js.map +1 -0
  94. package/dist/version.js +5 -0
  95. package/dist/version.js.map +1 -0
  96. package/package.json +49 -0
  97. package/scripts/bundle.js +15 -0
  98. package/scripts/generate-version.js +14 -0
  99. package/skills/rosterlock-config-editor/SKILL.md +151 -0
  100. package/src/commands/draft/from-lock.ts +34 -0
  101. package/src/commands/draft/index.ts +5 -0
  102. package/src/commands/draft/init.ts +21 -0
  103. package/src/commands/draft/promote.ts +24 -0
  104. package/src/commands/draft/publish.ts +26 -0
  105. package/src/commands/draft/validate.ts +30 -0
  106. package/src/commands/engine/add-asset.ts +46 -0
  107. package/src/commands/engine/add-piece-type.ts +56 -0
  108. package/src/commands/engine/edit-definition.ts +58 -0
  109. package/src/commands/engine/index.ts +7 -0
  110. package/src/commands/engine/remove-asset.ts +25 -0
  111. package/src/commands/engine/remove-piece-type.ts +27 -0
  112. package/src/commands/engine/set-info.ts +17 -0
  113. package/src/commands/engine/show.ts +20 -0
  114. package/src/commands/piece-meta/index.ts +2 -0
  115. package/src/commands/piece-meta/set.ts +29 -0
  116. package/src/commands/piece-meta/show.ts +12 -0
  117. package/src/commands/roster/add-piece.ts +109 -0
  118. package/src/commands/roster/edit-piece.ts +99 -0
  119. package/src/commands/roster/import-piece.ts +112 -0
  120. package/src/commands/roster/index.ts +7 -0
  121. package/src/commands/roster/list.ts +23 -0
  122. package/src/commands/roster/remove-piece.ts +28 -0
  123. package/src/commands/roster/rescan.ts +63 -0
  124. package/src/commands/roster/test-download.ts +76 -0
  125. package/src/commands/selection/add-script.ts +44 -0
  126. package/src/commands/selection/index.ts +4 -0
  127. package/src/commands/selection/run-script.ts +30 -0
  128. package/src/commands/selection/set.ts +54 -0
  129. package/src/commands/selection/show.ts +12 -0
  130. package/src/commands/show.ts +12 -0
  131. package/src/commands/validate.ts +26 -0
  132. package/src/index.ts +85 -0
  133. package/src/lib/cli-options.ts +8 -0
  134. package/src/lib/draft-io.ts +41 -0
  135. package/src/lib/errors.ts +30 -0
  136. package/src/lib/fs-walk.ts +31 -0
  137. package/src/lib/json-input.ts +21 -0
  138. package/src/lib/lock-io.ts +18 -0
  139. package/src/lib/parse.ts +42 -0
  140. package/src/lib/piece-scan.ts +19 -0
  141. package/src/lib/schema-help.ts +44 -0
  142. package/src/lib/schemas.ts +100 -0
  143. package/src/lib/semver-report.ts +11 -0
  144. package/src/lib/slugify.ts +9 -0
  145. package/test.rosterlock.draft.json +62 -0
  146. package/tests/commands/add-piece.spec.ts +92 -0
  147. package/tests/commands/draft-validate.spec.ts +85 -0
  148. package/tests/commands/edit-definition.spec.ts +94 -0
  149. package/tests/commands/edit-piece.spec.ts +153 -0
  150. package/tests/commands/from-lock.spec.ts +80 -0
  151. package/tests/commands/help-arguments.spec.ts +31 -0
  152. package/tests/commands/import-piece.spec.ts +228 -0
  153. package/tests/commands/piece-meta.spec.ts +123 -0
  154. package/tests/commands/rescan.spec.ts +137 -0
  155. package/tests/lib/draft-io.spec.ts +76 -0
  156. package/tests/lib/errors.spec.ts +48 -0
  157. package/tests/lib/fs-walk.spec.ts +35 -0
  158. package/tests/lib/json-input.spec.ts +63 -0
  159. package/tests/lib/parse.spec.ts +85 -0
  160. package/tests/lib/schema-help.spec.ts +42 -0
  161. package/tests/lib/schemas.spec.ts +82 -0
  162. package/tests/lib/slugify.spec.ts +28 -0
  163. package/tsconfig.json +19 -0
  164. package/tsconfig.test.json +10 -0
@@ -0,0 +1,63 @@
1
+ import { Command } from "commander";
2
+ import { ROSTERLOCK_V1_CASTER_JSONSCHEMA } from "@roster-lock/shared";
3
+ import { resolveDraftPath, readDraft, writeDraft } from "../../lib/draft-io";
4
+ import { withDraftOption } from "../../lib/cli-options";
5
+ import { withErrorHandling } from "../../lib/errors";
6
+ import { parseKeyValueList } from "../../lib/parse";
7
+ import { scanPieceFolder } from "../../lib/piece-scan";
8
+
9
+ export const rescanCommand = withDraftOption(new Command("rescan"))
10
+ .description(
11
+ "Recompute an existing roster piece's asset hashes from its folder (e.g. after adding new asset files or " +
12
+ "changing an asset definition), without touching its id, humanInfo, downloadSources, or requiredPieces"
13
+ )
14
+ .argument("<pieceType>", "the piece type key")
15
+ .argument("<pieceId>", "the piece's id (as shown by \"roster list\")")
16
+ .argument("[folder]", "path to the folder to scan (defaults to the folder used by the last \"roster add-piece\"/\"roster rescan\")")
17
+ .option("--path-variables <k=v,...>", "path variable values used to resolve this piece's asset globs (defaults to the piece's existing path variables)")
18
+ .action(withErrorHandling(async (pieceType: string, pieceId: string, folderArg: string | undefined, opts: {
19
+ draft?: string, pathVariables?: string
20
+ }) => {
21
+ const draftPath = resolveDraftPath(opts.draft);
22
+ const draft = readDraft(draftPath);
23
+
24
+ const pieceDefinition = draft.stagedLock.engine.pieceDefinitions[pieceType];
25
+ if(!pieceDefinition) throw new Error(`Unknown piece type "${pieceType}"`);
26
+
27
+ const collection = draft.stagedLock.rosters[pieceType];
28
+ if(!collection) throw new Error(`Unknown piece type "${pieceType}"`);
29
+ const piece = collection.find((p) => p.id === pieceId);
30
+ if(!piece) throw new Error(`Unknown piece "${pieceId}" in "${pieceType}"`);
31
+
32
+ const pieceInfo = draft.draft.rosterPieceInfo[pieceType]?.[pieceId];
33
+ const folder = folderArg ?? pieceInfo?.referenceFolder;
34
+ if(!folder){
35
+ throw new Error(
36
+ `No folder recorded for piece "${pieceId}"; pass one explicitly: ` +
37
+ `roster rescan ${pieceType} ${pieceId} <folder>`
38
+ );
39
+ }
40
+
41
+ const pathVariables = opts.pathVariables !== undefined ? parseKeyValueList(opts.pathVariables) : piece.pathVariables;
42
+ const { version, errors } = await scanPieceFolder(folder, pathVariables, pieceDefinition);
43
+ if(errors.length > 0){
44
+ for(const err of errors) console.error(`[${err.type}] ${err.id}: ${err.message}`);
45
+ throw new Error(`Folder does not match piece type "${pieceType}"'s asset definitions`);
46
+ }
47
+
48
+ const oldVersion = piece.version;
49
+ piece.version = version;
50
+ ROSTERLOCK_V1_CASTER_JSONSCHEMA.cast(draft.stagedLock);
51
+
52
+ draft.draft.rosterPieceInfo[pieceType] ??= {};
53
+ draft.draft.rosterPieceInfo[pieceType][pieceId] = {
54
+ referenceFolder: folder,
55
+ testedDownloadSources: [],
56
+ };
57
+
58
+ writeDraft(draftPath, draft);
59
+ console.log(
60
+ `Rescanned piece "${pieceId}" in "${pieceType}": ` +
61
+ `logic ${oldVersion.logic}->${version.logic}, media ${oldVersion.media}->${version.media}, docs ${oldVersion.docs}->${version.docs}`
62
+ );
63
+ }));
@@ -0,0 +1,76 @@
1
+ import { Command } from "commander";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join as pathJoin } from "node:path";
5
+ import { downloadToFolder, DEFAULT_PLUGIN_DIR } from "@roster-lock/plugin-runtime";
6
+ import { resolveDraftPath, readDraft, writeDraft } from "../../lib/draft-io";
7
+ import { withDraftOption } from "../../lib/cli-options";
8
+ import { withErrorHandling } from "../../lib/errors";
9
+ import { scanPieceFolder } from "../../lib/piece-scan";
10
+
11
+ export const testDownloadCommand = withDraftOption(new Command("test-download"))
12
+ .description("Download a source over the network, verify its hash matches the piece, and record the test")
13
+ .argument("<pieceType>", "the piece type key")
14
+ .argument("<pieceId>", "the piece's id (as shown by \"roster list\")")
15
+ .argument("<sourceUrl>", "download source url to fetch and verify")
16
+ .action(withErrorHandling(async (pieceType: string, pieceId: string, sourceUrl: string, opts: { draft?: string }) => {
17
+ const draftPath = resolveDraftPath(opts.draft);
18
+ const draft = readDraft(draftPath);
19
+
20
+ const pieceDefinition = draft.stagedLock.engine.pieceDefinitions[pieceType];
21
+ if(!pieceDefinition) throw new Error(`Unknown piece type "${pieceType}"`);
22
+ const piece = draft.stagedLock.rosters[pieceType]?.find((p) => p.id === pieceId);
23
+ if(!piece) throw new Error(`Unknown piece "${pieceId}" in "${pieceType}"`);
24
+
25
+ const tempDir = await mkdtemp(pathJoin(tmpdir(), "rosterlock-test-download-"));
26
+ try {
27
+ const { finishPromise } = await downloadToFolder(DEFAULT_PLUGIN_DIR, {
28
+ url: sourceUrl,
29
+ destinationFolder: tempDir,
30
+ processHandlers: {
31
+ abortSignal: new AbortController().signal,
32
+ onProgress: (progress, total) => {
33
+ process.stderr.write(`\rDownloading... ${progress}${total ? `/${total}` : ""}`);
34
+ },
35
+ },
36
+ });
37
+ await finishPromise;
38
+ process.stderr.write("\n");
39
+
40
+ const { version, errors } = await scanPieceFolder(tempDir, piece.pathVariables, pieceDefinition);
41
+ if(errors.length > 0){
42
+ for(const err of errors) console.error(`[${err.type}] ${err.id}: ${err.message}`);
43
+ throw new Error("Downloaded folder does not match the piece type's asset definitions");
44
+ }
45
+
46
+ if(
47
+ version.logic !== piece.version.logic ||
48
+ version.media !== piece.version.media ||
49
+ version.docs !== piece.version.docs
50
+ ){
51
+ console.error("Version mismatch:");
52
+ console.error(` expected: ${JSON.stringify(piece.version)}`);
53
+ console.error(` actual: ${JSON.stringify(version)}`);
54
+ throw new Error(`Downloaded source does not match the recorded version for "${pieceId}"`);
55
+ }
56
+
57
+ if(!piece.downloadSources.includes(sourceUrl)){
58
+ piece.downloadSources.push(sourceUrl);
59
+ }
60
+
61
+ draft.draft.rosterPieceInfo[pieceType] ??= {};
62
+ const pieceInfo = draft.draft.rosterPieceInfo[pieceType][pieceId] ?? (
63
+ draft.draft.rosterPieceInfo[pieceType][pieceId] = { testedDownloadSources: [] }
64
+ );
65
+ pieceInfo.testedDownloadSources.push({
66
+ source: sourceUrl,
67
+ testedAt: Date.now(),
68
+ version,
69
+ });
70
+
71
+ writeDraft(draftPath, draft);
72
+ console.log(`Verified download source for "${pieceId}"`);
73
+ } finally {
74
+ await rm(tempDir, { recursive: true, force: true });
75
+ }
76
+ }));
@@ -0,0 +1,44 @@
1
+ import { Command } from "commander";
2
+ import { statSync, readFileSync } from "node:fs";
3
+ import { resolve, basename } from "node:path";
4
+ import { bufferToStr, createShaFromBuffer } from "@roster-lock/utils";
5
+ import { ROSTERLOCK_V1_CASTER_JSONSCHEMA } from "@roster-lock/shared";
6
+ import { resolveDraftPath, readDraft, writeDraft } from "../../lib/draft-io";
7
+ import { withDraftOption } from "../../lib/cli-options";
8
+ import { withErrorHandling } from "../../lib/errors";
9
+ import { walkRelative } from "../../lib/fs-walk";
10
+
11
+ export const addScriptCommand = withDraftOption(new Command("add-script"))
12
+ .description("Add one or more selection scripts (a single file, or every file in a folder) to the staged lock")
13
+ .argument("<fileOrFolder>", "path to a single script file, or a folder of script files")
14
+ .option("--key <relPath>", "script key to use when adding a single file (defaults to its basename)")
15
+ .action(withErrorHandling(async (fileOrFolder: string, opts: { draft?: string, key?: string }) => {
16
+ const draftPath = resolveDraftPath(opts.draft);
17
+ const draft = readDraft(draftPath);
18
+
19
+ const target = resolve(process.cwd(), fileOrFolder);
20
+ const stats = statSync(target);
21
+ const lastLoad = Date.now();
22
+
23
+ const entries: Array<{ key: string, absolutePath: string }> = [];
24
+ if(stats.isDirectory()){
25
+ for await (const relativePath of walkRelative(target)){
26
+ entries.push({ key: relativePath, absolutePath: resolve(target, relativePath) });
27
+ }
28
+ } else {
29
+ entries.push({ key: opts.key ?? basename(target), absolutePath: target });
30
+ }
31
+
32
+ for(const { key, absolutePath } of entries){
33
+ const contentRaw = readFileSync(absolutePath);
34
+ const content = bufferToStr(contentRaw);
35
+ const sha = await createShaFromBuffer(contentRaw);
36
+
37
+ draft.stagedLock.selection.scriptDictionary[key] = { content };
38
+ draft.draft.selectionScriptInfo[key] = { lastLoad, sha, referencePath: absolutePath };
39
+ }
40
+
41
+ ROSTERLOCK_V1_CASTER_JSONSCHEMA.cast(draft.stagedLock);
42
+ writeDraft(draftPath, draft);
43
+ console.log(`Added ${entries.length} script(s): ${entries.map((e) => e.key).join(", ")}`);
44
+ }));
@@ -0,0 +1,4 @@
1
+ export * from "./add-script";
2
+ export * from "./set";
3
+ export * from "./run-script";
4
+ export * from "./show";
@@ -0,0 +1,30 @@
1
+ import { Command } from "commander";
2
+ import { readFileSync } from "node:fs";
3
+ import { runUntrustedScript, DEFAULT_PLUGIN_DIR } from "@roster-lock/plugin-runtime";
4
+ import { ScriptPurposeInput } from "@roster-lock/types";
5
+ import { resolveDraftPath, readDraft } from "../../lib/draft-io";
6
+ import { withDraftOption } from "../../lib/cli-options";
7
+ import { withErrorHandling } from "../../lib/errors";
8
+
9
+ export const runScriptCommand = withDraftOption(new Command("run-script"))
10
+ .description("Run a selection script from the staged lock against sample input, without modifying the draft")
11
+ .argument("<scriptRef>", "the script's key in scriptDictionary")
12
+ .requiredOption("--input <jsonFile>", "JSON file with { purpose: ScriptPurposeInput, randomSeeds?: string[] }")
13
+ .option("--method <name>", "exported function to call (default: main)")
14
+ .action(withErrorHandling(async (scriptRef: string, opts: { draft?: string, input: string, method?: string }) => {
15
+ const draftPath = resolveDraftPath(opts.draft);
16
+ const draft = readDraft(draftPath);
17
+
18
+ const input = JSON.parse(readFileSync(opts.input, "utf-8")) as {
19
+ purpose: ScriptPurposeInput, randomSeeds?: Array<string>
20
+ };
21
+
22
+ const result = await runUntrustedScript(DEFAULT_PLUGIN_DIR, {
23
+ config: draft.stagedLock,
24
+ randomSeeds: input.randomSeeds ?? [],
25
+ purpose: input.purpose,
26
+ entryScript: { src: scriptRef, method: opts.method },
27
+ });
28
+
29
+ console.log(JSON.stringify(result, null, 2));
30
+ }));
@@ -0,0 +1,54 @@
1
+ import { Command } from "commander";
2
+ import { ZodType } from "zod";
3
+ import {
4
+ ROSTERLOCK_V1_CASTER_JSONSCHEMA,
5
+ EMPTY_ROSTER_NORMAL_SELECTION,
6
+ EMPTY_ROSTER_PRESELECTED_SELECTION,
7
+ EMPTY_ROSTER_UNSELECTABLE_SELECTION,
8
+ EMPTY_ROSTER_GAME_SELECTION,
9
+ } from "@roster-lock/shared";
10
+ import { cloneJSON } from "@roster-lock/utils";
11
+ import { resolveDraftPath, readDraft, writeDraft } from "../../lib/draft-io";
12
+ import { withDraftOption } from "../../lib/cli-options";
13
+ import { withErrorHandling } from "../../lib/errors";
14
+ import { readJsonInput } from "../../lib/json-input";
15
+ import { selectionOverridesSchemas } from "../../lib/schemas";
16
+ import { describeSchemaShape } from "../../lib/schema-help";
17
+
18
+ const TEMPLATES = {
19
+ normal: EMPTY_ROSTER_NORMAL_SELECTION,
20
+ preselected: EMPTY_ROSTER_PRESELECTED_SELECTION,
21
+ unselectable: EMPTY_ROSTER_UNSELECTABLE_SELECTION,
22
+ "game-controlled": EMPTY_ROSTER_GAME_SELECTION,
23
+ } as const;
24
+
25
+ const JSON_SHAPE_BY_TYPE = Object.entries(selectionOverridesSchemas)
26
+ .map(([type, schema]) => `${type}: ${describeSchemaShape(schema)}`)
27
+ .join("; ");
28
+
29
+ export const selectionSetCommand = withDraftOption(new Command("set"))
30
+ .description("Set the selection config for a piece type")
31
+ .argument("<pieceType>", "the piece type key")
32
+ .requiredOption("--type <type>", "normal|preselected|unselectable|game-controlled")
33
+ .option(
34
+ "--json <file>",
35
+ "type-specific fields to merge in, read from a JSON file (or \"-\" for stdin); shape depends on --type. " +
36
+ `Shapes: ${JSON_SHAPE_BY_TYPE}`
37
+ )
38
+ .action(withErrorHandling(async (pieceType: string, opts: { draft?: string, type: string, json?: string }) => {
39
+ const draftPath = resolveDraftPath(opts.draft);
40
+ const draft = readDraft(draftPath);
41
+
42
+ const template = TEMPLATES[opts.type as keyof typeof TEMPLATES];
43
+ if(!template) throw new Error(`Invalid --type "${opts.type}" (expected normal|preselected|unselectable|game-controlled)`);
44
+
45
+ const overridesSchema = selectionOverridesSchemas[opts.type as keyof typeof selectionOverridesSchemas] as (
46
+ ZodType<Record<string, unknown>>
47
+ );
48
+ const overrides = opts.json ? await readJsonInput(opts.json, overridesSchema) : {};
49
+ draft.stagedLock.selection.piece[pieceType] = { ...cloneJSON(template), ...overrides };
50
+
51
+ ROSTERLOCK_V1_CASTER_JSONSCHEMA.cast(draft.stagedLock);
52
+ writeDraft(draftPath, draft);
53
+ console.log(`Set selection for "${pieceType}" to "${opts.type}"`);
54
+ }));
@@ -0,0 +1,12 @@
1
+ import { Command } from "commander";
2
+ import { resolveDraftPath, readDraft } from "../../lib/draft-io";
3
+ import { withDraftOption } from "../../lib/cli-options";
4
+ import { withErrorHandling } from "../../lib/errors";
5
+
6
+ export const selectionShowCommand = withDraftOption(new Command("show"))
7
+ .description("Print the staged lock's selection config")
8
+ .action(withErrorHandling(async (opts: { draft?: string }) => {
9
+ const draftPath = resolveDraftPath(opts.draft);
10
+ const draft = readDraft(draftPath);
11
+ console.log(JSON.stringify(draft.stagedLock.selection, null, 2));
12
+ }));
@@ -0,0 +1,12 @@
1
+ import { Command } from "commander";
2
+ import { resolveDraftPath, readDraft } from "../lib/draft-io";
3
+ import { withDraftOption } from "../lib/cli-options";
4
+ import { withErrorHandling } from "../lib/errors";
5
+
6
+ export const showCommand = withDraftOption(new Command("show"))
7
+ .description("Print the draft's full staged lock as JSON")
8
+ .action(withErrorHandling(async (opts: { draft?: string }) => {
9
+ const draftPath = resolveDraftPath(opts.draft);
10
+ const draft = readDraft(draftPath);
11
+ console.log(JSON.stringify(draft.stagedLock, null, 2));
12
+ }));
@@ -0,0 +1,26 @@
1
+ import { Command } from "commander";
2
+ import { readFileSync } from "node:fs";
3
+ import { ROSTERLOCK_V1_DRAFT_CASTER_JSONSCHEMA, ROSTERLOCK_V1_CASTER_JSONSCHEMA } from "@roster-lock/shared";
4
+ import { resolveDraftPath } from "../lib/draft-io";
5
+ import { withDraftOption } from "../lib/cli-options";
6
+ import { formatError } from "../lib/errors";
7
+
8
+ export const validateCommand = withDraftOption(new Command("validate"))
9
+ .description("Validate a draft (default) or, with --lock, a published lock file")
10
+ .option("--lock <path>", "validate a plain lock file instead of a draft")
11
+ .action(async (opts: { draft?: string, lock?: string }) => {
12
+ try {
13
+ if(opts.lock){
14
+ const json = JSON.parse(readFileSync(opts.lock, "utf-8"));
15
+ ROSTERLOCK_V1_CASTER_JSONSCHEMA.cast(json, true);
16
+ } else {
17
+ const draftPath = resolveDraftPath(opts.draft);
18
+ const json = JSON.parse(readFileSync(draftPath, "utf-8"));
19
+ ROSTERLOCK_V1_DRAFT_CASTER_JSONSCHEMA.cast(json, true);
20
+ }
21
+ console.log("OK");
22
+ } catch(e){
23
+ console.error(formatError(e));
24
+ process.exitCode = 1;
25
+ }
26
+ });
package/src/index.ts ADDED
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ import { program, Command } from "commander";
3
+
4
+ import { initCommand, fromLockCommand, promoteCommand, publishCommand, draftValidateCommand } from "./commands/draft";
5
+ import {
6
+ setInfoCommand,
7
+ addPieceTypeCommand,
8
+ editDefinitionCommand,
9
+ addAssetCommand,
10
+ removePieceTypeCommand,
11
+ removeAssetCommand,
12
+ engineShowCommand,
13
+ } from "./commands/engine";
14
+ import {
15
+ addPieceCommand,
16
+ editPieceCommand,
17
+ importPieceCommand,
18
+ rescanCommand,
19
+ testDownloadCommand,
20
+ rosterListCommand,
21
+ removePieceCommand,
22
+ } from "./commands/roster";
23
+ import {
24
+ addScriptCommand,
25
+ selectionSetCommand,
26
+ runScriptCommand,
27
+ selectionShowCommand,
28
+ } from "./commands/selection";
29
+ import {
30
+ pieceMetaSetCommand,
31
+ pieceMetaShowCommand,
32
+ } from "./commands/piece-meta";
33
+ import { validateCommand } from "./commands/validate";
34
+ import { showCommand } from "./commands/show";
35
+ import { VERSION } from "./version";
36
+
37
+ program
38
+ .name("rosterlock-config-editor")
39
+ .description("A cli tool to build roster lock configs")
40
+ .version(VERSION);
41
+
42
+ const draftCommand = new Command("draft").description("Manage the draft lifecycle (create, promote, publish, validate)");
43
+ draftCommand.addCommand(initCommand);
44
+ draftCommand.addCommand(fromLockCommand);
45
+ draftCommand.addCommand(promoteCommand);
46
+ draftCommand.addCommand(publishCommand);
47
+ draftCommand.addCommand(draftValidateCommand);
48
+ program.addCommand(draftCommand);
49
+
50
+ program.addCommand(validateCommand);
51
+ program.addCommand(showCommand);
52
+
53
+ const engineCommand = new Command("engine").description("Edit the staged lock's engine (piece types)");
54
+ engineCommand.addCommand(setInfoCommand);
55
+ engineCommand.addCommand(addPieceTypeCommand);
56
+ engineCommand.addCommand(editDefinitionCommand);
57
+ engineCommand.addCommand(addAssetCommand);
58
+ engineCommand.addCommand(removePieceTypeCommand);
59
+ engineCommand.addCommand(removeAssetCommand);
60
+ engineCommand.addCommand(engineShowCommand);
61
+ program.addCommand(engineCommand);
62
+
63
+ const rosterCommand = new Command("roster").description("Edit the staged lock's rosters (pieces)");
64
+ rosterCommand.addCommand(addPieceCommand);
65
+ rosterCommand.addCommand(editPieceCommand);
66
+ rosterCommand.addCommand(importPieceCommand);
67
+ rosterCommand.addCommand(rescanCommand);
68
+ rosterCommand.addCommand(testDownloadCommand);
69
+ rosterCommand.addCommand(rosterListCommand);
70
+ rosterCommand.addCommand(removePieceCommand);
71
+ program.addCommand(rosterCommand);
72
+
73
+ const selectionCommand = new Command("selection").description("Edit the staged lock's selection config");
74
+ selectionCommand.addCommand(addScriptCommand);
75
+ selectionCommand.addCommand(selectionSetCommand);
76
+ selectionCommand.addCommand(runScriptCommand);
77
+ selectionCommand.addCommand(selectionShowCommand);
78
+ program.addCommand(selectionCommand);
79
+
80
+ const pieceMetaCommand = new Command("piece-meta").description("Edit the staged lock's piece meta (custom per-piece fields)");
81
+ pieceMetaCommand.addCommand(pieceMetaSetCommand);
82
+ pieceMetaCommand.addCommand(pieceMetaShowCommand);
83
+ program.addCommand(pieceMetaCommand);
84
+
85
+ program.parse();
@@ -0,0 +1,8 @@
1
+ import { Command } from "commander";
2
+
3
+ export function withDraftOption(cmd: Command): Command {
4
+ return cmd.option(
5
+ "-d, --draft <path>",
6
+ "path to the draft file (defaults to the sole *.rosterlock.draft.json file in the current directory)"
7
+ );
8
+ }
@@ -0,0 +1,41 @@
1
+ import { readFileSync, writeFileSync, readdirSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { ROSTERLOCK_V1_DRAFT_CASTER_JSONSCHEMA } from "@roster-lock/shared";
4
+ import { RosterLockV1Draft } from "@roster-lock/types";
5
+ import { slugify } from "./slugify";
6
+
7
+ export const DRAFT_SUFFIX = ".rosterlock.draft.json";
8
+
9
+ export function resolveDraftPath(explicit?: string): string {
10
+ if(explicit) return resolve(process.cwd(), explicit);
11
+
12
+ const cwd = process.cwd();
13
+ const candidates = readdirSync(cwd).filter((name) => name.endsWith(DRAFT_SUFFIX));
14
+
15
+ if(candidates.length === 0){
16
+ throw new Error(
17
+ `No draft file found in current directory (expected a *${DRAFT_SUFFIX} file); pass --draft <path>`
18
+ );
19
+ }
20
+ if(candidates.length > 1){
21
+ throw new Error(
22
+ `Multiple draft files found in current directory: ${candidates.join(", ")}; pass --draft <path> to choose one`
23
+ );
24
+ }
25
+ return resolve(cwd, candidates[0]);
26
+ }
27
+
28
+ export function defaultDraftPath(title?: string): string {
29
+ const slug = slugify(title && title.length > 0 ? title : "config") || "config";
30
+ return resolve(process.cwd(), `${slug}${DRAFT_SUFFIX}`);
31
+ }
32
+
33
+ export function readDraft(path: string): RosterLockV1Draft {
34
+ const json = JSON.parse(readFileSync(path, "utf-8"));
35
+ return ROSTERLOCK_V1_DRAFT_CASTER_JSONSCHEMA.cast(json, true);
36
+ }
37
+
38
+ export function writeDraft(path: string, draft: RosterLockV1Draft): void {
39
+ ROSTERLOCK_V1_DRAFT_CASTER_JSONSCHEMA.cast(draft, true);
40
+ writeFileSync(path, JSON.stringify(draft, null, 2) + "\n");
41
+ }
@@ -0,0 +1,30 @@
1
+ import { ErrorObject } from "ajv";
2
+ import { ZodError } from "zod";
3
+
4
+ export function formatError(e: unknown): string {
5
+ if(e instanceof ZodError){
6
+ return e.issues
7
+ .map((issue) => `/${issue.path.join("/")} ${issue.message}`)
8
+ .join("\n");
9
+ }
10
+ if(Array.isArray(e)){
11
+ return (e as Array<ErrorObject>)
12
+ .map((err) => `${err.instancePath || "/"} ${err.message}`)
13
+ .join("\n");
14
+ }
15
+ if(e instanceof Error) return e.message;
16
+ return String(e);
17
+ }
18
+
19
+ export function withErrorHandling<Args extends Array<any>>(
20
+ fn: (...args: Args) => Promise<void>
21
+ ) {
22
+ return async (...args: Args) => {
23
+ try {
24
+ await fn(...args);
25
+ } catch(e){
26
+ console.error(formatError(e));
27
+ process.exitCode = 1;
28
+ }
29
+ };
30
+ }
@@ -0,0 +1,31 @@
1
+ import { readdir, stat as fsStat } from "node:fs/promises";
2
+ import { createReadStream } from "node:fs";
3
+ import { join as pathJoin, relative as pathRelative, sep as pathSep } from "node:path";
4
+
5
+ /**
6
+ * Yields file paths relative to `root`, using forward slashes, matching the
7
+ * convention `getMatchingAssetsForFile` expects for glob matching.
8
+ */
9
+ export async function* walkRelative(root: string): AsyncIterable<string> {
10
+ yield* walk(root, root);
11
+ }
12
+
13
+ async function* walk(root: string, dir: string): AsyncIterable<string> {
14
+ const entries = await readdir(dir, { withFileTypes: true });
15
+ for(const entry of entries){
16
+ const fullPath = pathJoin(dir, entry.name);
17
+ if(entry.isDirectory()){
18
+ yield* walk(root, fullPath);
19
+ } else {
20
+ yield pathRelative(root, fullPath).split(pathSep).join("/");
21
+ }
22
+ }
23
+ }
24
+
25
+ export async function getFileFromRoot(
26
+ root: string, relativePath: string
27
+ ): Promise<{ byteSize: number, stream: AsyncIterable<Uint8Array> }> {
28
+ const fullPath = pathJoin(root, relativePath);
29
+ const stats = await fsStat(fullPath);
30
+ return { byteSize: stats.size, stream: createReadStream(fullPath) };
31
+ }
@@ -0,0 +1,21 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { ZodType } from "zod";
3
+
4
+ /**
5
+ * Reads, parses, and validates a JSON argument that names a file, or "-" to read
6
+ * from stdin (for use when the JSON is too large/awkward to pass as a file path,
7
+ * e.g. piped from another command). Requires a zod schema so untrusted JSON is
8
+ * never used until its shape is confirmed.
9
+ */
10
+ export async function readJsonInput<T>(source: string, schema: ZodType<T>): Promise<T> {
11
+ const raw = source === "-" ? await readStdin() : await readFile(source, "utf-8");
12
+ return schema.parse(JSON.parse(raw));
13
+ }
14
+
15
+ async function readStdin(): Promise<string> {
16
+ const chunks: Array<Buffer> = [];
17
+ for await (const chunk of process.stdin){
18
+ chunks.push(Buffer.from(chunk));
19
+ }
20
+ return Buffer.concat(chunks).toString("utf-8");
21
+ }
@@ -0,0 +1,18 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { ROSTERLOCK_V1_CASTER_JSONSCHEMA } from "@roster-lock/shared";
4
+ import { RosterLockV1Config } from "@roster-lock/types";
5
+
6
+ export function readLock(path: string): RosterLockV1Config {
7
+ const json = JSON.parse(readFileSync(resolve(process.cwd(), path), "utf-8"));
8
+ return ROSTERLOCK_V1_CASTER_JSONSCHEMA.cast(json, true);
9
+ }
10
+
11
+ export function writeLock(path: string, lock: RosterLockV1Config): void {
12
+ ROSTERLOCK_V1_CASTER_JSONSCHEMA.cast(lock, true);
13
+ writeFileSync(resolve(process.cwd(), path), JSON.stringify(lock, null, 2) + "\n");
14
+ }
15
+
16
+ export function defaultLockPath(title: string, version: string): string {
17
+ return resolve(process.cwd(), `${title}-${version}.rosterlock.json`);
18
+ }
@@ -0,0 +1,42 @@
1
+ import { Count } from "@roster-lock/types";
2
+
3
+ export function parseKeyValueList(value: string | undefined): Record<string, string> {
4
+ if(!value) return {};
5
+ const result: Record<string, string> = {};
6
+ for(const pair of value.split(",")){
7
+ const trimmed = pair.trim();
8
+ if(!trimmed) continue;
9
+ const eq = trimmed.indexOf("=");
10
+ if(eq === -1) throw new Error(`Invalid key=value pair: "${trimmed}"`);
11
+ result[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
12
+ }
13
+ return result;
14
+ }
15
+
16
+ export function parseCommaList(value: string | undefined): Array<string> {
17
+ if(!value) return [];
18
+ return value.split(",").map((v) => v.trim()).filter((v) => v.length > 0);
19
+ }
20
+
21
+ export function parseCount(value: string): Count {
22
+ if(value === "*") return "*";
23
+ if(value.includes(":")){
24
+ const [minRaw, maxRaw] = value.split(":");
25
+ const min = Number.parseInt(minRaw, 10);
26
+ if(Number.isNaN(min)) throw new Error(`Invalid count range: "${value}"`);
27
+ const max: number | "*" = maxRaw === "*" ? "*" : Number.parseInt(maxRaw, 10);
28
+ if(max !== "*" && Number.isNaN(max)) throw new Error(`Invalid count range: "${value}"`);
29
+ return [min, max];
30
+ }
31
+ const n = Number.parseInt(value, 10);
32
+ if(Number.isNaN(n)) throw new Error(`Invalid count: "${value}"`);
33
+ return n;
34
+ }
35
+
36
+ export function parseStrategy(value: string): "mandatory" | "personal" | "shared" | "on demand" {
37
+ const normalized = value === "on-demand" ? "on demand" : value;
38
+ if(!["mandatory", "personal", "shared", "on demand"].includes(normalized)){
39
+ throw new Error(`Invalid strategy: "${value}" (expected mandatory|personal|shared|on-demand)`);
40
+ }
41
+ return normalized as "mandatory" | "personal" | "shared" | "on demand";
42
+ }
@@ -0,0 +1,19 @@
1
+ import { getAssetsOfFiles, collectAssetFileErrors, calculatePieceVersion } from "@roster-lock/shared";
2
+ import { RosterLockV1Config } from "@roster-lock/types";
3
+ import { walkRelative, getFileFromRoot } from "./fs-walk";
4
+
5
+ type PieceDefinition = RosterLockV1Config["engine"]["pieceDefinitions"][string];
6
+
7
+ export async function scanPieceFolder(
8
+ folder: string,
9
+ pathVariables: Record<string, string>,
10
+ pieceDefinition: PieceDefinition,
11
+ ){
12
+ const assetInfo = await getAssetsOfFiles(walkRelative(folder), pathVariables, pieceDefinition);
13
+ const errors = collectAssetFileErrors(assetInfo);
14
+ const version = await calculatePieceVersion(
15
+ assetInfo.filesWithAssets,
16
+ (relativePath) => getFileFromRoot(folder, relativePath)
17
+ );
18
+ return { version, errors };
19
+ }