@smallpen/cli 0.1.0-alpha.1 → 0.1.0-alpha.11

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.
@@ -0,0 +1,512 @@
1
+ import { SmallPenError } from "@smallpen/core";
2
+ import {
3
+ COMMAND_CONTRACTS,
4
+ commandContract,
5
+ validateCommandArguments,
6
+ } from "./command-contract.mjs";
7
+
8
+ const action = (engine, purpose, extra = {}) => ({ engine, purpose, ...extra });
9
+
10
+ // Public command paths specialize the existing contracts and run the same
11
+ // engines. Every action names things the way outlines show them; only
12
+ // advanced apply (the undo batches writes return) and project repair (the
13
+ // choices it offers) carry IDs.
14
+ export const COMMAND_GROUPS = {
15
+ project: {
16
+ purpose: "Create, inspect and maintain a project",
17
+ actions: {
18
+ init: action("init", "Create a blank package or workspace"),
19
+ show: action("inspect", "Read the package name, revision and counts"),
20
+ list: action("list", "Locate project objects by kind"),
21
+ watch: action("watch", "Watch package revision changes"),
22
+ repair: action("repair", "Inspect conflicts or apply an offered repair"),
23
+ migrate: action("migrate-themes", "Copy a package with migrated themes"),
24
+ },
25
+ },
26
+ token: {
27
+ purpose: "Read and edit design values, theme groups, options and defaults",
28
+ groups: {
29
+ theme: {
30
+ purpose:
31
+ "Read and edit Token theme groups, options and project defaults",
32
+ },
33
+ },
34
+ actions: {
35
+ list: action(
36
+ "tokens",
37
+ "Read resolved Tokens; --definitions reads stored option values",
38
+ ),
39
+ show: action(
40
+ "effective-token",
41
+ "Resolve one Token for the supplied themes",
42
+ ),
43
+ search: action("search-tokens", "Find reusable Tokens"),
44
+ explain: action("explain-token", "Explain one Token's value and sources"),
45
+ set: action(
46
+ "token-set",
47
+ "Create or change Tokens by name, with default and per-option values",
48
+ { topic: "token-set" },
49
+ ),
50
+ delete: action(
51
+ "token-delete",
52
+ "Delete a Token by name: --path color.brand; bound Tokens are protected",
53
+ ),
54
+ impact: action("impact", "Locate bindings of one Token"),
55
+ export: action(
56
+ "token-export",
57
+ "Export resolved Token values as JSON: --type string --by Language writes one file per option (English.json, Chinese.json)",
58
+ ),
59
+ import: action("import-tokens", "Review or apply a Token import"),
60
+ "theme list": action(
61
+ "themes",
62
+ "Read groups, options and defaults; --theme selects values for this call only",
63
+ ),
64
+ "theme add": action(
65
+ "theme-add",
66
+ "Add options by full name: --theme Viewport/Desktop --theme Viewport/Mobile",
67
+ ),
68
+ "theme rename": action(
69
+ "theme-rename",
70
+ "Rename an option (--theme Viewport/Desktop --to Web) or a group (--group Viewport --to Platform)",
71
+ ),
72
+ "theme default": action(
73
+ "theme-default",
74
+ "Make an option its group's default: --theme Viewport/Mobile",
75
+ ),
76
+ "theme delete": action(
77
+ "theme-delete",
78
+ "Delete an option (--theme Viewport/Tablet) or a group (--group Viewport)",
79
+ ),
80
+ },
81
+ },
82
+ canvas: {
83
+ purpose:
84
+ "Arrange pages on canvases; the CLI lays every canvas out (one row per module: Tasks / ... pages)",
85
+ actions: {
86
+ list: action("canvas-list", "List canvases, their rows and pages"),
87
+ rename: action(
88
+ "canvas-rename",
89
+ "Rename a canvas: --canvas Pages --to App",
90
+ ),
91
+ put: action(
92
+ "canvas-put",
93
+ 'Put a page on a canvas: --page "Tasks / Board" --canvas Reviews',
94
+ ),
95
+ },
96
+ },
97
+ component: {
98
+ purpose: "Find and edit components and their variants",
99
+ actions: {
100
+ list: action("catalog", "List reusable components"),
101
+ search: action("search-components", "Find reusable components"),
102
+ define: action(
103
+ "component-define",
104
+ "Define or redefine a component by name: base elements, properties, variant changes",
105
+ { topic: "component-define" },
106
+ ),
107
+ set: action(
108
+ "component-set",
109
+ "Change an element's fields without defining again: --element Label --variant Style=ghost --set fontSize=16 (--all for every variant)",
110
+ { topic: "component-fields" },
111
+ ),
112
+ rename: action(
113
+ "component-rename",
114
+ 'Rename a component: --component Button --to "Action button"',
115
+ ),
116
+ delete: action(
117
+ "component-delete",
118
+ "Delete a component, or one variant: --component Button [--variant Style=ghost]; used components are protected",
119
+ ),
120
+ },
121
+ },
122
+ page: {
123
+ purpose:
124
+ "Draw and arrange pages from named elements and component instances",
125
+ actions: {
126
+ list: action("list", "List pages", { fixed: ["--kind", "screens"] }),
127
+ draw: action(
128
+ "page-draw",
129
+ "Draw a page or one container from named elements and component instances",
130
+ { topic: "page-draw" },
131
+ ),
132
+ set: action(
133
+ "page-set",
134
+ "Change one element's fields (or the page's) without drawing again: --element Card --set width=320 --set fill={color.surface}",
135
+ { topic: "page-set" },
136
+ ),
137
+ move: action(
138
+ "page-move",
139
+ "Move an element up, down, left or right; the CLI picks coordinates and avoids overlaps",
140
+ ),
141
+ rename: action(
142
+ "page-rename",
143
+ 'Rename a page: --page "Tasks / List" --to Overview',
144
+ ),
145
+ delete: action(
146
+ "page-delete",
147
+ "Delete a page, a platform version (--platform) or an element (--element)",
148
+ ),
149
+ },
150
+ },
151
+ asset: {
152
+ purpose: "Import and manage media and font files",
153
+ groups: {
154
+ media: { purpose: "Import and manage media files" },
155
+ font: { purpose: "Import and manage font families and file variants" },
156
+ },
157
+ actions: {
158
+ "media list": action("assets", "List media", {
159
+ fixed: ["--kind", "media"],
160
+ }),
161
+ "media import": action("import-media", "Import a media file"),
162
+ "media delete": action(
163
+ "media-delete",
164
+ "Delete a media file by name: --media Logos/Mark",
165
+ ),
166
+ "font list": action("assets", "List fonts", {
167
+ fixed: ["--kind", "fonts"],
168
+ }),
169
+ "font import": action("import-font", "Import a font file"),
170
+ "font delete": action(
171
+ "font-delete",
172
+ "Delete a font family, or one variant: --font Inter [--variant Bold]",
173
+ ),
174
+ },
175
+ },
176
+ flow: {
177
+ purpose: "Read and set prototype starts and element links by name",
178
+ actions: {
179
+ list: action(
180
+ "flow-list",
181
+ "List starts and links by page and element name",
182
+ ),
183
+ link: action(
184
+ "flow-link",
185
+ 'Link an element to a page: --from "Page / Element" --to Page [--platform desktop] [--on click] [--action navigate|overlay|back]',
186
+ ),
187
+ start: action(
188
+ "flow-start",
189
+ "Make a page the start of a prototype flow: --page Page [--platform desktop]; --remove stops it",
190
+ ),
191
+ unlink: action(
192
+ "flow-unlink",
193
+ 'Remove an element\'s links: --from "Page / Element" [--to Page] [--on click]',
194
+ ),
195
+ },
196
+ },
197
+ advanced: {
198
+ purpose:
199
+ "Use atomic batches, draft imports, remote libraries and App styles",
200
+ groups: {
201
+ style: {
202
+ purpose:
203
+ "Manage existing App color and typography assets; use token for design values",
204
+ },
205
+ },
206
+ actions: {
207
+ "style list": action("assets", "List App color and typography assets", {
208
+ overrides: { kind: { values: ["colors", "typographies"] } },
209
+ }),
210
+ "style set": action(
211
+ "asset-set",
212
+ "Create or change an App style by name: --color Brand/Primary --value \"#1f6feb\", or --typography Text/Body --value '{...}'",
213
+ ),
214
+ "style delete": action(
215
+ "asset-delete",
216
+ "Delete an App style by name: --color Brand/Primary or --typography Text/Body",
217
+ ),
218
+ apply: action(
219
+ "apply",
220
+ "Apply an explicit revision-guarded atomic batch, such as the undo batch a write returns",
221
+ { topic: "batch" },
222
+ ),
223
+ "import-draft": action("import-draft", "Import a separate Draft package"),
224
+ "draft-diff": action("draft-diff", "Compare two Drafts"),
225
+ "draft-compile": action(
226
+ "draft-compile",
227
+ "Compile selected Draft fields into a batch",
228
+ ),
229
+ "library-refresh": action(
230
+ "library-refresh",
231
+ "Refresh a declared URL Library",
232
+ ),
233
+ },
234
+ },
235
+ };
236
+
237
+ for (const [group, { actions }] of Object.entries(COMMAND_GROUPS)) {
238
+ for (const [name, route] of Object.entries(actions)) {
239
+ route.name = `${group} ${name}`;
240
+ const base = COMMAND_CONTRACTS[route.engine];
241
+ const fixedOptions = route.fixed?.filter((_, i) => i % 2 === 0) ?? [];
242
+ const baseParameters = commandContract(route.engine).parameters;
243
+ const removed = Object.entries(baseParameters)
244
+ .filter(([, p]) => fixedOptions.includes(p.option))
245
+ .map(([key]) => key);
246
+ const spec = {
247
+ ...base,
248
+ purpose: route.purpose,
249
+ overrides: { ...base.overrides, ...route.overrides },
250
+ fields: [
251
+ ...base.fields.filter(
252
+ (field) => !removed.includes(field) && !route.omit?.includes(field),
253
+ ),
254
+ ...(route.fields ?? []),
255
+ ],
256
+ required: base.required ?? [],
257
+ };
258
+ Object.defineProperty(COMMAND_CONTRACTS, route.name, { value: spec });
259
+ }
260
+ }
261
+
262
+ export const PUBLIC_COMMANDS = [
263
+ ...Object.entries(COMMAND_GROUPS).map(([command, { purpose }]) => ({
264
+ command,
265
+ purpose,
266
+ })),
267
+ ...["view", "changes", "export", "validate", "help", "schema", "version"].map(
268
+ (command) => ({ command, purpose: COMMAND_CONTRACTS[command].purpose }),
269
+ ),
270
+ ];
271
+
272
+ export function groupRoute(group, actionName) {
273
+ const actions = Object.hasOwn(COMMAND_GROUPS, group)
274
+ ? COMMAND_GROUPS[group].actions
275
+ : undefined;
276
+ const route =
277
+ actions && Object.hasOwn(actions, actionName)
278
+ ? actions[actionName]
279
+ : undefined;
280
+ if (!route)
281
+ throw new SmallPenError(
282
+ "unknown_action",
283
+ `Unknown ${group} action: ${actionName}`,
284
+ {
285
+ nextOperations: [
286
+ {
287
+ operation: "smallpen.help",
288
+ argv: [
289
+ "help",
290
+ group,
291
+ ...parentGroup(group, actionName).split(" ").filter(Boolean),
292
+ "--json",
293
+ ],
294
+ },
295
+ ],
296
+ },
297
+ );
298
+ return route;
299
+ }
300
+
301
+ function parentGroup(group, section = "") {
302
+ return (
303
+ Object.keys(COMMAND_GROUPS[group]?.groups ?? {})
304
+ .filter((name) => section === name || section.startsWith(`${name} `))
305
+ .sort((a, b) => b.length - a.length)[0] ?? ""
306
+ );
307
+ }
308
+
309
+ export function groupDefinition(group, section = "") {
310
+ if (!Object.hasOwn(COMMAND_GROUPS, group)) return undefined;
311
+ if (!section) return COMMAND_GROUPS[group];
312
+ const groups = COMMAND_GROUPS[group].groups;
313
+ return groups && Object.hasOwn(groups, section) ? groups[section] : undefined;
314
+ }
315
+
316
+ // Store leaf routes once; discovery shows only the next level of their path.
317
+ export function groupActions(group, section = "") {
318
+ const prefix = section ? `${section} ` : "";
319
+ const actions = new Map();
320
+ for (const [name, route] of Object.entries(COMMAND_GROUPS[group].actions)) {
321
+ if (!name.startsWith(prefix) || route.hidden) continue;
322
+ const action = name.slice(prefix.length).split(" ")[0];
323
+ const child = [section, action].filter(Boolean).join(" ");
324
+ actions.set(action, {
325
+ action,
326
+ purpose: groupDefinition(group, child)?.purpose ?? route.purpose,
327
+ });
328
+ }
329
+ return [...actions.values()];
330
+ }
331
+
332
+ export function commandMatch(argv) {
333
+ const group = argv[0];
334
+ if (!Object.hasOwn(COMMAND_GROUPS, group)) return undefined;
335
+ let section = "";
336
+ let length = 1;
337
+ for (; length < argv.length && !argv[length].startsWith("-"); length++) {
338
+ section = [section, argv[length]].filter(Boolean).join(" ");
339
+ const actions = COMMAND_GROUPS[group].actions;
340
+ const route = Object.hasOwn(actions, section)
341
+ ? actions[section]
342
+ : undefined;
343
+ if (route) return { group, section, route, length: length + 1 };
344
+ if (!groupDefinition(group, section))
345
+ return { group, section, length: length + 1 };
346
+ }
347
+ return { group, section, length };
348
+ }
349
+
350
+ export function actionContract(group, actionName) {
351
+ const route = groupRoute(group, actionName);
352
+ const contract = commandContract(route.name);
353
+ return {
354
+ ...contract,
355
+ next: ["schema", "command", ...route.name.split(" "), "--json"],
356
+ nextOperations: inputQueries(route),
357
+ };
358
+ }
359
+
360
+ export function inputQueries(route) {
361
+ const queries = route.topic ? [["schema", route.topic, "--json"]] : [];
362
+ return queries.map((argv) => ({ operation: "smallpen.schema", argv }));
363
+ }
364
+
365
+ export function routeCommand(argv) {
366
+ if (!Object.hasOwn(COMMAND_GROUPS, argv[0])) return { args: argv };
367
+ const group = COMMAND_GROUPS[argv[0]];
368
+ const matched = commandMatch(argv);
369
+ if (!matched.route && groupDefinition(matched.group, matched.section))
370
+ return {
371
+ args: [
372
+ "help",
373
+ matched.group,
374
+ ...matched.section.split(" ").filter(Boolean),
375
+ ...argv
376
+ .slice(matched.length)
377
+ .filter((a) => a !== "--help" && a !== "-h"),
378
+ ],
379
+ };
380
+ // Existing package-path commands remain compatibility aliases, not discovery entries.
381
+ if (
382
+ !group.actions[argv[1]] &&
383
+ (/[/.\\]/.test(argv[1]) || argv[1].includes("://"))
384
+ )
385
+ return { args: argv };
386
+ const route = matched.route ?? groupRoute(matched.group, matched.section);
387
+ if (
388
+ argv.slice(matched.length).includes("--help") ||
389
+ argv.slice(matched.length).includes("-h")
390
+ )
391
+ return {
392
+ args: [
393
+ "help",
394
+ ...route.name.split(" "),
395
+ ...argv
396
+ .slice(matched.length)
397
+ .filter((a) => ["--json", "--full", "--stdout"].includes(a)),
398
+ ],
399
+ };
400
+ const validated = validateCommandArguments(route.name, [
401
+ route.name,
402
+ ...argv.slice(matched.length),
403
+ ]);
404
+ return {
405
+ route,
406
+ args: [route.engine, ...validated.slice(1), ...(route.fixed ?? [])],
407
+ };
408
+ }
409
+
410
+ const ALIASES = {
411
+ init: ["project", "init"],
412
+ inspect: ["project", "show"],
413
+ list: ["project", "list"],
414
+ watch: ["project", "watch"],
415
+ repair: ["project", "repair"],
416
+ "migrate-themes": ["project", "migrate"],
417
+ theme: ["token", "theme"],
418
+ themes: ["token", "theme", "list"],
419
+ tokens: ["token", "list"],
420
+ "effective-token": ["token", "show"],
421
+ "explain-token": ["token", "explain"],
422
+ "search-tokens": ["token", "search"],
423
+ impact: ["token", "impact"],
424
+ "import-tokens": ["token", "import"],
425
+ catalog: ["component", "list"],
426
+ "token-export": ["token", "export"],
427
+ "search-components": ["component", "search"],
428
+ assets: ["advanced", "style", "list"],
429
+ "import-media": ["asset", "media", "import"],
430
+ "import-font": ["asset", "font", "import"],
431
+ media: ["asset", "media"],
432
+ font: ["asset", "font"],
433
+ apply: ["advanced", "apply"],
434
+ "import-draft": ["advanced", "import-draft"],
435
+ "draft-diff": ["advanced", "draft-diff"],
436
+ "draft-compile": ["advanced", "draft-compile"],
437
+ "library-refresh": ["advanced", "library-refresh"],
438
+ };
439
+
440
+ export function publicArgv(argv) {
441
+ if (!Array.isArray(argv) || !argv.length) return argv;
442
+ if (argv[0].includes(" ") && COMMAND_GROUPS[argv[0].split(" ")[0]])
443
+ return [...argv[0].split(" "), ...argv.slice(1)];
444
+ if (argv[0] === "schema" && argv[1] === "command" && argv[2]?.includes(" "))
445
+ return [...argv.slice(0, 2), ...argv[2].split(" "), ...argv.slice(3)];
446
+ if (COMMAND_GROUPS[argv[0]]?.actions[argv[1]]) return argv;
447
+ if (
448
+ argv[0] === "schema" &&
449
+ argv[1] === "command" &&
450
+ Object.hasOwn(COMMAND_GROUPS, argv[2]) &&
451
+ Object.hasOwn(COMMAND_GROUPS[argv[2]].actions, argv[3])
452
+ )
453
+ return argv;
454
+ if (
455
+ (argv[0] === "help" && argv.length === 3) ||
456
+ (argv[0] === "schema" && argv[1] === "command")
457
+ ) {
458
+ const index = argv[0] === "help" ? 1 : 2;
459
+ const alias = ALIASES[argv[index]];
460
+ if (alias)
461
+ return [...argv.slice(0, index), ...alias, ...argv.slice(index + 1)];
462
+ }
463
+ const alias = ALIASES[argv[0]] ?? ENGINE_ROUTES.get(argv[0]);
464
+ return alias ? [...alias, ...argv.slice(1)] : argv;
465
+ }
466
+
467
+ // The public path of an engine that one action runs ("component-rename" is
468
+ // component rename).
469
+ const ENGINE_ROUTES = new Map();
470
+ for (const [group, { actions }] of Object.entries(COMMAND_GROUPS))
471
+ for (const [name, route] of Object.entries(actions))
472
+ if (!route.fixed)
473
+ ENGINE_ROUTES.set(
474
+ route.engine,
475
+ ENGINE_ROUTES.has(route.engine) ? null : route.name.split(" "),
476
+ );
477
+
478
+ // Rewrite only command guidance; design strings and stored operation payloads stay exact.
479
+ export function publicGuidance(value) {
480
+ if (Array.isArray(value)) return value.map(publicGuidance);
481
+ if (!value || typeof value !== "object" || value instanceof Map) return value;
482
+ return Object.fromEntries(
483
+ Object.entries(value).map(([key, child]) => {
484
+ if (key === "argv" && Array.isArray(child))
485
+ return [key, publicArgv(child)];
486
+ if (key === "operation" && Array.isArray(value.argv))
487
+ return [
488
+ key,
489
+ `smallpen.${publicArgv(value.argv)
490
+ .slice(0, commandMatch(publicArgv(value.argv))?.length ?? 1)
491
+ .join(".")}`,
492
+ ];
493
+ if (
494
+ [
495
+ "nextOperations",
496
+ "reverseEdit",
497
+ "undoError",
498
+ "error",
499
+ "details",
500
+ "stages",
501
+ "commands",
502
+ "actions",
503
+ "rules",
504
+ "diagnostics",
505
+ "reuseReminders",
506
+ ].includes(key)
507
+ )
508
+ return [key, publicGuidance(child)];
509
+ return [key, child];
510
+ }),
511
+ );
512
+ }