@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,44 @@
1
+ import { ZodType } from "zod";
2
+
3
+ type ZodNode = { type: string, def: Record<string, unknown> };
4
+
5
+ /**
6
+ * Renders a compact, human-readable sketch of a zod schema's shape (e.g.
7
+ * `{ name: string, count: number | "*" }`), for embedding in --help text so users
8
+ * know what a --json input needs to look like without reading source.
9
+ */
10
+ export function describeSchemaShape(schema: ZodType): string {
11
+ return describe(schema as unknown as ZodNode);
12
+ }
13
+
14
+ function describe(node: ZodNode): string {
15
+ switch(node.type){
16
+ case "object": {
17
+ const shape = node.def.shape as Record<string, ZodNode>;
18
+ const parts = Object.entries(shape).map(([key, value]) => {
19
+ const isOptional = value.type === "optional";
20
+ const inner = isOptional ? (value.def.innerType as ZodNode) : value;
21
+ return `${key}${isOptional ? "?" : ""}: ${describe(inner)}`;
22
+ });
23
+ return `{ ${parts.join(", ")} }`;
24
+ }
25
+ case "array":
26
+ return `${describe(node.def.element as ZodNode)}[]`;
27
+ case "tuple":
28
+ return `[${(node.def.items as Array<ZodNode>).map(describe).join(", ")}]`;
29
+ case "union":
30
+ return (node.def.options as Array<ZodNode>).map(describe).join(" | ");
31
+ case "enum":
32
+ return Object.values(node.def.entries as Record<string, string>).map((v) => JSON.stringify(v)).join(" | ");
33
+ case "literal":
34
+ return (node.def.values as Array<unknown>).map((v) => JSON.stringify(v)).join(" | ");
35
+ case "record":
36
+ return `Record<${describe(node.def.keyType as ZodNode)}, ${describe(node.def.valueType as ZodNode)}>`;
37
+ case "optional":
38
+ return `${describe(node.def.innerType as ZodNode)}?`;
39
+ case "lazy":
40
+ return "<recursive>";
41
+ default:
42
+ return node.type;
43
+ }
44
+ }
@@ -0,0 +1,100 @@
1
+ import { z } from "zod";
2
+
3
+ // Shape validation for JSON loaded via --json flags, before it's merged into the
4
+ // staged lock. This is deliberately looser than the full lock schema in
5
+ // @roster-lock/shared (AJV/JSON-schema, which also encodes cross-field domain
6
+ // rules like requirement cycles or piece-meta consistency) - it exists to give a
7
+ // fast, readable error for a malformed CLI argument, before that full check runs
8
+ // against the whole staged lock.
9
+
10
+ const countSchema = z.union([
11
+ z.number().int(),
12
+ z.literal("*"),
13
+ z.tuple([z.number().int(), z.union([z.number().int(), z.literal("*")])]),
14
+ ]);
15
+
16
+ const assetSchema = z.object({
17
+ name: z.string(),
18
+ classification: z.enum(["logic", "media", "doc"]),
19
+ count: countSchema,
20
+ glob: z.array(z.string()),
21
+ }).strict();
22
+
23
+ export const pieceDefinitionSchema = z.object({
24
+ selectionStrategy: z.enum(["mandatory", "personal", "shared", "on demand"]),
25
+ requires: z.array(z.string()),
26
+ pathVariables: z.array(z.string()),
27
+ assets: z.array(assetSchema),
28
+ }).strict();
29
+
30
+ const pieceHumanInfoSchema = z.object({
31
+ name: z.string(),
32
+ author: z.string(),
33
+ url: z.string(),
34
+ image: z.string().optional(),
35
+ }).strict();
36
+
37
+ // Used by "roster add-piece"/"roster edit-piece" --json: an alternative to the
38
+ // discrete --path-variables/--download-source/--name/etc flags. Only covers
39
+ // additive fields (set humanInfo, add downloadSources/pathVariables) - removing a
40
+ // download source or path variable still requires edit-piece's discrete flags.
41
+ export const pieceOverridesSchema = z.object({
42
+ humanInfo: pieceHumanInfoSchema.optional(),
43
+ downloadSources: z.array(z.string()).optional(),
44
+ pathVariables: z.record(z.string(), z.string()).optional(),
45
+ }).strict();
46
+
47
+ const untrustedScriptRefSchema = z.object({
48
+ src: z.string(),
49
+ method: z.string().optional(),
50
+ }).strict();
51
+
52
+ const jsonShallowValueSchema = z.union([
53
+ z.string(), z.number(), z.boolean(),
54
+ z.array(z.string()), z.array(z.number()), z.array(z.boolean()),
55
+ ]);
56
+ const jsonShallowObjectSchema = z.record(z.string(), jsonShallowValueSchema);
57
+
58
+ // Used by "piece-meta set" --json: the top-level per-piece-type custom fields
59
+ // (kept separate from the selection config so a selection config stays roster-agnostic).
60
+ export const pieceMetaOverridesSchema = z.object({
61
+ schema: z.record(z.string(), z.enum(["boolean", "number", "string", "boolean[]", "number[]", "string[]"])).optional(),
62
+ defaultMeta: jsonShallowObjectSchema.optional(),
63
+ // Record<PieceId, Partial<Config>> - a record's values are already independently
64
+ // optional per key, so this is the same shape as the record itself.
65
+ values: z.record(z.string(), jsonShallowObjectSchema).optional(),
66
+ }).strict();
67
+
68
+ type SelectedPieceInput = {
69
+ id: string,
70
+ required: Record<string, { mandatory: Array<SelectedPieceInput>, selectable: Array<SelectedPieceInput> }>,
71
+ };
72
+ const selectedPieceSchema: z.ZodType<SelectedPieceInput> = z.lazy(() => z.object({
73
+ id: z.string(),
74
+ required: z.record(z.string(), z.object({
75
+ mandatory: z.array(selectedPieceSchema),
76
+ selectable: z.array(selectedPieceSchema),
77
+ }).strict()),
78
+ }).strict());
79
+
80
+ const userSelectionValidationSchema = z.object({
81
+ count: countSchema,
82
+ unique: z.boolean(),
83
+ banList: z.array(z.string()),
84
+ customValidation: z.array(untrustedScriptRefSchema),
85
+ }).strict();
86
+
87
+ // Keyed by the `--type` option on "selection set"; each schema covers the fields
88
+ // mergeable on top of that type's template (its own template already supplies
89
+ // `type`, so it stays optional here).
90
+ export const selectionOverridesSchemas = {
91
+ normal: z.object({
92
+ validation: userSelectionValidationSchema.optional(),
93
+ mergeAlgorithm: untrustedScriptRefSchema.optional(),
94
+ }).strict(),
95
+ preselected: z.object({
96
+ pieces: z.array(selectedPieceSchema).optional(),
97
+ }).strict(),
98
+ unselectable: z.object({}).strict(),
99
+ "game-controlled": z.object({}).strict(),
100
+ } as const;
@@ -0,0 +1,11 @@
1
+ import { SemVer } from "@roster-lock/shared";
2
+
3
+ export function printSemverReasons(semver: SemVer): void {
4
+ const reasons = semver.getReasons();
5
+ for(const level of ["major", "minor", "patch"] as const){
6
+ for(const { reason, info } of reasons[level]){
7
+ console.log(`[${level}] ${reason} ${JSON.stringify(info)}`);
8
+ }
9
+ }
10
+ console.log(`Version: ${semver.toString()}`);
11
+ }
@@ -0,0 +1,9 @@
1
+ export function slugify(text: string): string {
2
+ return text
3
+ .toLowerCase()
4
+ .trim()
5
+ .replace(/[\s_]+/g, '-')
6
+ .replace(/[^a-z0-9-]+/g, '')
7
+ .replace(/^-+|-+$/g, '')
8
+ .replace(/-+/g, '-');
9
+ }
@@ -0,0 +1,62 @@
1
+ {
2
+ "configIdentity": {
3
+ "namespace": "roster-lock",
4
+ "purpose": "draft",
5
+ "version": 1
6
+ },
7
+ "previousLock": {
8
+ "configIdentity": {
9
+ "namespace": "roster-lock",
10
+ "purpose": "lock",
11
+ "version": 1
12
+ },
13
+ "author": "",
14
+ "title": "",
15
+ "version": "0.0.0",
16
+ "engine": {
17
+ "name": "",
18
+ "version": "",
19
+ "pieceDefinitions": {}
20
+ },
21
+ "rosters": {},
22
+ "selection": {
23
+ "piece": {},
24
+ "globalValidation": [],
25
+ "scriptDictionary": {}
26
+ }
27
+ },
28
+ "stagedLock": {
29
+ "configIdentity": {
30
+ "namespace": "roster-lock",
31
+ "purpose": "lock",
32
+ "version": 1
33
+ },
34
+ "author": "",
35
+ "title": "Test Game",
36
+ "version": "0.0.0",
37
+ "engine": {
38
+ "name": "",
39
+ "version": "",
40
+ "pieceDefinitions": {
41
+ "character": {
42
+ "selectionStrategy": "mandatory",
43
+ "requires": [],
44
+ "pathVariables": [],
45
+ "assets": []
46
+ }
47
+ }
48
+ },
49
+ "rosters": {
50
+ "character": []
51
+ },
52
+ "selection": {
53
+ "piece": {},
54
+ "globalValidation": [],
55
+ "scriptDictionary": {}
56
+ }
57
+ },
58
+ "draft": {
59
+ "rosterPieceInfo": {},
60
+ "selectionScriptInfo": {}
61
+ }
62
+ }
@@ -0,0 +1,92 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join as pathJoin } from "node:path";
5
+ import { EMPTY_ROSTER_DRAFT } from "@roster-lock/shared";
6
+ import { cloneJSON } from "@roster-lock/utils";
7
+ import { RosterLockV1Draft } from "@roster-lock/types";
8
+
9
+ // Repeatable options (--download-source) accumulate into a default array captured
10
+ // once at `.option()`-declare time, so reusing one Command instance across
11
+ // parseAsync() calls leaks values between tests - see edit-piece.spec.ts. Re-import
12
+ // fresh for every call.
13
+ async function runAddPiece(args: Array<string>){
14
+ vi.resetModules();
15
+ const { addPieceCommand } = await import("../../src/commands/roster/add-piece.js");
16
+
17
+ process.exitCode = undefined;
18
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
19
+ await addPieceCommand.parseAsync(["node", "add-piece", ...args]);
20
+ const logs = logSpy.mock.calls.map((call) => call.join(" "));
21
+ logSpy.mockRestore();
22
+ const exitCode = process.exitCode;
23
+ process.exitCode = undefined;
24
+ return { logs, exitCode };
25
+ }
26
+
27
+ describe("roster add-piece", () => {
28
+ let tempDir: string;
29
+ let draftPath: string;
30
+ let pieceFolder: string;
31
+
32
+ beforeEach(() => {
33
+ tempDir = mkdtempSync(pathJoin(tmpdir(), "rosterlock-add-piece-"));
34
+ draftPath = pathJoin(tempDir, "test.rosterlock.draft.json");
35
+ pieceFolder = pathJoin(tempDir, "blue-character");
36
+ mkdirSync(pieceFolder);
37
+ writeFileSync(pathJoin(pieceFolder, "sprite.png"), "fake-png-bytes");
38
+
39
+ const draft: RosterLockV1Draft = cloneJSON(EMPTY_ROSTER_DRAFT);
40
+ draft.stagedLock.engine.pieceDefinitions["character"] = {
41
+ selectionStrategy: "mandatory",
42
+ requires: [],
43
+ pathVariables: [],
44
+ assets: [{ name: "sprite", classification: "media", count: 1, glob: ["*.png"] }],
45
+ };
46
+ draft.stagedLock.rosters["character"] = [];
47
+ writeFileSync(draftPath, JSON.stringify(draft));
48
+ });
49
+
50
+ afterEach(() => {
51
+ rmSync(tempDir, { recursive: true, force: true });
52
+ });
53
+
54
+ function readPiece(){
55
+ const draft: RosterLockV1Draft = JSON.parse(readFileSync(draftPath, "utf-8"));
56
+ return draft.stagedLock.rosters["character"][0];
57
+ }
58
+
59
+ it("--json sets humanInfo and additional download sources", async () => {
60
+ const jsonPath = pathJoin(tempDir, "overrides.json");
61
+ writeFileSync(jsonPath, JSON.stringify({
62
+ humanInfo: { name: "Blue", author: "author", url: "https://example.com/blue" },
63
+ downloadSources: ["https://example.com/blue.tar"],
64
+ }));
65
+
66
+ await runAddPiece(["character", pieceFolder, "--draft", draftPath, "--json", jsonPath]);
67
+
68
+ const piece = readPiece();
69
+ expect(piece.humanInfo).toEqual({ name: "Blue", author: "author", url: "https://example.com/blue" });
70
+ expect(piece.downloadSources).toEqual(["https://example.com/blue.tar"]);
71
+ expect(piece.id).toBe("@author/blue");
72
+ });
73
+
74
+ it("still refuses to add a piece with no download source, --json or otherwise", async () => {
75
+ const jsonPath = pathJoin(tempDir, "overrides.json");
76
+ writeFileSync(jsonPath, JSON.stringify({ humanInfo: { name: "Blue", author: "author", url: "https://example.com/blue" } }));
77
+
78
+ const { exitCode } = await runAddPiece(["character", pieceFolder, "--draft", draftPath, "--json", jsonPath]);
79
+ expect(exitCode).toBe(1);
80
+ });
81
+
82
+ it("rejects --json with an unrecognized field", async () => {
83
+ const jsonPath = pathJoin(tempDir, "bad-overrides.json");
84
+ writeFileSync(jsonPath, JSON.stringify({ notARealField: true }));
85
+
86
+ const { exitCode } = await runAddPiece([
87
+ "character", pieceFolder, "--draft", draftPath, "--json", jsonPath,
88
+ "--download-source", "https://example.com/blue.tar",
89
+ ]);
90
+ expect(exitCode).toBe(1);
91
+ });
92
+ });
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join as pathJoin } from "node:path";
5
+ import { EMPTY_ROSTER_DRAFT } from "@roster-lock/shared";
6
+ import { cloneJSON } from "@roster-lock/utils";
7
+ import { RosterLockV1Draft } from "@roster-lock/types";
8
+ import { draftValidateCommand } from "../../src/commands/draft/validate";
9
+
10
+ async function runValidate(draftPath: string){
11
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
12
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
13
+ process.exitCode = undefined;
14
+
15
+ await draftValidateCommand.parseAsync(["node", "draft-validate", "--draft", draftPath]);
16
+
17
+ const result = {
18
+ exitCode: process.exitCode,
19
+ logs: logSpy.mock.calls.map((call) => call.join(" ")),
20
+ errors: errorSpy.mock.calls.map((call) => call.join(" ")),
21
+ };
22
+ logSpy.mockRestore();
23
+ errorSpy.mockRestore();
24
+ process.exitCode = undefined;
25
+ return result;
26
+ }
27
+
28
+ describe("draft validate", () => {
29
+ let tempDir: string;
30
+ let draftPath: string;
31
+
32
+ beforeEach(() => {
33
+ tempDir = mkdtempSync(pathJoin(tmpdir(), "rosterlock-draft-validate-"));
34
+ draftPath = pathJoin(tempDir, "test.rosterlock.draft.json");
35
+ });
36
+
37
+ afterEach(() => {
38
+ rmSync(tempDir, { recursive: true, force: true });
39
+ });
40
+
41
+ function writeDraftFixture(draft: RosterLockV1Draft){
42
+ writeFileSync(draftPath, JSON.stringify(draft));
43
+ }
44
+
45
+ it("reports OK and exit code 0 for a valid (empty) staged lock", async () => {
46
+ writeDraftFixture(cloneJSON(EMPTY_ROSTER_DRAFT));
47
+
48
+ const { exitCode, logs } = await runValidate(draftPath);
49
+
50
+ expect(exitCode).toBeUndefined();
51
+ expect(logs.join("\n")).toMatch(/OK/);
52
+ });
53
+
54
+ it("reports every issue at once for an invalid staged lock, not just the first", async () => {
55
+ const draft = cloneJSON(EMPTY_ROSTER_DRAFT);
56
+ draft.stagedLock.engine.pieceDefinitions["character"] = {
57
+ selectionStrategy: "mandatory",
58
+ requires: ["missing-type"],
59
+ pathVariables: [],
60
+ assets: [
61
+ { name: "sprite", classification: "media", count: 1, glob: ["*.png"] },
62
+ { name: "sprite", classification: "media", count: 1, glob: ["*.jpg"] },
63
+ ],
64
+ };
65
+ draft.stagedLock.rosters["character"] = [];
66
+ writeDraftFixture(draft);
67
+
68
+ const { exitCode, errors } = await runValidate(draftPath);
69
+ const output = errors.join("\n");
70
+
71
+ expect(exitCode).toBe(1);
72
+ // A single first-error-only validator would stop after the "requires" issue;
73
+ // both requirement AND duplicate-asset-name issues must be present together.
74
+ expect(output).toMatch(/missing-type/);
75
+ expect(output).toMatch(/Duplicate name/);
76
+ expect((output.match(/Duplicate name/g) ?? []).length).toBe(2);
77
+ });
78
+
79
+ it("propagates draft-resolution errors (e.g. missing draft file) via formatError", async () => {
80
+ const { exitCode, errors } = await runValidate(pathJoin(tempDir, "does-not-exist.rosterlock.draft.json"));
81
+
82
+ expect(exitCode).toBe(1);
83
+ expect(errors.join("\n")).toMatch(/ENOENT|no such file/i);
84
+ });
85
+ });
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join as pathJoin } from "node:path";
5
+ import { EMPTY_ROSTER_DRAFT } from "@roster-lock/shared";
6
+ import { cloneJSON } from "@roster-lock/utils";
7
+ import { RosterLockV1Draft } from "@roster-lock/types";
8
+ import { editDefinitionCommand } from "../../src/commands/engine/edit-definition";
9
+
10
+ async function runEditDefinition(args: Array<string>){
11
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
12
+ process.exitCode = undefined;
13
+ let thrown: unknown;
14
+ try {
15
+ await editDefinitionCommand.parseAsync(["node", "edit-definition", ...args]);
16
+ } catch(e){
17
+ thrown = e;
18
+ }
19
+ const logs = logSpy.mock.calls.map((call) => call.join(" "));
20
+ logSpy.mockRestore();
21
+ return { logs, thrown };
22
+ }
23
+
24
+ describe("engine edit-definition", () => {
25
+ let tempDir: string;
26
+ let draftPath: string;
27
+
28
+ beforeEach(() => {
29
+ tempDir = mkdtempSync(pathJoin(tmpdir(), "rosterlock-edit-definition-"));
30
+ draftPath = pathJoin(tempDir, "test.rosterlock.draft.json");
31
+
32
+ const draft: RosterLockV1Draft = cloneJSON(EMPTY_ROSTER_DRAFT);
33
+ draft.stagedLock.engine.pieceDefinitions["character"] = {
34
+ selectionStrategy: "mandatory",
35
+ requires: [],
36
+ pathVariables: [],
37
+ assets: [{ name: "sprite", classification: "media", count: 1, glob: ["*.png"] }],
38
+ };
39
+ draft.stagedLock.rosters["character"] = [];
40
+ writeFileSync(draftPath, JSON.stringify(draft));
41
+ });
42
+
43
+ afterEach(() => {
44
+ rmSync(tempDir, { recursive: true, force: true });
45
+ });
46
+
47
+ function readDraftFile(): RosterLockV1Draft {
48
+ return JSON.parse(readFileSync(draftPath, "utf-8"));
49
+ }
50
+
51
+ it("updates the selection strategy while leaving assets untouched", async () => {
52
+ await runEditDefinition(["character", "--draft", draftPath, "--strategy", "shared"]);
53
+
54
+ const definition = readDraftFile().stagedLock.engine.pieceDefinitions["character"];
55
+ expect(definition.selectionStrategy).toBe("shared");
56
+ expect(definition.assets).toHaveLength(1);
57
+ });
58
+
59
+ it("replaces the path-variables list", async () => {
60
+ await runEditDefinition(["character", "--draft", draftPath, "--path-variables", "region,skin"]);
61
+
62
+ const definition = readDraftFile().stagedLock.engine.pieceDefinitions["character"];
63
+ expect(definition.pathVariables).toEqual(["region", "skin"]);
64
+ });
65
+
66
+ it("rejects an unknown piece type", async () => {
67
+ const { thrown } = await runEditDefinition(["ghost", "--draft", draftPath, "--strategy", "shared"]);
68
+ expect(thrown).toBeUndefined(); // withErrorHandling swallows and sets exitCode instead
69
+ expect(process.exitCode).toBe(1);
70
+ process.exitCode = undefined;
71
+ });
72
+
73
+ it("rejects a no-op call with no flags given", async () => {
74
+ await runEditDefinition(["character", "--draft", draftPath]);
75
+ expect(process.exitCode).toBe(1);
76
+ process.exitCode = undefined;
77
+ });
78
+
79
+ it("--json replaces the full definition", async () => {
80
+ const jsonPath = pathJoin(tempDir, "def.json");
81
+ writeFileSync(jsonPath, JSON.stringify({
82
+ selectionStrategy: "personal",
83
+ requires: [],
84
+ pathVariables: [],
85
+ assets: [],
86
+ }));
87
+
88
+ await runEditDefinition(["character", "--draft", draftPath, "--json", jsonPath]);
89
+
90
+ const definition = readDraftFile().stagedLock.engine.pieceDefinitions["character"];
91
+ expect(definition.selectionStrategy).toBe("personal");
92
+ expect(definition.assets).toEqual([]);
93
+ });
94
+ });
@@ -0,0 +1,153 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join as pathJoin } from "node:path";
5
+ import { EMPTY_ROSTER_DRAFT } from "@roster-lock/shared";
6
+ import { cloneJSON } from "@roster-lock/utils";
7
+ import { RosterLockV1Draft } from "@roster-lock/types";
8
+
9
+ // Repeatable options (--add-download-source etc.) accumulate into a default array
10
+ // captured once at `.option()`-declare time, so reusing one Command instance across
11
+ // parseAsync() calls leaks values between tests. A real CLI process only parses once,
12
+ // so this only matters here: re-import the module fresh for every call to get a clean
13
+ // Command instance and options.
14
+ async function runEditPiece(args: Array<string>){
15
+ vi.resetModules();
16
+ const { editPieceCommand } = await import("../../src/commands/roster/edit-piece.js");
17
+
18
+ process.exitCode = undefined;
19
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
20
+ await editPieceCommand.parseAsync(["node", "edit-piece", ...args]);
21
+ const logs = logSpy.mock.calls.map((call) => call.join(" "));
22
+ logSpy.mockRestore();
23
+ const exitCode = process.exitCode;
24
+ process.exitCode = undefined;
25
+ return { logs, exitCode };
26
+ }
27
+
28
+ describe("roster edit-piece", () => {
29
+ let tempDir: string;
30
+ let draftPath: string;
31
+
32
+ beforeEach(() => {
33
+ tempDir = mkdtempSync(pathJoin(tmpdir(), "rosterlock-edit-piece-"));
34
+ draftPath = pathJoin(tempDir, "test.rosterlock.draft.json");
35
+
36
+ const draft: RosterLockV1Draft = cloneJSON(EMPTY_ROSTER_DRAFT);
37
+ draft.stagedLock.engine.pieceDefinitions["character"] = {
38
+ selectionStrategy: "mandatory",
39
+ requires: [],
40
+ pathVariables: ["skin"],
41
+ assets: [{ name: "sprite", classification: "media", count: 1, glob: ["{skin}/*.png"] }],
42
+ };
43
+ draft.stagedLock.rosters["character"] = [{
44
+ id: "@author/blue",
45
+ version: { logic: "a".repeat(64), media: "b".repeat(64), docs: "c".repeat(64) },
46
+ humanInfo: { name: "Blue", author: "author", url: "https://example.com/blue" },
47
+ downloadSources: ["https://example.com/blue.tar"],
48
+ pathVariables: { skin: "blue" },
49
+ requiredPieces: {},
50
+ }];
51
+ writeFileSync(draftPath, JSON.stringify(draft));
52
+ });
53
+
54
+ afterEach(() => {
55
+ rmSync(tempDir, { recursive: true, force: true });
56
+ });
57
+
58
+ function readPiece(){
59
+ const draft: RosterLockV1Draft = JSON.parse(readFileSync(draftPath, "utf-8"));
60
+ return draft.stagedLock.rosters["character"][0];
61
+ }
62
+
63
+ it("updates human info fields", async () => {
64
+ await runEditPiece(["character", "@author/blue", "--draft", draftPath, "--name", "Bluey", "--author", "someone-else"]);
65
+
66
+ const piece = readPiece();
67
+ expect(piece.humanInfo.name).toBe("Bluey");
68
+ expect(piece.humanInfo.author).toBe("someone-else");
69
+ expect(piece.humanInfo.url).toBe("https://example.com/blue");
70
+ });
71
+
72
+ it("adds and removes download sources", async () => {
73
+ await runEditPiece([
74
+ "character", "@author/blue", "--draft", draftPath,
75
+ "--add-download-source", "https://mirror.example.com/blue.tar",
76
+ "--remove-download-source", "https://example.com/blue.tar",
77
+ ]);
78
+
79
+ const piece = readPiece();
80
+ expect(piece.downloadSources).toEqual(["https://mirror.example.com/blue.tar"]);
81
+ });
82
+
83
+ it("refuses to remove the last download source", async () => {
84
+ const { exitCode } = await runEditPiece([
85
+ "character", "@author/blue", "--draft", draftPath,
86
+ "--remove-download-source", "https://example.com/blue.tar",
87
+ ]);
88
+ expect(exitCode).toBe(1);
89
+ });
90
+
91
+ it("refuses to remove a download source that isn't present", async () => {
92
+ const { exitCode } = await runEditPiece([
93
+ "character", "@author/blue", "--draft", draftPath,
94
+ "--remove-download-source", "https://not-there.example.com/x.tar",
95
+ ]);
96
+ expect(exitCode).toBe(1);
97
+ });
98
+
99
+ it("updates path variables, leaving unmentioned keys alone", async () => {
100
+ await runEditPiece(["character", "@author/blue", "--draft", draftPath, "--path-variables", "skin=red"]);
101
+ expect(readPiece().pathVariables).toEqual({ skin: "red" });
102
+ });
103
+
104
+ it("rejects removing a path variable the piece definition still requires", async () => {
105
+ const { exitCode } = await runEditPiece([
106
+ "character", "@author/blue", "--draft", draftPath, "--remove-path-variable", "skin",
107
+ ]);
108
+ expect(exitCode).toBe(1);
109
+ });
110
+
111
+ it("rejects a no-op call", async () => {
112
+ const { exitCode } = await runEditPiece(["character", "@author/blue", "--draft", draftPath]);
113
+ expect(exitCode).toBe(1);
114
+ });
115
+
116
+ it("rejects an unknown piece id", async () => {
117
+ const { exitCode } = await runEditPiece(["character", "@author/ghost", "--draft", draftPath, "--name", "X"]);
118
+ expect(exitCode).toBe(1);
119
+ });
120
+
121
+ it("--json bulk-sets humanInfo/downloadSources/pathVariables", async () => {
122
+ const jsonPath = pathJoin(tempDir, "overrides.json");
123
+ writeFileSync(jsonPath, JSON.stringify({
124
+ humanInfo: { name: "Bluey", author: "someone-else", url: "https://example.com/bluey" },
125
+ downloadSources: ["https://mirror.example.com/blue.tar"],
126
+ pathVariables: { skin: "red" },
127
+ }));
128
+
129
+ await runEditPiece(["character", "@author/blue", "--draft", draftPath, "--json", jsonPath]);
130
+
131
+ const piece = readPiece();
132
+ expect(piece.humanInfo).toEqual({ name: "Bluey", author: "someone-else", url: "https://example.com/bluey" });
133
+ expect(piece.downloadSources).toEqual(["https://example.com/blue.tar", "https://mirror.example.com/blue.tar"]);
134
+ expect(piece.pathVariables).toEqual({ skin: "red" });
135
+ });
136
+
137
+ it("a specific flag overrides --json for the same field", async () => {
138
+ const jsonPath = pathJoin(tempDir, "overrides.json");
139
+ writeFileSync(jsonPath, JSON.stringify({ humanInfo: { name: "FromJson", author: "author", url: "https://example.com/blue" } }));
140
+
141
+ await runEditPiece(["character", "@author/blue", "--draft", draftPath, "--json", jsonPath, "--name", "FromFlag"]);
142
+
143
+ expect(readPiece().humanInfo.name).toBe("FromFlag");
144
+ });
145
+
146
+ it("rejects --json with an unrecognized field", async () => {
147
+ const jsonPath = pathJoin(tempDir, "bad-overrides.json");
148
+ writeFileSync(jsonPath, JSON.stringify({ notARealField: true }));
149
+
150
+ const { exitCode } = await runEditPiece(["character", "@author/blue", "--draft", draftPath, "--json", jsonPath]);
151
+ expect(exitCode).toBe(1);
152
+ });
153
+ });