@smallpen/cli 0.1.0-alpha.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.
- package/bin/help.mjs +620 -0
- package/bin/smallpen-check.cjs +30 -0
- package/bin/smallpen.mjs +2509 -0
- package/bin/warning-output.mjs +64 -0
- package/package.json +26 -0
package/bin/smallpen.mjs
ADDED
|
@@ -0,0 +1,2509 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import {
|
|
5
|
+
mkdir,
|
|
6
|
+
readdir,
|
|
7
|
+
readFile,
|
|
8
|
+
rename,
|
|
9
|
+
rm,
|
|
10
|
+
writeFile,
|
|
11
|
+
} from "node:fs/promises";
|
|
12
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
13
|
+
import { createInterface } from "node:readline/promises";
|
|
14
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
canonicalJSON,
|
|
18
|
+
compileDraftMerge,
|
|
19
|
+
createCatalog,
|
|
20
|
+
createCompareView,
|
|
21
|
+
createInitializationState,
|
|
22
|
+
diffDrafts,
|
|
23
|
+
designTokenWarningsForBatch,
|
|
24
|
+
draftFromSnapshot,
|
|
25
|
+
explainEffectiveToken,
|
|
26
|
+
importTokens,
|
|
27
|
+
listEffectiveTokens,
|
|
28
|
+
readDesignView,
|
|
29
|
+
prepareOperationBatch,
|
|
30
|
+
resolveEffectiveToken,
|
|
31
|
+
searchEffectiveTokens,
|
|
32
|
+
SmallPenError,
|
|
33
|
+
SMALLPEN_FORMAT_CAPABILITIES,
|
|
34
|
+
} from "@smallpen/core";
|
|
35
|
+
import {
|
|
36
|
+
applyOperationBatch,
|
|
37
|
+
createEvidence,
|
|
38
|
+
defaultLibraryCacheRoot,
|
|
39
|
+
importDraft,
|
|
40
|
+
importFontVariant,
|
|
41
|
+
importMedia,
|
|
42
|
+
initializeWorkspace,
|
|
43
|
+
inspectFont,
|
|
44
|
+
inspectMedia,
|
|
45
|
+
openPackage,
|
|
46
|
+
openRemoteLibrary,
|
|
47
|
+
openWorkspace,
|
|
48
|
+
removeMedia,
|
|
49
|
+
replayRecordedBatch,
|
|
50
|
+
resolveWorkspace,
|
|
51
|
+
sfntToWoff,
|
|
52
|
+
} from "@smallpen/local-package";
|
|
53
|
+
|
|
54
|
+
import { COMMAND_NAMES, printHelp } from "./help.mjs";
|
|
55
|
+
import { warningOutput } from "./warning-output.mjs";
|
|
56
|
+
|
|
57
|
+
const FLAG_OPTIONS = new Set([
|
|
58
|
+
"--apply",
|
|
59
|
+
"--base64",
|
|
60
|
+
"--confirm",
|
|
61
|
+
"--confirm-unmatched",
|
|
62
|
+
"--diff",
|
|
63
|
+
"--dry-run",
|
|
64
|
+
"--explain",
|
|
65
|
+
"--help",
|
|
66
|
+
"--include-image",
|
|
67
|
+
"--json",
|
|
68
|
+
"-h",
|
|
69
|
+
]);
|
|
70
|
+
const COMMAND_OPTIONS = {
|
|
71
|
+
version: ["--json"],
|
|
72
|
+
apply: ["--batch", "--confirm-unmatched", "--diff", "--dry-run", "--explain", "--json", "--warning-detail"],
|
|
73
|
+
catalog: ["--context", "--json"],
|
|
74
|
+
component: ["--component-id", "--json"],
|
|
75
|
+
compare: ["--json", "--selector"],
|
|
76
|
+
discover: [
|
|
77
|
+
"--context",
|
|
78
|
+
"--context-profile",
|
|
79
|
+
"--format",
|
|
80
|
+
"--json",
|
|
81
|
+
"--limit",
|
|
82
|
+
"--locale",
|
|
83
|
+
"--offset",
|
|
84
|
+
"--presentation",
|
|
85
|
+
"--scenario",
|
|
86
|
+
"--screen",
|
|
87
|
+
],
|
|
88
|
+
"draft-compile": ["--batch-id", "--draft", "--json", "--selections"],
|
|
89
|
+
"draft-diff": ["--after", "--json"],
|
|
90
|
+
"effective-token": ["--context", "--json", "--package-id", "--token-id"],
|
|
91
|
+
evidence: [
|
|
92
|
+
"--context",
|
|
93
|
+
"--context-profile",
|
|
94
|
+
"--format",
|
|
95
|
+
"--json",
|
|
96
|
+
"--output",
|
|
97
|
+
"--presentation",
|
|
98
|
+
"--scale",
|
|
99
|
+
"--scenario",
|
|
100
|
+
"--screen",
|
|
101
|
+
],
|
|
102
|
+
"inspect-view": [
|
|
103
|
+
"--base64",
|
|
104
|
+
"--context",
|
|
105
|
+
"--context-profile",
|
|
106
|
+
"--include-image",
|
|
107
|
+
"--json",
|
|
108
|
+
"--locale",
|
|
109
|
+
"--output",
|
|
110
|
+
"--presentation",
|
|
111
|
+
"--scale",
|
|
112
|
+
"--scenario",
|
|
113
|
+
"--screen",
|
|
114
|
+
],
|
|
115
|
+
"render-matrix": ["--contexts", "--json", "--output", "--scale"],
|
|
116
|
+
impact: ["--json", "--package-id", "--path", "--token-id"],
|
|
117
|
+
flow: ["--batch-id", "--confirm-unmatched", "--diff", "--dry-run", "--explain", "--intent", "--json", "--warning-detail"],
|
|
118
|
+
page: ["--batch-id", "--confirm-unmatched", "--diff", "--dry-run", "--explain", "--intent", "--json", "--warning-detail"],
|
|
119
|
+
token: ["--batch-id", "--confirm-unmatched", "--diff", "--dry-run", "--explain", "--intent", "--json", "--warning-detail"],
|
|
120
|
+
"explain-token": ["--context", "--json", "--package-id", "--token-id"],
|
|
121
|
+
init: ["--answer", "--answers", "--confirm", "--json", "--locale", "--state"],
|
|
122
|
+
"import-draft": ["--input", "--json", "--kind", "--package-id"],
|
|
123
|
+
"import-tokens": [
|
|
124
|
+
"--apply",
|
|
125
|
+
"--batch-id",
|
|
126
|
+
"--dry-run",
|
|
127
|
+
"--input",
|
|
128
|
+
"--json",
|
|
129
|
+
"--select",
|
|
130
|
+
"--set-name",
|
|
131
|
+
],
|
|
132
|
+
inspect: ["--json"],
|
|
133
|
+
"import-font": [
|
|
134
|
+
"--family",
|
|
135
|
+
"--file",
|
|
136
|
+
"--font-id",
|
|
137
|
+
"--json",
|
|
138
|
+
"--media-path",
|
|
139
|
+
"--name",
|
|
140
|
+
"--style",
|
|
141
|
+
"--variant-id",
|
|
142
|
+
"--weight",
|
|
143
|
+
],
|
|
144
|
+
"import-media": ["--file", "--json", "--media-id", "--media-path", "--name"],
|
|
145
|
+
"library-refresh": ["--interval", "--json", "--library", "--max-events"],
|
|
146
|
+
watch: ["--interval", "--json", "--max-events"],
|
|
147
|
+
list: ["--json", "--kind", "--limit", "--offset"],
|
|
148
|
+
read: ["--json"],
|
|
149
|
+
"remove-media": ["--json", "--media-id"],
|
|
150
|
+
"read-view": [
|
|
151
|
+
"--context",
|
|
152
|
+
"--context-profile",
|
|
153
|
+
"--format",
|
|
154
|
+
"--json",
|
|
155
|
+
"--limit",
|
|
156
|
+
"--locale",
|
|
157
|
+
"--offset",
|
|
158
|
+
"--presentation",
|
|
159
|
+
"--scenario",
|
|
160
|
+
"--screen",
|
|
161
|
+
],
|
|
162
|
+
render: [
|
|
163
|
+
"--context",
|
|
164
|
+
"--context-profile",
|
|
165
|
+
"--format",
|
|
166
|
+
"--json",
|
|
167
|
+
"--output",
|
|
168
|
+
"--presentation",
|
|
169
|
+
"--scale",
|
|
170
|
+
"--scenario",
|
|
171
|
+
"--screen",
|
|
172
|
+
],
|
|
173
|
+
repair: [
|
|
174
|
+
"--action",
|
|
175
|
+
"--asset",
|
|
176
|
+
"--asset-kind",
|
|
177
|
+
"--batch-id",
|
|
178
|
+
"--conflict",
|
|
179
|
+
"--foundation",
|
|
180
|
+
"--json",
|
|
181
|
+
"--replacement-asset-id",
|
|
182
|
+
"--replacement-package-id",
|
|
183
|
+
],
|
|
184
|
+
"search-components": ["--json", "--limit", "--query"],
|
|
185
|
+
"search-tokens": [
|
|
186
|
+
"--color",
|
|
187
|
+
"--context",
|
|
188
|
+
"--json",
|
|
189
|
+
"--limit",
|
|
190
|
+
"--query",
|
|
191
|
+
"--type",
|
|
192
|
+
"--value",
|
|
193
|
+
],
|
|
194
|
+
tokens: ["--context", "--json"],
|
|
195
|
+
validate: ["--json"],
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
function isRecord(value) {
|
|
199
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function option(args, name) {
|
|
203
|
+
const index = args.indexOf(name);
|
|
204
|
+
if (index === -1) return undefined;
|
|
205
|
+
const value = args[index + 1];
|
|
206
|
+
if (value === undefined || value.startsWith("--")) {
|
|
207
|
+
throw new SmallPenError(
|
|
208
|
+
"missing_option_value",
|
|
209
|
+
`${name} requires a value`,
|
|
210
|
+
{ option: name },
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return value;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function options(args, name) {
|
|
217
|
+
const values = [];
|
|
218
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
219
|
+
if (args[index] !== name) continue;
|
|
220
|
+
const value = args[index + 1];
|
|
221
|
+
if (value === undefined || value.startsWith("--")) {
|
|
222
|
+
throw new SmallPenError(
|
|
223
|
+
"missing_option_value",
|
|
224
|
+
`${name} requires a value`,
|
|
225
|
+
{ option: name },
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
values.push(value);
|
|
229
|
+
}
|
|
230
|
+
return values;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function flag(args, name) {
|
|
234
|
+
return args.includes(name);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function validateCommandArguments(command, args) {
|
|
238
|
+
const allowed = new Set([
|
|
239
|
+
...(COMMAND_OPTIONS[command] ?? []),
|
|
240
|
+
"--help",
|
|
241
|
+
"-h",
|
|
242
|
+
]);
|
|
243
|
+
for (let index = 2; index < args.length; index += 1) {
|
|
244
|
+
const value = args[index];
|
|
245
|
+
if (!value.startsWith("-")) {
|
|
246
|
+
throw new SmallPenError(
|
|
247
|
+
"unexpected_argument",
|
|
248
|
+
`Unexpected positional argument: ${value}`,
|
|
249
|
+
{ argument: value, command },
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
if (!allowed.has(value)) {
|
|
253
|
+
throw new SmallPenError("unknown_option", `Unknown option: ${value}`, {
|
|
254
|
+
command,
|
|
255
|
+
option: value,
|
|
256
|
+
validOptions: [...allowed].sort(),
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
if (!FLAG_OPTIONS.has(value)) index += 1;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function parseJson(source, code, details = {}) {
|
|
264
|
+
try {
|
|
265
|
+
return JSON.parse(source);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
throw new SmallPenError(code, `Invalid JSON: ${error.message}`, details);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function contextSelection(args) {
|
|
272
|
+
return Object.fromEntries(
|
|
273
|
+
options(args, "--context").map((value) => {
|
|
274
|
+
const separator = value.indexOf("=");
|
|
275
|
+
if (separator <= 0 || separator === value.length - 1) {
|
|
276
|
+
throw new SmallPenError(
|
|
277
|
+
"invalid_context_argument",
|
|
278
|
+
"--context requires AXIS_ID=VALUE_ID",
|
|
279
|
+
{ value },
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
return [value.slice(0, separator), value.slice(separator + 1)];
|
|
283
|
+
}),
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function integerOption(args, name, fallback, maximum = Number.MAX_SAFE_INTEGER) {
|
|
288
|
+
const value = option(args, name);
|
|
289
|
+
if (value === undefined) return fallback;
|
|
290
|
+
const parsed = Number(value);
|
|
291
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > maximum) {
|
|
292
|
+
throw new SmallPenError(
|
|
293
|
+
"invalid_integer_option",
|
|
294
|
+
`${name} requires an integer from 0 through ${maximum}`,
|
|
295
|
+
{ maximum, name, value },
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
return parsed;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function numberOption(args, name, fallback) {
|
|
302
|
+
const value = option(args, name);
|
|
303
|
+
if (value === undefined) return fallback;
|
|
304
|
+
const parsed = Number(value);
|
|
305
|
+
if (!Number.isFinite(parsed)) {
|
|
306
|
+
throw new SmallPenError(
|
|
307
|
+
"invalid_number_option",
|
|
308
|
+
`${name} requires a finite number`,
|
|
309
|
+
{ name, value },
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
return parsed;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function designSelector(args, viewFormat) {
|
|
316
|
+
return {
|
|
317
|
+
...(options(args, "--context").length > 0
|
|
318
|
+
? { context: contextSelection(args) }
|
|
319
|
+
: {}),
|
|
320
|
+
...(option(args, "--context-profile")
|
|
321
|
+
? { contextProfileId: option(args, "--context-profile") }
|
|
322
|
+
: {}),
|
|
323
|
+
...(viewFormat ?? option(args, "--format")
|
|
324
|
+
? { viewFormat: viewFormat ?? option(args, "--format") }
|
|
325
|
+
: {}),
|
|
326
|
+
...(option(args, "--presentation")
|
|
327
|
+
? { presentationId: option(args, "--presentation") }
|
|
328
|
+
: {}),
|
|
329
|
+
...(option(args, "--scenario")
|
|
330
|
+
? { scenarioId: option(args, "--scenario") }
|
|
331
|
+
: {}),
|
|
332
|
+
...(option(args, "--screen")
|
|
333
|
+
? { screenId: option(args, "--screen") }
|
|
334
|
+
: {}),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function printJson(value) {
|
|
339
|
+
process.stdout.write(
|
|
340
|
+
`${JSON.stringify(
|
|
341
|
+
value,
|
|
342
|
+
(_key, child) =>
|
|
343
|
+
child instanceof Map
|
|
344
|
+
? Object.fromEntries(
|
|
345
|
+
[...child.entries()].sort(([left], [right]) =>
|
|
346
|
+
String(left).localeCompare(String(right)),
|
|
347
|
+
),
|
|
348
|
+
)
|
|
349
|
+
: child,
|
|
350
|
+
2,
|
|
351
|
+
)}\n`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function inspect(snapshot) {
|
|
356
|
+
return {
|
|
357
|
+
annotations: [...snapshot.domain.annotations.values()],
|
|
358
|
+
components: [
|
|
359
|
+
...[...snapshot.domain.componentSets.values()].map(
|
|
360
|
+
({ axes, deprecated, id, name, variants, visibility }) => ({
|
|
361
|
+
axes,
|
|
362
|
+
deprecated,
|
|
363
|
+
id,
|
|
364
|
+
kind: "set",
|
|
365
|
+
name,
|
|
366
|
+
variants: variants.map(({ id: variantId, selection }) => ({
|
|
367
|
+
id: variantId,
|
|
368
|
+
selection,
|
|
369
|
+
})),
|
|
370
|
+
visibility,
|
|
371
|
+
}),
|
|
372
|
+
),
|
|
373
|
+
...[...snapshot.domain.locatedComponents.values()].map(
|
|
374
|
+
({ id, mainNodeId, name, path, presentationId, screenId }) => ({
|
|
375
|
+
id,
|
|
376
|
+
kind: "located",
|
|
377
|
+
mainNodeId,
|
|
378
|
+
name,
|
|
379
|
+
path,
|
|
380
|
+
presentationId,
|
|
381
|
+
screenId,
|
|
382
|
+
}),
|
|
383
|
+
),
|
|
384
|
+
].sort((left, right) => left.id.localeCompare(right.id)),
|
|
385
|
+
contexts: {
|
|
386
|
+
axes: [...snapshot.domain.contextAxes.values()],
|
|
387
|
+
profiles: [...snapshot.domain.contextProfiles.values()],
|
|
388
|
+
},
|
|
389
|
+
draft: snapshot.manifest.draft
|
|
390
|
+
? structuredClone(snapshot.manifest.draft)
|
|
391
|
+
: undefined,
|
|
392
|
+
flows: [...snapshot.domain.flows.values()],
|
|
393
|
+
formatCapabilities: SMALLPEN_FORMAT_CAPABILITIES,
|
|
394
|
+
guidance: {
|
|
395
|
+
beforeDesigning: [
|
|
396
|
+
{
|
|
397
|
+
args: {
|
|
398
|
+
packagePath: snapshot.locator,
|
|
399
|
+
query: "<intended-component-name-or-purpose>",
|
|
400
|
+
},
|
|
401
|
+
operation: "smallpen.search-components",
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
args: {
|
|
405
|
+
packagePath: snapshot.locator,
|
|
406
|
+
value: "<intended-style-value>",
|
|
407
|
+
},
|
|
408
|
+
operation: "smallpen.search-tokens",
|
|
409
|
+
},
|
|
410
|
+
],
|
|
411
|
+
stateless: true,
|
|
412
|
+
},
|
|
413
|
+
package: {
|
|
414
|
+
dependencies: structuredClone(snapshot.manifest.dependencies ?? []),
|
|
415
|
+
id: snapshot.manifest.packageId,
|
|
416
|
+
name: snapshot.manifest.name,
|
|
417
|
+
revision: snapshot.revision,
|
|
418
|
+
role: snapshot.manifest.role,
|
|
419
|
+
},
|
|
420
|
+
requirements: [...snapshot.domain.requirements.values()],
|
|
421
|
+
scenarios: [...snapshot.domain.scenarios.values()],
|
|
422
|
+
screens: snapshot.manifest.entries.screens.map((entry) => {
|
|
423
|
+
const screen = snapshot.entries[entry];
|
|
424
|
+
return {
|
|
425
|
+
basePresentationId: screen.basePresentationId,
|
|
426
|
+
id: screen.id,
|
|
427
|
+
name: screen.name,
|
|
428
|
+
presentations: screen.presentations.map((presentation) => ({
|
|
429
|
+
id: presentation.id,
|
|
430
|
+
name: presentation.name,
|
|
431
|
+
nodeCount: Object.keys(presentation.nodes).length,
|
|
432
|
+
rootId: presentation.rootId,
|
|
433
|
+
})),
|
|
434
|
+
};
|
|
435
|
+
}),
|
|
436
|
+
tokens: [...snapshot.domain.tokens.values()].map(
|
|
437
|
+
({ contextValues, id, overrideOf, path, type, visibility }) => ({
|
|
438
|
+
contextValues,
|
|
439
|
+
id,
|
|
440
|
+
overrideOf,
|
|
441
|
+
path,
|
|
442
|
+
type,
|
|
443
|
+
visibility,
|
|
444
|
+
}),
|
|
445
|
+
),
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function listDomain(snapshot, kind) {
|
|
450
|
+
const groups = {
|
|
451
|
+
components: [
|
|
452
|
+
...snapshot.domain.componentSets.values(),
|
|
453
|
+
...snapshot.domain.locatedComponents.values(),
|
|
454
|
+
],
|
|
455
|
+
contexts: [
|
|
456
|
+
...snapshot.domain.contextAxes.values(),
|
|
457
|
+
...snapshot.domain.contextProfiles.values(),
|
|
458
|
+
],
|
|
459
|
+
flows: [...snapshot.domain.flows.values()],
|
|
460
|
+
presentations: snapshot.manifest.entries.screens.flatMap((entry) =>
|
|
461
|
+
snapshot.entries[entry].presentations.map((presentation) => ({
|
|
462
|
+
...presentation,
|
|
463
|
+
nodes: undefined,
|
|
464
|
+
screenId: snapshot.entries[entry].id,
|
|
465
|
+
})),
|
|
466
|
+
),
|
|
467
|
+
requirements: [...snapshot.domain.requirements.values()],
|
|
468
|
+
scenarios: [...snapshot.domain.scenarios.values()],
|
|
469
|
+
screens: snapshot.manifest.entries.screens.map(
|
|
470
|
+
(entry) => snapshot.entries[entry],
|
|
471
|
+
),
|
|
472
|
+
tokens: [...snapshot.domain.tokens.values()],
|
|
473
|
+
};
|
|
474
|
+
if (kind === "all") {
|
|
475
|
+
return Object.entries(groups).flatMap(([group, values]) =>
|
|
476
|
+
values.map((value) => ({ group, item: value })),
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
if (!Object.hasOwn(groups, kind)) {
|
|
480
|
+
throw new SmallPenError("invalid_list_kind", `Unknown list kind: ${kind}`, {
|
|
481
|
+
validValues: ["all", ...Object.keys(groups)],
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
return groups[kind].map((item) => ({ group: kind, item }));
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function tokenWorkspace(packagePath) {
|
|
488
|
+
const workspace = await openWorkspace(packagePath, {
|
|
489
|
+
libraryCacheRoot: defaultLibraryCacheRoot(),
|
|
490
|
+
});
|
|
491
|
+
return {
|
|
492
|
+
foundation: workspace.foundation,
|
|
493
|
+
libraries: workspace.libraries ?? [],
|
|
494
|
+
product: workspace.product,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function resolveCliWorkspace(packagePath) {
|
|
499
|
+
return resolveWorkspace(packagePath, {
|
|
500
|
+
libraryCacheRoot: defaultLibraryCacheRoot(),
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function tokenAdviceWorkspace(packagePath) {
|
|
505
|
+
const resolution = await resolveCliWorkspace(packagePath);
|
|
506
|
+
if (resolution.status === "ready") return resolution.workspace;
|
|
507
|
+
return {
|
|
508
|
+
foundation: resolution.foundation,
|
|
509
|
+
product: resolution.product,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function workspaceRevisions(product, foundation, libraries = []) {
|
|
514
|
+
return {
|
|
515
|
+
...(foundation ? { foundationRevision: foundation.revision } : {}),
|
|
516
|
+
...(libraries.length > 0
|
|
517
|
+
? {
|
|
518
|
+
libraryRevisions: Object.fromEntries(
|
|
519
|
+
libraries.map((library) => [
|
|
520
|
+
library.manifest.packageId,
|
|
521
|
+
library.revision,
|
|
522
|
+
]),
|
|
523
|
+
),
|
|
524
|
+
}
|
|
525
|
+
: {}),
|
|
526
|
+
productRevision: product.revision,
|
|
527
|
+
revision: product.revision,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function tokenReference(args, product) {
|
|
532
|
+
const assetId = option(args, "--token-id");
|
|
533
|
+
if (!assetId) {
|
|
534
|
+
throw new SmallPenError(
|
|
535
|
+
"missing_token_id",
|
|
536
|
+
"Token read requires --token-id TOK",
|
|
537
|
+
{ nextOperations: [{ operation: "smallpen.tokens", args: {} }] },
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
return {
|
|
541
|
+
assetId,
|
|
542
|
+
packageId: option(args, "--package-id") ?? product.manifest.packageId,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function writeAtomic(path, value) {
|
|
547
|
+
const output = resolve(path);
|
|
548
|
+
await mkdir(dirname(output), { recursive: true });
|
|
549
|
+
const temporary = `${output}.smallpen-${randomUUID()}.tmp`;
|
|
550
|
+
try {
|
|
551
|
+
await writeFile(temporary, value);
|
|
552
|
+
await rename(temporary, output);
|
|
553
|
+
return output;
|
|
554
|
+
} catch (error) {
|
|
555
|
+
await rm(temporary, { force: true });
|
|
556
|
+
throw error;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// SP-042/SP-043: every write supports an explain/diff preview computed from the
|
|
561
|
+
// same prepared candidate that the atomic write would use; nothing is written.
|
|
562
|
+
const EXPLAIN_TARGET_FIELDS = [
|
|
563
|
+
"assetId",
|
|
564
|
+
"componentSetId",
|
|
565
|
+
"entry",
|
|
566
|
+
"field",
|
|
567
|
+
"mediaId",
|
|
568
|
+
"nodeId",
|
|
569
|
+
"presentationId",
|
|
570
|
+
"screenId",
|
|
571
|
+
"tokenId",
|
|
572
|
+
"type",
|
|
573
|
+
"variantId",
|
|
574
|
+
];
|
|
575
|
+
|
|
576
|
+
function explainBatch(before, prepared, batch) {
|
|
577
|
+
const explain = (batch.operations ?? []).map((operation, index) => {
|
|
578
|
+
const target = Object.fromEntries(
|
|
579
|
+
EXPLAIN_TARGET_FIELDS.filter((field) => operation[field] !== undefined).map(
|
|
580
|
+
(field) => [field, operation[field]],
|
|
581
|
+
),
|
|
582
|
+
);
|
|
583
|
+
return { index, type: operation.type, target };
|
|
584
|
+
});
|
|
585
|
+
const diff = [
|
|
586
|
+
...prepared.result.changedFiles.map((entry) => {
|
|
587
|
+
const beforeEntry = before.entries[entry];
|
|
588
|
+
const afterEntry = prepared.snapshot.entries[entry];
|
|
589
|
+
if (beforeEntry === undefined || afterEntry === undefined) {
|
|
590
|
+
return { entry, replaced: true };
|
|
591
|
+
}
|
|
592
|
+
const changes = {};
|
|
593
|
+
for (const key of new Set([
|
|
594
|
+
...Object.keys(beforeEntry),
|
|
595
|
+
...Object.keys(afterEntry),
|
|
596
|
+
])) {
|
|
597
|
+
const beforeValue = beforeEntry[key];
|
|
598
|
+
const afterValue = afterEntry[key];
|
|
599
|
+
if (
|
|
600
|
+
JSON.stringify(beforeValue ?? null) !==
|
|
601
|
+
JSON.stringify(afterValue ?? null)
|
|
602
|
+
) {
|
|
603
|
+
changes[key] = {
|
|
604
|
+
after: afterValue ?? null,
|
|
605
|
+
before: beforeValue ?? null,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return { changes, entry };
|
|
610
|
+
}),
|
|
611
|
+
...prepared.result.deletedFiles.map((entry) => ({ deleted: true, entry })),
|
|
612
|
+
];
|
|
613
|
+
return { diff, explain };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
async function applyBatch(packagePath, batch, args) {
|
|
617
|
+
const detail = option(args, "--warning-detail") ?? "compact";
|
|
618
|
+
if (!["compact", "full"].includes(detail)) {
|
|
619
|
+
throw new SmallPenError("invalid_warning_detail", "--warning-detail must be compact or full", {
|
|
620
|
+
validValues: ["compact", "full"],
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
if (batch.blobs !== undefined) {
|
|
624
|
+
throw new SmallPenError(
|
|
625
|
+
"invalid_blob_writes",
|
|
626
|
+
"CLI Operation Batch JSON cannot contain binary blob writes",
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
// 通用 apply 仍然能修复处于 Repair 的 Product;Token 建议不能变成写入门禁。
|
|
630
|
+
const { foundation, product } = await tokenAdviceWorkspace(packagePath);
|
|
631
|
+
let prepared;
|
|
632
|
+
try {
|
|
633
|
+
prepared = await prepareOperationBatch(product, batch);
|
|
634
|
+
} catch (error) {
|
|
635
|
+
if (error?.code === "stale_revision") {
|
|
636
|
+
const replayed = await replayRecordedBatch(packagePath, batch);
|
|
637
|
+
if (replayed) return replayed;
|
|
638
|
+
}
|
|
639
|
+
throw error;
|
|
640
|
+
}
|
|
641
|
+
const warnings = designTokenWarningsForBatch(prepared.snapshot, batch, {
|
|
642
|
+
foundation,
|
|
643
|
+
});
|
|
644
|
+
// Import warnings have their own schema and are not design-style advice.
|
|
645
|
+
const advice = args[0] === "import-tokens"
|
|
646
|
+
? { warnings }
|
|
647
|
+
: warningOutput(warnings, detail, { confirmUnmatched: flag(args, "--confirm-unmatched") });
|
|
648
|
+
if (flag(args, "--explain") || flag(args, "--diff")) {
|
|
649
|
+
// explain/diff are read-only previews in any combination; neither flag
|
|
650
|
+
// writes, with or without --dry-run (CLI-AI-TOPIC: "explain and diff
|
|
651
|
+
// never write").
|
|
652
|
+
return {
|
|
653
|
+
...prepared.result,
|
|
654
|
+
...explainBatch(product, prepared, batch),
|
|
655
|
+
dryRun: true,
|
|
656
|
+
...advice,
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
if (flag(args, "--dry-run")) {
|
|
660
|
+
return { ...prepared.result, dryRun: true, ...advice };
|
|
661
|
+
}
|
|
662
|
+
return {
|
|
663
|
+
...(await applyOperationBatch(packagePath, batch)),
|
|
664
|
+
...advice,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function searchValue(args) {
|
|
669
|
+
const color = option(args, "--color");
|
|
670
|
+
const source = option(args, "--value");
|
|
671
|
+
if (color !== undefined && source !== undefined) {
|
|
672
|
+
throw new SmallPenError(
|
|
673
|
+
"ambiguous_token_search_value",
|
|
674
|
+
"search-tokens accepts only one of --color or --value",
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
if (color !== undefined) return { type: "color", value: color };
|
|
678
|
+
if (source === undefined) return {};
|
|
679
|
+
try {
|
|
680
|
+
return { value: JSON.parse(source) };
|
|
681
|
+
} catch {
|
|
682
|
+
return { value: source };
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
async function readOptionalJson(path) {
|
|
687
|
+
try {
|
|
688
|
+
return parseJson(
|
|
689
|
+
await readFile(path, "utf8"),
|
|
690
|
+
"invalid_initialization_state",
|
|
691
|
+
{ path },
|
|
692
|
+
);
|
|
693
|
+
} catch (error) {
|
|
694
|
+
if (error?.code === "ENOENT") return {};
|
|
695
|
+
throw error;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function answerArguments(args) {
|
|
700
|
+
return Object.fromEntries(
|
|
701
|
+
options(args, "--answer").map((source) => {
|
|
702
|
+
const separator = source.indexOf("=");
|
|
703
|
+
if (separator <= 0 || separator === source.length - 1) {
|
|
704
|
+
throw new SmallPenError(
|
|
705
|
+
"invalid_initialization_answer_argument",
|
|
706
|
+
"--answer requires QUESTION_ID=JSON",
|
|
707
|
+
{ value: source },
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
const id = source.slice(0, separator);
|
|
711
|
+
return [
|
|
712
|
+
id,
|
|
713
|
+
parseJson(
|
|
714
|
+
source.slice(separator + 1),
|
|
715
|
+
"invalid_initialization_answer_json",
|
|
716
|
+
{ questionId: id },
|
|
717
|
+
),
|
|
718
|
+
];
|
|
719
|
+
}),
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
async function interactiveInitialization(answersValue, statePath, locale) {
|
|
724
|
+
const answers = structuredClone(answersValue);
|
|
725
|
+
const terminal = createInterface({ input: process.stdin, output: process.stdout });
|
|
726
|
+
try {
|
|
727
|
+
let state = createInitializationState(answers, { locale, statePath });
|
|
728
|
+
while (state.status === "needs_input") {
|
|
729
|
+
const question = state.nextQuestion;
|
|
730
|
+
process.stdout.write(`\n${question.reason}\n`);
|
|
731
|
+
const recommendation = JSON.stringify(question.recommendation);
|
|
732
|
+
let accepted = false;
|
|
733
|
+
while (!accepted) {
|
|
734
|
+
const raw = await terminal.question(
|
|
735
|
+
`${question.label} [${recommendation}]: `,
|
|
736
|
+
);
|
|
737
|
+
let value;
|
|
738
|
+
try {
|
|
739
|
+
value =
|
|
740
|
+
raw.trim().length === 0
|
|
741
|
+
? structuredClone(question.recommendation)
|
|
742
|
+
: question.schema.type === "string"
|
|
743
|
+
? raw.trim()
|
|
744
|
+
: parseJson(
|
|
745
|
+
raw,
|
|
746
|
+
"invalid_initialization_answer_json",
|
|
747
|
+
{ questionId: question.id },
|
|
748
|
+
);
|
|
749
|
+
const candidate = { ...answers, [question.id]: value };
|
|
750
|
+
state = createInitializationState(candidate, { locale, statePath });
|
|
751
|
+
Object.assign(answers, candidate);
|
|
752
|
+
accepted = true;
|
|
753
|
+
} catch (error) {
|
|
754
|
+
process.stderr.write(
|
|
755
|
+
`${error instanceof Error ? error.message : String(error)}\n`,
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
await writeAtomic(
|
|
760
|
+
statePath,
|
|
761
|
+
canonicalJSON(
|
|
762
|
+
state.status === "proposal"
|
|
763
|
+
? {
|
|
764
|
+
answers: state.proposal.brief,
|
|
765
|
+
proposal: state.proposal,
|
|
766
|
+
status: state.status,
|
|
767
|
+
}
|
|
768
|
+
: { answers: state.answers, status: state.status },
|
|
769
|
+
),
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
const answer = await terminal.question(
|
|
773
|
+
`\nCreate ${state.proposal.packages.foundation.directoryName} and ${state.proposal.packages.product.directoryName}? [y/N]: `,
|
|
774
|
+
);
|
|
775
|
+
return {
|
|
776
|
+
confirmed: /^y(?:es)?$/i.test(answer.trim()),
|
|
777
|
+
state,
|
|
778
|
+
};
|
|
779
|
+
} finally {
|
|
780
|
+
terminal.close();
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
async function initializeCommand(workspacePath, args) {
|
|
785
|
+
const explicitStatePath = option(args, "--state");
|
|
786
|
+
const statePath = resolve(
|
|
787
|
+
explicitStatePath ?? `${resolve(workspacePath)}.smallpen-init.json`,
|
|
788
|
+
);
|
|
789
|
+
// 只有调用者显式传回 --state 才恢复事务,普通调用永远从无状态输入开始。
|
|
790
|
+
const persisted = explicitStatePath ? await readOptionalJson(statePath) : {};
|
|
791
|
+
if (!isRecord(persisted)) {
|
|
792
|
+
throw new SmallPenError(
|
|
793
|
+
"invalid_initialization_state",
|
|
794
|
+
"Initialization state must contain an object",
|
|
795
|
+
{ path: statePath },
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
let fileAnswers = {};
|
|
799
|
+
const answersPath = option(args, "--answers");
|
|
800
|
+
if (answersPath) {
|
|
801
|
+
fileAnswers = parseJson(
|
|
802
|
+
await readFile(answersPath, "utf8"),
|
|
803
|
+
"invalid_initialization_answers_json",
|
|
804
|
+
{ path: answersPath },
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
const answers = {
|
|
808
|
+
...(persisted.answers ?? {}),
|
|
809
|
+
...fileAnswers,
|
|
810
|
+
...answerArguments(args),
|
|
811
|
+
};
|
|
812
|
+
const locale = option(args, "--locale") ?? "zh-TW";
|
|
813
|
+
let state = createInitializationState(answers, {
|
|
814
|
+
locale,
|
|
815
|
+
statePath,
|
|
816
|
+
});
|
|
817
|
+
let interactiveConfirmation = false;
|
|
818
|
+
if (!flag(args, "--json") && process.stdin.isTTY && process.stdout.isTTY) {
|
|
819
|
+
const interactive = await interactiveInitialization(
|
|
820
|
+
state.status === "needs_input" ? state.answers : state.proposal.brief,
|
|
821
|
+
statePath,
|
|
822
|
+
locale,
|
|
823
|
+
);
|
|
824
|
+
state = interactive.state;
|
|
825
|
+
interactiveConfirmation = interactive.confirmed;
|
|
826
|
+
}
|
|
827
|
+
const stateDocument =
|
|
828
|
+
state.status === "proposal"
|
|
829
|
+
? { answers: state.proposal.brief, proposal: state.proposal, status: state.status }
|
|
830
|
+
: { answers: state.answers, status: state.status };
|
|
831
|
+
await writeAtomic(statePath, canonicalJSON(stateDocument));
|
|
832
|
+
if (state.status === "needs_input") {
|
|
833
|
+
state.nextQuestion.continuation.args[1] = resolve(workspacePath);
|
|
834
|
+
state.nextQuestion.continuation.args.push("--locale", locale);
|
|
835
|
+
return { ...state, statePath };
|
|
836
|
+
}
|
|
837
|
+
const confirmation = {
|
|
838
|
+
args: [
|
|
839
|
+
"init",
|
|
840
|
+
resolve(workspacePath),
|
|
841
|
+
"--state",
|
|
842
|
+
statePath,
|
|
843
|
+
"--confirm",
|
|
844
|
+
"--json",
|
|
845
|
+
"--locale",
|
|
846
|
+
locale,
|
|
847
|
+
],
|
|
848
|
+
operation: "smallpen.init.confirm",
|
|
849
|
+
};
|
|
850
|
+
if (!flag(args, "--confirm") && !interactiveConfirmation) {
|
|
851
|
+
return { ...state, confirmation, statePath };
|
|
852
|
+
}
|
|
853
|
+
const initialized = await initializeWorkspace(workspacePath, state.proposal, {
|
|
854
|
+
confirmed: true,
|
|
855
|
+
});
|
|
856
|
+
await writeAtomic(
|
|
857
|
+
statePath,
|
|
858
|
+
canonicalJSON({
|
|
859
|
+
answers: state.proposal.brief,
|
|
860
|
+
initialized,
|
|
861
|
+
proposal: state.proposal,
|
|
862
|
+
status: "initialized",
|
|
863
|
+
}),
|
|
864
|
+
);
|
|
865
|
+
return { ...initialized, statePath };
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
async function deliverImage(
|
|
869
|
+
bundle,
|
|
870
|
+
{ product, foundation, libraries },
|
|
871
|
+
output,
|
|
872
|
+
includeBase64 = false,
|
|
873
|
+
) {
|
|
874
|
+
return {
|
|
875
|
+
diagnostics: bundle.render.diagnostics,
|
|
876
|
+
height: bundle.render.height,
|
|
877
|
+
mimeType: bundle.render.mimeType,
|
|
878
|
+
packageId: product.manifest.packageId,
|
|
879
|
+
renderHash: bundle.render.renderHash,
|
|
880
|
+
...workspaceRevisions(product, foundation, libraries),
|
|
881
|
+
selection: bundle.evidence.selector,
|
|
882
|
+
width: bundle.render.width,
|
|
883
|
+
...(output === undefined
|
|
884
|
+
? {}
|
|
885
|
+
: { output: await writeAtomic(output, bundle.render.bytes) }),
|
|
886
|
+
...(output === undefined || includeBase64
|
|
887
|
+
? { base64: Buffer.from(bundle.render.bytes).toString("base64") }
|
|
888
|
+
: {}),
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
async function renderCommand(packagePath, args, evidenceOnly) {
|
|
893
|
+
const requestedFormat = option(args, "--format");
|
|
894
|
+
if (requestedFormat && requestedFormat !== "screenshot") {
|
|
895
|
+
throw new SmallPenError(
|
|
896
|
+
"invalid_render_format",
|
|
897
|
+
"render and evidence only accept --format screenshot",
|
|
898
|
+
{ validValues: ["screenshot"] },
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
const workspace = await tokenWorkspace(packagePath);
|
|
902
|
+
const { foundation, libraries, product } = workspace;
|
|
903
|
+
const bundle = await createEvidence(product, {
|
|
904
|
+
foundation,
|
|
905
|
+
libraries,
|
|
906
|
+
scale: numberOption(args, "--scale", 1),
|
|
907
|
+
selector: designSelector(args, "screenshot"),
|
|
908
|
+
});
|
|
909
|
+
const output = option(args, "--output");
|
|
910
|
+
if (evidenceOnly && output !== undefined) {
|
|
911
|
+
const imagePath = await writeAtomic(`${output}.png`, bundle.render.bytes);
|
|
912
|
+
const evidencePath = await writeAtomic(
|
|
913
|
+
`${output}.json`,
|
|
914
|
+
canonicalJSON({
|
|
915
|
+
...bundle.evidence,
|
|
916
|
+
...workspaceRevisions(product, foundation, libraries),
|
|
917
|
+
}),
|
|
918
|
+
);
|
|
919
|
+
return {
|
|
920
|
+
...bundle.evidence,
|
|
921
|
+
...workspaceRevisions(product, foundation, libraries),
|
|
922
|
+
evidencePath,
|
|
923
|
+
imagePath,
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
const image = await deliverImage(bundle, workspace, output);
|
|
927
|
+
return evidenceOnly
|
|
928
|
+
? {
|
|
929
|
+
...bundle.evidence,
|
|
930
|
+
...workspaceRevisions(product, foundation, libraries),
|
|
931
|
+
image,
|
|
932
|
+
}
|
|
933
|
+
: image;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
async function inspectViewCommand(packagePath, args) {
|
|
937
|
+
const output = option(args, "--output");
|
|
938
|
+
if (
|
|
939
|
+
!flag(args, "--include-image") &&
|
|
940
|
+
(output !== undefined || flag(args, "--base64"))
|
|
941
|
+
) {
|
|
942
|
+
throw new SmallPenError(
|
|
943
|
+
"missing_include_image",
|
|
944
|
+
"--output and --base64 require --include-image; omit --output for an inline image without image files",
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
const workspace = await tokenWorkspace(packagePath);
|
|
948
|
+
const { foundation, libraries, product } = workspace;
|
|
949
|
+
const selector = designSelector(args);
|
|
950
|
+
const semantic = readDesignView(product, {
|
|
951
|
+
foundation,
|
|
952
|
+
libraries,
|
|
953
|
+
locale: option(args, "--locale") ?? "zh-TW",
|
|
954
|
+
selector: { ...selector, viewFormat: "semantic" },
|
|
955
|
+
});
|
|
956
|
+
const wireframe = readDesignView(product, {
|
|
957
|
+
foundation,
|
|
958
|
+
libraries,
|
|
959
|
+
locale: option(args, "--locale") ?? "zh-TW",
|
|
960
|
+
selector: { ...selector, viewFormat: "wireframe" },
|
|
961
|
+
});
|
|
962
|
+
const context = semantic.selection.context;
|
|
963
|
+
const result = {
|
|
964
|
+
components: createCatalog(product, { context, foundation, libraries })
|
|
965
|
+
.components,
|
|
966
|
+
packageId: product.manifest.packageId,
|
|
967
|
+
...workspaceRevisions(product, foundation, libraries),
|
|
968
|
+
selection: semantic.selection,
|
|
969
|
+
semanticTree: semantic.result,
|
|
970
|
+
tokens: listEffectiveTokens(product, { context, foundation, libraries }),
|
|
971
|
+
wireframe: wireframe.result,
|
|
972
|
+
};
|
|
973
|
+
if (flag(args, "--include-image")) {
|
|
974
|
+
const bundle = await createEvidence(product, {
|
|
975
|
+
foundation,
|
|
976
|
+
libraries,
|
|
977
|
+
scale: numberOption(args, "--scale", 1),
|
|
978
|
+
selector: designSelector(args, "screenshot"),
|
|
979
|
+
});
|
|
980
|
+
result.image = await deliverImage(
|
|
981
|
+
bundle,
|
|
982
|
+
workspace,
|
|
983
|
+
output,
|
|
984
|
+
flag(args, "--base64"),
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
return result;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
async function renderMatrixCommand(packagePath, args) {
|
|
991
|
+
const contextsPath = option(args, "--contexts");
|
|
992
|
+
if (!contextsPath) {
|
|
993
|
+
throw new SmallPenError(
|
|
994
|
+
"missing_render_contexts",
|
|
995
|
+
"render-matrix requires --contexts CONTEXTS.json",
|
|
996
|
+
);
|
|
997
|
+
}
|
|
998
|
+
const contexts = parseJson(
|
|
999
|
+
await readFile(contextsPath, "utf8"),
|
|
1000
|
+
"invalid_render_contexts_json",
|
|
1001
|
+
{ path: contextsPath },
|
|
1002
|
+
);
|
|
1003
|
+
if (
|
|
1004
|
+
!Array.isArray(contexts) ||
|
|
1005
|
+
contexts.length === 0 ||
|
|
1006
|
+
contexts.length > 32
|
|
1007
|
+
) {
|
|
1008
|
+
throw new SmallPenError(
|
|
1009
|
+
"invalid_render_contexts",
|
|
1010
|
+
"contexts must be an array containing 1 through 32 selectors",
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
const workspace = await tokenWorkspace(packagePath);
|
|
1014
|
+
const { foundation, libraries, product } = workspace;
|
|
1015
|
+
const outputDirectory = option(args, "--output");
|
|
1016
|
+
const scale = numberOption(args, "--scale", 1);
|
|
1017
|
+
const images = [];
|
|
1018
|
+
for (const [index, selector] of contexts.entries()) {
|
|
1019
|
+
if (!isRecord(selector)) {
|
|
1020
|
+
throw new SmallPenError(
|
|
1021
|
+
"invalid_render_selector",
|
|
1022
|
+
"Each context selector must be an object",
|
|
1023
|
+
{
|
|
1024
|
+
index,
|
|
1025
|
+
},
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
const bundle = await createEvidence(product, {
|
|
1029
|
+
foundation,
|
|
1030
|
+
libraries,
|
|
1031
|
+
scale,
|
|
1032
|
+
selector: { ...selector, viewFormat: "screenshot" },
|
|
1033
|
+
});
|
|
1034
|
+
const output =
|
|
1035
|
+
outputDirectory === undefined
|
|
1036
|
+
? undefined
|
|
1037
|
+
: join(
|
|
1038
|
+
outputDirectory,
|
|
1039
|
+
`${String(index + 1).padStart(2, "0")}-${bundle.render.renderHash.slice(0, 12)}.png`,
|
|
1040
|
+
);
|
|
1041
|
+
images.push(await deliverImage(bundle, workspace, output));
|
|
1042
|
+
}
|
|
1043
|
+
return {
|
|
1044
|
+
count: images.length,
|
|
1045
|
+
images,
|
|
1046
|
+
packageId: product.manifest.packageId,
|
|
1047
|
+
...workspaceRevisions(product, foundation, libraries),
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function tokenImpact(snapshot, tokenId, packageId) {
|
|
1052
|
+
const locations = [];
|
|
1053
|
+
const visit = (value, location) => {
|
|
1054
|
+
if (!isRecord(value)) return;
|
|
1055
|
+
if (isRecord(value.tokenBindings)) {
|
|
1056
|
+
for (const [field, binding] of Object.entries(value.tokenBindings)) {
|
|
1057
|
+
if (
|
|
1058
|
+
isRecord(binding) &&
|
|
1059
|
+
binding.assetId === tokenId &&
|
|
1060
|
+
(binding.packageId === undefined || binding.packageId === packageId)
|
|
1061
|
+
) {
|
|
1062
|
+
locations.push({ ...location, field });
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
for (const [key, child] of Object.entries(value)) {
|
|
1067
|
+
if (key !== "tokenBindings") visit(child, { ...location, key });
|
|
1068
|
+
}
|
|
1069
|
+
};
|
|
1070
|
+
for (const entry of snapshot.manifest.entries.screens) {
|
|
1071
|
+
const screen = snapshot.entries[entry];
|
|
1072
|
+
for (const presentation of screen.presentations ?? []) {
|
|
1073
|
+
for (const node of Object.values(presentation.nodes ?? {})) {
|
|
1074
|
+
visit(node, {
|
|
1075
|
+
kind: "screen-node",
|
|
1076
|
+
nodeId: node.id,
|
|
1077
|
+
presentationId: presentation.id,
|
|
1078
|
+
screenId: screen.id,
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
for (const entry of snapshot.manifest.entries.components ?? []) {
|
|
1084
|
+
const componentFile = snapshot.entries[entry];
|
|
1085
|
+
for (const component of componentFile.componentSets ?? []) {
|
|
1086
|
+
for (const variant of component.variants ?? []) {
|
|
1087
|
+
for (const node of Object.values(variant.nodes ?? {})) {
|
|
1088
|
+
visit(node, {
|
|
1089
|
+
componentId: component.id,
|
|
1090
|
+
kind: "component-node",
|
|
1091
|
+
nodeId: node.id,
|
|
1092
|
+
variantId: variant.id,
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
return locations.sort((left, right) =>
|
|
1099
|
+
JSON.stringify(left).localeCompare(JSON.stringify(right)),
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function componentDetails(snapshot, componentId) {
|
|
1104
|
+
const component = snapshot.domain.componentSets.get(componentId);
|
|
1105
|
+
if (!component) {
|
|
1106
|
+
const located = snapshot.domain.locatedComponents.get(componentId);
|
|
1107
|
+
return located ? { ...structuredClone(located), kind: "located" } : undefined;
|
|
1108
|
+
}
|
|
1109
|
+
return {
|
|
1110
|
+
id: component.id,
|
|
1111
|
+
kind: "set",
|
|
1112
|
+
name: component.name,
|
|
1113
|
+
category: component.category,
|
|
1114
|
+
description: component.description,
|
|
1115
|
+
deprecated: component.deprecated,
|
|
1116
|
+
visibility: component.visibility,
|
|
1117
|
+
replaces: component.replaces,
|
|
1118
|
+
replacement: component.replacement,
|
|
1119
|
+
axes: component.axes,
|
|
1120
|
+
variants: component.variants.map(({ id, name, selection, visibility }) => ({
|
|
1121
|
+
id,
|
|
1122
|
+
name,
|
|
1123
|
+
selection,
|
|
1124
|
+
visibility,
|
|
1125
|
+
})),
|
|
1126
|
+
scenarios: [...snapshot.domain.scenarios.values()]
|
|
1127
|
+
.filter(
|
|
1128
|
+
(scenario) =>
|
|
1129
|
+
scenario.target.kind === "component" &&
|
|
1130
|
+
scenario.target.component.assetId === componentId,
|
|
1131
|
+
)
|
|
1132
|
+
.map(({ id, name, target }) => ({ id, name, target }))
|
|
1133
|
+
.sort((left, right) => left.id.localeCompare(right.id)),
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function selectedRepairConflict(resolution, args) {
|
|
1138
|
+
const index = integerOption(args, "--conflict", 0, resolution.conflicts.length - 1);
|
|
1139
|
+
const selected = resolution.conflicts[index];
|
|
1140
|
+
if (!selected) {
|
|
1141
|
+
throw new SmallPenError(
|
|
1142
|
+
"missing_repair_conflict",
|
|
1143
|
+
"Selected Repair conflict does not exist",
|
|
1144
|
+
{ conflictCount: resolution.conflicts.length, index },
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
return { index, selected };
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
async function repairOperations(resolution, conflict, action, args) {
|
|
1151
|
+
const choice = conflict.choices.find((candidate) => candidate.action === action);
|
|
1152
|
+
if (!choice) {
|
|
1153
|
+
throw new SmallPenError(
|
|
1154
|
+
"repair_action_not_offered",
|
|
1155
|
+
`Repair action is not offered for this conflict: ${action}`,
|
|
1156
|
+
{ action, choices: conflict.choices },
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
if (action === "remove-dependent-usage") {
|
|
1160
|
+
return [
|
|
1161
|
+
{
|
|
1162
|
+
action,
|
|
1163
|
+
referencePath: choice.referencePath,
|
|
1164
|
+
type: "repair-reference",
|
|
1165
|
+
},
|
|
1166
|
+
];
|
|
1167
|
+
}
|
|
1168
|
+
if (action === "retarget-reference") {
|
|
1169
|
+
const packageId = option(args, "--replacement-package-id");
|
|
1170
|
+
const assetId = option(args, "--replacement-asset-id");
|
|
1171
|
+
if (!packageId || !assetId) {
|
|
1172
|
+
throw new SmallPenError(
|
|
1173
|
+
"missing_repair_replacement",
|
|
1174
|
+
"retarget-reference requires replacement Package and asset IDs",
|
|
1175
|
+
);
|
|
1176
|
+
}
|
|
1177
|
+
return [
|
|
1178
|
+
{
|
|
1179
|
+
action,
|
|
1180
|
+
referencePath: choice.referencePath,
|
|
1181
|
+
replacement: { assetId, packageId },
|
|
1182
|
+
type: "repair-reference",
|
|
1183
|
+
},
|
|
1184
|
+
];
|
|
1185
|
+
}
|
|
1186
|
+
if (action === "choose-foundation") {
|
|
1187
|
+
const foundationPath = option(args, "--foundation");
|
|
1188
|
+
if (!foundationPath) {
|
|
1189
|
+
throw new SmallPenError(
|
|
1190
|
+
"missing_foundation_path",
|
|
1191
|
+
"choose-foundation requires --foundation PATH",
|
|
1192
|
+
);
|
|
1193
|
+
}
|
|
1194
|
+
const foundation = await openPackage(foundationPath);
|
|
1195
|
+
if (foundation.manifest.role !== "foundation") {
|
|
1196
|
+
throw new SmallPenError(
|
|
1197
|
+
"dependency_not_foundation",
|
|
1198
|
+
"Selected Package does not have the Foundation role",
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
const dependencyPath = relative(
|
|
1202
|
+
dirname(resolution.product.locator),
|
|
1203
|
+
foundation.locator,
|
|
1204
|
+
);
|
|
1205
|
+
if (
|
|
1206
|
+
dependencyPath === "" ||
|
|
1207
|
+
dependencyPath.startsWith("..") ||
|
|
1208
|
+
isAbsolute(dependencyPath)
|
|
1209
|
+
) {
|
|
1210
|
+
throw new SmallPenError(
|
|
1211
|
+
"foundation_path_outside_workspace",
|
|
1212
|
+
"Selected Foundation must stay inside the Product workspace",
|
|
1213
|
+
{ foundationPath: foundation.locator },
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
return [
|
|
1217
|
+
{
|
|
1218
|
+
dependency: {
|
|
1219
|
+
packageId: foundation.manifest.packageId,
|
|
1220
|
+
path: dependencyPath,
|
|
1221
|
+
},
|
|
1222
|
+
type: "set-foundation-dependency",
|
|
1223
|
+
},
|
|
1224
|
+
];
|
|
1225
|
+
}
|
|
1226
|
+
if (action === "recreate-product-asset") {
|
|
1227
|
+
const kind = option(args, "--asset-kind");
|
|
1228
|
+
const assetPath = option(args, "--asset");
|
|
1229
|
+
if (!assetPath || !["component", "token"].includes(kind)) {
|
|
1230
|
+
throw new SmallPenError(
|
|
1231
|
+
"missing_recreated_asset",
|
|
1232
|
+
"recreate-product-asset requires --asset-kind token|component and --asset FILE",
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1235
|
+
if (choice.assetKind && kind !== choice.assetKind) {
|
|
1236
|
+
throw new SmallPenError(
|
|
1237
|
+
"repair_asset_kind_mismatch",
|
|
1238
|
+
`Repair requires a ${choice.assetKind} replacement`,
|
|
1239
|
+
{ actualKind: kind, expectedKind: choice.assetKind },
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
const asset = parseJson(
|
|
1243
|
+
await readFile(assetPath, "utf8"),
|
|
1244
|
+
"invalid_recreated_asset_json",
|
|
1245
|
+
{ path: assetPath },
|
|
1246
|
+
);
|
|
1247
|
+
const create =
|
|
1248
|
+
kind === "token"
|
|
1249
|
+
? { ...asset, type: "put-token" }
|
|
1250
|
+
: { componentSet: asset.componentSet ?? asset, type: "put-component-set" };
|
|
1251
|
+
const assetId =
|
|
1252
|
+
kind === "token"
|
|
1253
|
+
? asset.tokenId
|
|
1254
|
+
: (asset.componentSet ?? asset).id;
|
|
1255
|
+
return [
|
|
1256
|
+
create,
|
|
1257
|
+
{
|
|
1258
|
+
action: "retarget-reference",
|
|
1259
|
+
referencePath: choice.referencePath,
|
|
1260
|
+
replacement: {
|
|
1261
|
+
assetId,
|
|
1262
|
+
packageId: resolution.product.manifest.packageId,
|
|
1263
|
+
},
|
|
1264
|
+
type: "repair-reference",
|
|
1265
|
+
},
|
|
1266
|
+
];
|
|
1267
|
+
}
|
|
1268
|
+
throw new SmallPenError(
|
|
1269
|
+
"unsupported_repair_action",
|
|
1270
|
+
`Unsupported Repair action: ${action}`,
|
|
1271
|
+
);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// SP-017: reusable candidates ship an executable insertion intent so an agent
|
|
1275
|
+
// can place a legal variant instance without guessing node geometry.
|
|
1276
|
+
function insertionAdvice(owner, product, component) {
|
|
1277
|
+
if (!owner || owner.remote) return undefined;
|
|
1278
|
+
const defaultScreenId = product.manifest.defaultScreenId;
|
|
1279
|
+
const screenEntry = defaultScreenId
|
|
1280
|
+
? product.manifest.entries.screens.find(
|
|
1281
|
+
(candidate) => product.entries[candidate].id === defaultScreenId,
|
|
1282
|
+
)
|
|
1283
|
+
: product.manifest.entries.screens[0];
|
|
1284
|
+
if (!screenEntry) return undefined;
|
|
1285
|
+
const screen = product.entries[screenEntry];
|
|
1286
|
+
const presentation = screen.presentations.find(
|
|
1287
|
+
({ id }) => id === screen.basePresentationId,
|
|
1288
|
+
);
|
|
1289
|
+
if (!presentation) return undefined;
|
|
1290
|
+
let dimensions;
|
|
1291
|
+
let variant = {};
|
|
1292
|
+
if (component.kind === "set") {
|
|
1293
|
+
const componentSet = owner.domain.componentSets.get(component.id);
|
|
1294
|
+
const legal = componentSet?.variants[0];
|
|
1295
|
+
if (!legal) return undefined;
|
|
1296
|
+
const rootNode = legal.nodes[legal.rootId];
|
|
1297
|
+
if (!rootNode) return undefined;
|
|
1298
|
+
dimensions = { height: rootNode.height, width: rootNode.width };
|
|
1299
|
+
variant = structuredClone(legal.selection);
|
|
1300
|
+
} else if (component.kind === "located") {
|
|
1301
|
+
const masterEntry = owner.manifest.entries.screens.find(
|
|
1302
|
+
(candidate) => owner.entries[candidate].id === component.screenId,
|
|
1303
|
+
);
|
|
1304
|
+
const masterPresentation = masterEntry
|
|
1305
|
+
? owner.entries[masterEntry].presentations.find(
|
|
1306
|
+
({ id }) => id === component.presentationId,
|
|
1307
|
+
)
|
|
1308
|
+
: undefined;
|
|
1309
|
+
const masterNode = masterPresentation?.nodes[component.mainNodeId];
|
|
1310
|
+
if (!masterNode) return undefined;
|
|
1311
|
+
dimensions = { height: masterNode.height, width: masterNode.width };
|
|
1312
|
+
} else {
|
|
1313
|
+
return undefined;
|
|
1314
|
+
}
|
|
1315
|
+
const instanceNode = {
|
|
1316
|
+
children: [],
|
|
1317
|
+
height: dimensions.height,
|
|
1318
|
+
id: `node_instance_${randomUUID().slice(0, 8)}`,
|
|
1319
|
+
instance: {
|
|
1320
|
+
component: { assetId: component.id, packageId: component.packageId },
|
|
1321
|
+
variant,
|
|
1322
|
+
},
|
|
1323
|
+
name: `${component.name} instance`,
|
|
1324
|
+
type: "INSTANCE",
|
|
1325
|
+
width: dimensions.width,
|
|
1326
|
+
x: 40,
|
|
1327
|
+
y: 40,
|
|
1328
|
+
};
|
|
1329
|
+
return {
|
|
1330
|
+
command: {
|
|
1331
|
+
argv: [
|
|
1332
|
+
"flow",
|
|
1333
|
+
product.locator,
|
|
1334
|
+
"--intent",
|
|
1335
|
+
"<recommendedInsertion.intent.json>",
|
|
1336
|
+
"--json",
|
|
1337
|
+
],
|
|
1338
|
+
},
|
|
1339
|
+
intent: {
|
|
1340
|
+
nodes: [instanceNode],
|
|
1341
|
+
parentId: presentation.rootId,
|
|
1342
|
+
presentationId: presentation.id,
|
|
1343
|
+
screenId: screen.id,
|
|
1344
|
+
},
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// SP-018: deleting a shared Component is refused while known same-workspace
|
|
1349
|
+
// consumers still use it. Unknown offline consumers are out of scope and are
|
|
1350
|
+
// documented as such in the command help.
|
|
1351
|
+
async function checkExternalComponentConsumers(packagePath, operations) {
|
|
1352
|
+
const componentIds = new Set();
|
|
1353
|
+
for (const operation of operations) {
|
|
1354
|
+
if (operation?.type === "delete-component" || operation?.type === "delete-component-set") {
|
|
1355
|
+
if (typeof operation.componentId === "string") {
|
|
1356
|
+
componentIds.add(operation.componentId);
|
|
1357
|
+
} else if (typeof operation.componentSetId === "string") {
|
|
1358
|
+
componentIds.add(operation.componentSetId);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
if (componentIds.size === 0) return;
|
|
1363
|
+
const target = await openPackage(packagePath);
|
|
1364
|
+
if (target.manifest.role === "product") return;
|
|
1365
|
+
const root = dirname(resolve(packagePath));
|
|
1366
|
+
let siblings;
|
|
1367
|
+
try {
|
|
1368
|
+
siblings = (await readdir(root)).filter(
|
|
1369
|
+
(name) =>
|
|
1370
|
+
name.endsWith(".smallpen") && resolve(root, name) !== resolve(packagePath),
|
|
1371
|
+
);
|
|
1372
|
+
} catch {
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
const scanUsages = (snapshot) => {
|
|
1376
|
+
const usages = [];
|
|
1377
|
+
for (const entry of snapshot.manifest.entries.screens) {
|
|
1378
|
+
const screen = snapshot.entries[entry];
|
|
1379
|
+
for (const presentation of screen.presentations ?? []) {
|
|
1380
|
+
for (const node of Object.values(presentation.nodes ?? {})) {
|
|
1381
|
+
const assetId = node.instance?.component?.assetId
|
|
1382
|
+
?? node.componentRef?.assetId;
|
|
1383
|
+
if (
|
|
1384
|
+
(node.componentId !== undefined && componentIds.has(node.componentId)) ||
|
|
1385
|
+
(assetId !== undefined && componentIds.has(assetId))
|
|
1386
|
+
) {
|
|
1387
|
+
usages.push({ nodeId: node.id, presentationId: presentation.id, screenId: screen.id });
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
return usages;
|
|
1393
|
+
};
|
|
1394
|
+
for (const sibling of siblings) {
|
|
1395
|
+
const locator = join(root, sibling);
|
|
1396
|
+
let consumer;
|
|
1397
|
+
try {
|
|
1398
|
+
consumer = await openPackage(locator);
|
|
1399
|
+
} catch {
|
|
1400
|
+
continue;
|
|
1401
|
+
}
|
|
1402
|
+
const declaresTarget = [
|
|
1403
|
+
...(consumer.manifest.dependencies ?? []),
|
|
1404
|
+
...(consumer.manifest.libraries ?? []),
|
|
1405
|
+
].some((dependency) => dependency.packageId === target.manifest.packageId);
|
|
1406
|
+
if (!declaresTarget) continue;
|
|
1407
|
+
for (const usage of scanUsages(consumer)) {
|
|
1408
|
+
throw new SmallPenError(
|
|
1409
|
+
"component_in_use_external",
|
|
1410
|
+
`Component is still used by another workspace Package: ${consumer.manifest.packageId}`,
|
|
1411
|
+
{
|
|
1412
|
+
componentIds: [...componentIds].sort(),
|
|
1413
|
+
consumerLocator: locator,
|
|
1414
|
+
consumerPackageId: consumer.manifest.packageId,
|
|
1415
|
+
nextOperations: [
|
|
1416
|
+
{
|
|
1417
|
+
argv: [
|
|
1418
|
+
"apply",
|
|
1419
|
+
locator,
|
|
1420
|
+
"--batch",
|
|
1421
|
+
"<batch.json removing or retargeting the dependent instance>",
|
|
1422
|
+
"--json",
|
|
1423
|
+
],
|
|
1424
|
+
operation: "smallpen.apply",
|
|
1425
|
+
when: "before-retrying-the-deletion",
|
|
1426
|
+
},
|
|
1427
|
+
],
|
|
1428
|
+
usage,
|
|
1429
|
+
},
|
|
1430
|
+
);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// SP-038: every advertised Repair choice ships an owner-scoped, executable
|
|
1436
|
+
// command so an agent can act on the choice directly.
|
|
1437
|
+
function repairChoiceCommand(locator, productPackageId, conflictIndex, choice) {
|
|
1438
|
+
const base = [
|
|
1439
|
+
"repair",
|
|
1440
|
+
locator,
|
|
1441
|
+
"--conflict",
|
|
1442
|
+
String(conflictIndex),
|
|
1443
|
+
"--action",
|
|
1444
|
+
choice.action,
|
|
1445
|
+
];
|
|
1446
|
+
if (choice.action === "retarget-reference") {
|
|
1447
|
+
// The broken reference's own owner cannot resolve it; default the
|
|
1448
|
+
// replacement to a Product-owned replacement asset (SP-038-A).
|
|
1449
|
+
base.push(
|
|
1450
|
+
"--replacement-package-id",
|
|
1451
|
+
productPackageId,
|
|
1452
|
+
"--replacement-asset-id",
|
|
1453
|
+
"<existing-replacement-asset-id>",
|
|
1454
|
+
);
|
|
1455
|
+
} else if (choice.action === "choose-foundation") {
|
|
1456
|
+
base.push("--foundation", "<path inside the workspace>");
|
|
1457
|
+
} else if (choice.action === "recreate-product-asset") {
|
|
1458
|
+
base.push(
|
|
1459
|
+
"--asset-kind",
|
|
1460
|
+
choice.assetKind ?? "token",
|
|
1461
|
+
"--asset",
|
|
1462
|
+
"<recreated-asset.json>",
|
|
1463
|
+
);
|
|
1464
|
+
}
|
|
1465
|
+
base.push("--json");
|
|
1466
|
+
return { argv: base };
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
async function repairCommand(packagePath, args) {
|
|
1470
|
+
const resolution = await resolveCliWorkspace(packagePath);
|
|
1471
|
+
if (resolution.status === "ready") {
|
|
1472
|
+
return {
|
|
1473
|
+
packageId: resolution.workspace.product.manifest.packageId,
|
|
1474
|
+
revision: resolution.workspace.product.revision,
|
|
1475
|
+
status: "ready",
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
if (!option(args, "--action")) {
|
|
1479
|
+
const locator = resolve(packagePath);
|
|
1480
|
+
return {
|
|
1481
|
+
conflicts: resolution.conflicts.map((conflict, conflictIndex) => ({
|
|
1482
|
+
...conflict,
|
|
1483
|
+
choices: (conflict.choices ?? []).map((choice) => ({
|
|
1484
|
+
...choice,
|
|
1485
|
+
command: repairChoiceCommand(
|
|
1486
|
+
locator,
|
|
1487
|
+
resolution.product.manifest.packageId,
|
|
1488
|
+
conflictIndex,
|
|
1489
|
+
choice,
|
|
1490
|
+
),
|
|
1491
|
+
})),
|
|
1492
|
+
})),
|
|
1493
|
+
packageId: resolution.product.manifest.packageId,
|
|
1494
|
+
revision: resolution.product.revision,
|
|
1495
|
+
status: "repair",
|
|
1496
|
+
};
|
|
1497
|
+
}
|
|
1498
|
+
const { index, selected } = selectedRepairConflict(resolution, args);
|
|
1499
|
+
const action = option(args, "--action");
|
|
1500
|
+
const operations = await repairOperations(resolution, selected, action, args);
|
|
1501
|
+
const result = await applyOperationBatch(resolution.product.locator, {
|
|
1502
|
+
baseRevision: resolution.product.revision,
|
|
1503
|
+
batchId: option(args, "--batch-id") ?? `repair_${randomUUID()}`,
|
|
1504
|
+
operations,
|
|
1505
|
+
});
|
|
1506
|
+
const next = await resolveCliWorkspace(packagePath);
|
|
1507
|
+
return {
|
|
1508
|
+
action,
|
|
1509
|
+
conflictIndex: index,
|
|
1510
|
+
result,
|
|
1511
|
+
status: next.status,
|
|
1512
|
+
...(next.status === "repair" ? { conflicts: next.conflicts } : {}),
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
async function main(args) {
|
|
1517
|
+
const command = args[0];
|
|
1518
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
1519
|
+
printHelp(args[1]);
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
if (command === "version" || command === "--version") {
|
|
1523
|
+
if (flag(args, "--help") || flag(args, "-h")) {
|
|
1524
|
+
printHelp("version");
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
validateCommandArguments("version", ["version", "", ...args.slice(1)]);
|
|
1528
|
+
const { name, version } = JSON.parse(
|
|
1529
|
+
await readFile(new URL("../package.json", import.meta.url), "utf8"),
|
|
1530
|
+
);
|
|
1531
|
+
if (flag(args, "--json")) printJson({ name, version });
|
|
1532
|
+
else process.stdout.write(`${version}\n`);
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
if (!COMMAND_NAMES.includes(command)) {
|
|
1536
|
+
throw new SmallPenError("unknown_command", `Unknown command: ${command}`, {
|
|
1537
|
+
validCommands: COMMAND_NAMES,
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1540
|
+
if (flag(args, "--help") || flag(args, "-h")) {
|
|
1541
|
+
printHelp(command);
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
const packagePath = args[1];
|
|
1545
|
+
if (!packagePath || packagePath.startsWith("-")) {
|
|
1546
|
+
throw new SmallPenError(
|
|
1547
|
+
command === "init" ? "missing_workspace" : "missing_package",
|
|
1548
|
+
command === "init"
|
|
1549
|
+
? "A workspace output directory is required"
|
|
1550
|
+
: "A .smallpen package path is required",
|
|
1551
|
+
{ command },
|
|
1552
|
+
);
|
|
1553
|
+
}
|
|
1554
|
+
validateCommandArguments(command, args);
|
|
1555
|
+
|
|
1556
|
+
if (command === "init") {
|
|
1557
|
+
printJson(await initializeCommand(packagePath, args));
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
if (command === "import-draft") {
|
|
1561
|
+
const inputPath = option(args, "--input");
|
|
1562
|
+
const kind = option(args, "--kind") ?? "figma";
|
|
1563
|
+
if (!inputPath) {
|
|
1564
|
+
throw new SmallPenError(
|
|
1565
|
+
"missing_draft_input",
|
|
1566
|
+
"import-draft requires --input FILE",
|
|
1567
|
+
);
|
|
1568
|
+
}
|
|
1569
|
+
const input = await readFile(inputPath);
|
|
1570
|
+
printJson(
|
|
1571
|
+
await importDraft({
|
|
1572
|
+
...(kind === "figma"
|
|
1573
|
+
? { html: input.toString("utf8") }
|
|
1574
|
+
: { bytes: new Uint8Array(input) }),
|
|
1575
|
+
kind,
|
|
1576
|
+
output: packagePath,
|
|
1577
|
+
packageId: option(args, "--package-id") ?? "pkg_figma_draft",
|
|
1578
|
+
}),
|
|
1579
|
+
);
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
if (command === "import-tokens") {
|
|
1583
|
+
const inputPath = option(args, "--input");
|
|
1584
|
+
if (!inputPath) {
|
|
1585
|
+
throw new SmallPenError(
|
|
1586
|
+
"missing_import_input",
|
|
1587
|
+
"import-tokens requires --input TOKENS.json",
|
|
1588
|
+
);
|
|
1589
|
+
}
|
|
1590
|
+
const document = parseJson(
|
|
1591
|
+
await readFile(inputPath, "utf8"),
|
|
1592
|
+
"invalid_import_json",
|
|
1593
|
+
{ path: inputPath },
|
|
1594
|
+
);
|
|
1595
|
+
const { product: snapshot } = await tokenWorkspace(packagePath);
|
|
1596
|
+
const selection = options(args, "--select");
|
|
1597
|
+
const result = importTokens(snapshot, document, {
|
|
1598
|
+
selection: selection.length > 0 ? selection : undefined,
|
|
1599
|
+
setName: option(args, "--set-name"),
|
|
1600
|
+
});
|
|
1601
|
+
const review = {
|
|
1602
|
+
diff: result.diff,
|
|
1603
|
+
previousLibraryId: result.previousLibraryId,
|
|
1604
|
+
warnings: result.warnings,
|
|
1605
|
+
};
|
|
1606
|
+
if (!flag(args, "--apply")) {
|
|
1607
|
+
printJson({ ...review, dryRun: true, library: result.library });
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
printJson({
|
|
1611
|
+
...(await applyBatch(packagePath, {
|
|
1612
|
+
baseRevision: snapshot.revision,
|
|
1613
|
+
batchId: option(args, "--batch-id") ?? `import_tokens_${randomUUID()}`,
|
|
1614
|
+
operations: result.operations,
|
|
1615
|
+
}, args)),
|
|
1616
|
+
...review,
|
|
1617
|
+
});
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
if (command === "draft-diff") {
|
|
1621
|
+
const afterPath = option(args, "--after");
|
|
1622
|
+
if (!afterPath) {
|
|
1623
|
+
throw new SmallPenError(
|
|
1624
|
+
"missing_draft_after",
|
|
1625
|
+
"draft-diff requires --after AFTER.smallpen",
|
|
1626
|
+
);
|
|
1627
|
+
}
|
|
1628
|
+
printJson(
|
|
1629
|
+
diffDrafts(
|
|
1630
|
+
draftFromSnapshot(await openPackage(packagePath)),
|
|
1631
|
+
draftFromSnapshot(await openPackage(afterPath)),
|
|
1632
|
+
),
|
|
1633
|
+
);
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
if (command === "draft-compile") {
|
|
1637
|
+
const draftPath = option(args, "--draft");
|
|
1638
|
+
const selectionsPath = option(args, "--selections");
|
|
1639
|
+
if (!draftPath || !selectionsPath) {
|
|
1640
|
+
throw new SmallPenError(
|
|
1641
|
+
"missing_draft_compile_input",
|
|
1642
|
+
"draft-compile requires --draft DRAFT.smallpen and --selections FILE",
|
|
1643
|
+
);
|
|
1644
|
+
}
|
|
1645
|
+
const selections = parseJson(
|
|
1646
|
+
await readFile(selectionsPath, "utf8"),
|
|
1647
|
+
"invalid_draft_selections_json",
|
|
1648
|
+
{ path: selectionsPath },
|
|
1649
|
+
);
|
|
1650
|
+
printJson(
|
|
1651
|
+
compileDraftMerge(
|
|
1652
|
+
await openPackage(packagePath),
|
|
1653
|
+
draftFromSnapshot(await openPackage(draftPath)),
|
|
1654
|
+
selections,
|
|
1655
|
+
{ batchId: option(args, "--batch-id") },
|
|
1656
|
+
),
|
|
1657
|
+
);
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
if (command === "read") {
|
|
1661
|
+
const { blobs: _blobs, ...snapshot } = await openPackage(packagePath);
|
|
1662
|
+
printJson(snapshot);
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
if (command === "validate") {
|
|
1666
|
+
const resolution = await resolveCliWorkspace(packagePath);
|
|
1667
|
+
if (resolution.status === "repair") {
|
|
1668
|
+
throw new SmallPenError(
|
|
1669
|
+
"repair_required",
|
|
1670
|
+
"SmallPen workspace requires Repair",
|
|
1671
|
+
{
|
|
1672
|
+
conflicts: resolution.conflicts,
|
|
1673
|
+
nextOperations: [
|
|
1674
|
+
{ args: { packagePath }, operation: "smallpen.repair" },
|
|
1675
|
+
],
|
|
1676
|
+
},
|
|
1677
|
+
);
|
|
1678
|
+
}
|
|
1679
|
+
printJson({
|
|
1680
|
+
foundationRevision: resolution.workspace.foundation?.revision,
|
|
1681
|
+
packageId: resolution.workspace.product.manifest.packageId,
|
|
1682
|
+
revision: resolution.workspace.product.revision,
|
|
1683
|
+
status: "valid",
|
|
1684
|
+
warnings: resolution.warnings ?? [],
|
|
1685
|
+
});
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
if (command === "inspect") {
|
|
1689
|
+
printJson(inspect(await openPackage(packagePath)));
|
|
1690
|
+
return;
|
|
1691
|
+
}
|
|
1692
|
+
if (command === "list") {
|
|
1693
|
+
const snapshot = await openPackage(packagePath);
|
|
1694
|
+
const kind = option(args, "--kind") ?? "all";
|
|
1695
|
+
const offset = integerOption(args, "--offset", 0);
|
|
1696
|
+
const limit = integerOption(args, "--limit", 100, 100);
|
|
1697
|
+
const all = listDomain(snapshot, kind).sort((left, right) =>
|
|
1698
|
+
String(left.item.id ?? "").localeCompare(String(right.item.id ?? "")),
|
|
1699
|
+
);
|
|
1700
|
+
printJson({
|
|
1701
|
+
items: all.slice(offset, offset + limit),
|
|
1702
|
+
kind,
|
|
1703
|
+
page: {
|
|
1704
|
+
hasMore: offset + limit < all.length,
|
|
1705
|
+
limit,
|
|
1706
|
+
offset,
|
|
1707
|
+
total: all.length,
|
|
1708
|
+
},
|
|
1709
|
+
packageId: snapshot.manifest.packageId,
|
|
1710
|
+
revision: snapshot.revision,
|
|
1711
|
+
});
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
if (command === "tokens") {
|
|
1715
|
+
const { foundation, libraries, product } = await tokenWorkspace(packagePath);
|
|
1716
|
+
const context = contextSelection(args);
|
|
1717
|
+
printJson({
|
|
1718
|
+
context,
|
|
1719
|
+
items: listEffectiveTokens(product, { context, foundation, libraries }),
|
|
1720
|
+
labels: { context: "設計狀態", items: "有效設計變數" },
|
|
1721
|
+
packageId: product.manifest.packageId,
|
|
1722
|
+
...workspaceRevisions(product, foundation, libraries),
|
|
1723
|
+
});
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
if (command === "search-tokens") {
|
|
1727
|
+
const { foundation, libraries, product } = await tokenWorkspace(packagePath);
|
|
1728
|
+
const query = option(args, "--query");
|
|
1729
|
+
const requestedType = option(args, "--type");
|
|
1730
|
+
const valueSearch = searchValue(args);
|
|
1731
|
+
if (
|
|
1732
|
+
requestedType !== undefined &&
|
|
1733
|
+
valueSearch.type !== undefined &&
|
|
1734
|
+
requestedType !== valueSearch.type
|
|
1735
|
+
) {
|
|
1736
|
+
throw new SmallPenError(
|
|
1737
|
+
"conflicting_token_search_type",
|
|
1738
|
+
`--color requires Token type color, not ${requestedType}`,
|
|
1739
|
+
{ impliedType: valueSearch.type, requestedType },
|
|
1740
|
+
);
|
|
1741
|
+
}
|
|
1742
|
+
if (
|
|
1743
|
+
query === undefined &&
|
|
1744
|
+
requestedType === undefined &&
|
|
1745
|
+
!Object.hasOwn(valueSearch, "value")
|
|
1746
|
+
) {
|
|
1747
|
+
throw new SmallPenError(
|
|
1748
|
+
"missing_token_search",
|
|
1749
|
+
"search-tokens requires --color, --value, --query, or --type",
|
|
1750
|
+
);
|
|
1751
|
+
}
|
|
1752
|
+
const validTypes = SMALLPEN_FORMAT_CAPABILITIES.canonicalPackage.tokenTypes;
|
|
1753
|
+
const type = requestedType ?? valueSearch.type;
|
|
1754
|
+
if (type !== undefined && !validTypes.includes(type)) {
|
|
1755
|
+
throw new SmallPenError(
|
|
1756
|
+
"invalid_token_type",
|
|
1757
|
+
`Unsupported Token type: ${type}`,
|
|
1758
|
+
{ type, validTypes },
|
|
1759
|
+
);
|
|
1760
|
+
}
|
|
1761
|
+
const limit = integerOption(args, "--limit", 20, 100);
|
|
1762
|
+
if (limit === 0) {
|
|
1763
|
+
throw new SmallPenError(
|
|
1764
|
+
"invalid_token_search_limit",
|
|
1765
|
+
"--limit must be between 1 and 100",
|
|
1766
|
+
{ limit },
|
|
1767
|
+
);
|
|
1768
|
+
}
|
|
1769
|
+
printJson({
|
|
1770
|
+
...searchEffectiveTokens(product, {
|
|
1771
|
+
...valueSearch,
|
|
1772
|
+
...(options(args, "--context").length > 0
|
|
1773
|
+
? { context: contextSelection(args) }
|
|
1774
|
+
: {}),
|
|
1775
|
+
foundation,
|
|
1776
|
+
libraries,
|
|
1777
|
+
limit,
|
|
1778
|
+
query,
|
|
1779
|
+
type,
|
|
1780
|
+
}),
|
|
1781
|
+
packageId: product.manifest.packageId,
|
|
1782
|
+
...workspaceRevisions(product, foundation, libraries),
|
|
1783
|
+
});
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
if (command === "read-view" || command === "discover") {
|
|
1787
|
+
const { foundation, libraries, product } = await tokenWorkspace(packagePath);
|
|
1788
|
+
const read = readDesignView(product, {
|
|
1789
|
+
foundation,
|
|
1790
|
+
libraries,
|
|
1791
|
+
limit: integerOption(args, "--limit", 20, 100),
|
|
1792
|
+
locale: option(args, "--locale") ?? "zh-TW",
|
|
1793
|
+
offset: integerOption(args, "--offset", 0),
|
|
1794
|
+
selector: designSelector(args),
|
|
1795
|
+
});
|
|
1796
|
+
if (command === "discover") {
|
|
1797
|
+
printJson(read.discovery);
|
|
1798
|
+
} else if (read.format === "screenshot") {
|
|
1799
|
+
const bundle = await createEvidence(product, {
|
|
1800
|
+
foundation,
|
|
1801
|
+
libraries,
|
|
1802
|
+
scale: 1,
|
|
1803
|
+
selector: designSelector(args, "screenshot"),
|
|
1804
|
+
});
|
|
1805
|
+
printJson({
|
|
1806
|
+
...read,
|
|
1807
|
+
result: {
|
|
1808
|
+
diagnostics: bundle.render.diagnostics,
|
|
1809
|
+
height: bundle.render.height,
|
|
1810
|
+
mimeType: bundle.render.mimeType,
|
|
1811
|
+
renderHash: bundle.render.renderHash,
|
|
1812
|
+
status: "rendered",
|
|
1813
|
+
width: bundle.render.width,
|
|
1814
|
+
},
|
|
1815
|
+
});
|
|
1816
|
+
} else {
|
|
1817
|
+
printJson(read);
|
|
1818
|
+
}
|
|
1819
|
+
return;
|
|
1820
|
+
}
|
|
1821
|
+
if (command === "catalog") {
|
|
1822
|
+
const { foundation, libraries, product } = await tokenWorkspace(packagePath);
|
|
1823
|
+
printJson({
|
|
1824
|
+
...createCatalog(product, {
|
|
1825
|
+
context: contextSelection(args),
|
|
1826
|
+
foundation,
|
|
1827
|
+
libraries,
|
|
1828
|
+
}),
|
|
1829
|
+
...workspaceRevisions(product, foundation, libraries),
|
|
1830
|
+
warnings: (await resolveCliWorkspace(packagePath)).warnings ?? [],
|
|
1831
|
+
});
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
if (command === "search-components") {
|
|
1835
|
+
const query = option(args, "--query");
|
|
1836
|
+
if (!query || query.trim().length === 0) {
|
|
1837
|
+
throw new SmallPenError(
|
|
1838
|
+
"missing_component_search",
|
|
1839
|
+
"search-components requires --query TEXT",
|
|
1840
|
+
);
|
|
1841
|
+
}
|
|
1842
|
+
const limit = integerOption(args, "--limit", 20, 100);
|
|
1843
|
+
if (limit === 0) {
|
|
1844
|
+
throw new SmallPenError(
|
|
1845
|
+
"invalid_component_search_limit",
|
|
1846
|
+
"--limit must be between 1 and 100",
|
|
1847
|
+
{ limit },
|
|
1848
|
+
);
|
|
1849
|
+
}
|
|
1850
|
+
const { foundation, libraries, product } = await tokenWorkspace(packagePath);
|
|
1851
|
+
const catalog = createCatalog(product, { foundation, libraries });
|
|
1852
|
+
const normalized = query.trim().toLowerCase();
|
|
1853
|
+
const matches = catalog.components.filter((component) =>
|
|
1854
|
+
[component.id, component.name, component.category, component.description]
|
|
1855
|
+
.filter(Boolean)
|
|
1856
|
+
.some((value) => String(value).toLowerCase().includes(normalized)),
|
|
1857
|
+
);
|
|
1858
|
+
printJson({
|
|
1859
|
+
items: matches.slice(0, limit).map((component) => {
|
|
1860
|
+
const owner = [product, foundation, ...libraries].find(
|
|
1861
|
+
(snapshot) => snapshot?.manifest.packageId === component.packageId,
|
|
1862
|
+
);
|
|
1863
|
+
const argv = [
|
|
1864
|
+
"component",
|
|
1865
|
+
owner.locator,
|
|
1866
|
+
"--component-id",
|
|
1867
|
+
component.id,
|
|
1868
|
+
"--json",
|
|
1869
|
+
];
|
|
1870
|
+
const command = ["smallpen", ...argv]
|
|
1871
|
+
.map((argument) =>
|
|
1872
|
+
/^[A-Za-z0-9_./-]+$/.test(argument)
|
|
1873
|
+
? argument
|
|
1874
|
+
: `'${argument.replaceAll("'", "'\\''")}'`,
|
|
1875
|
+
)
|
|
1876
|
+
.join(" ");
|
|
1877
|
+
const insertion = insertionAdvice(owner, product, component);
|
|
1878
|
+
return {
|
|
1879
|
+
...component,
|
|
1880
|
+
nextOperations: [{ argv, command, operation: "smallpen.component" }],
|
|
1881
|
+
...(insertion ? { recommendedInsertion: insertion } : {}),
|
|
1882
|
+
};
|
|
1883
|
+
}),
|
|
1884
|
+
packageId: product.manifest.packageId,
|
|
1885
|
+
query,
|
|
1886
|
+
total: matches.length,
|
|
1887
|
+
...workspaceRevisions(product, foundation, libraries),
|
|
1888
|
+
});
|
|
1889
|
+
return;
|
|
1890
|
+
}
|
|
1891
|
+
if (command === "component") {
|
|
1892
|
+
const componentId = option(args, "--component-id");
|
|
1893
|
+
if (!componentId) {
|
|
1894
|
+
throw new SmallPenError(
|
|
1895
|
+
"missing_component_id",
|
|
1896
|
+
"component requires --component-id CMP",
|
|
1897
|
+
);
|
|
1898
|
+
}
|
|
1899
|
+
const snapshot = /^https?:\/\//.test(packagePath)
|
|
1900
|
+
? await openRemoteLibrary(packagePath, {
|
|
1901
|
+
cacheRoot: defaultLibraryCacheRoot(),
|
|
1902
|
+
})
|
|
1903
|
+
: await openPackage(packagePath);
|
|
1904
|
+
const component = componentDetails(snapshot, componentId);
|
|
1905
|
+
if (!component) {
|
|
1906
|
+
throw new SmallPenError("missing_component", "Component Set is not available", {
|
|
1907
|
+
componentId,
|
|
1908
|
+
});
|
|
1909
|
+
}
|
|
1910
|
+
printJson({
|
|
1911
|
+
component,
|
|
1912
|
+
packageId: snapshot.manifest.packageId,
|
|
1913
|
+
revision: snapshot.revision,
|
|
1914
|
+
});
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
if (command === "compare") {
|
|
1918
|
+
const selectors = options(args, "--selector").map((value) =>
|
|
1919
|
+
parseJson(value, "invalid_compare_selector", { value }),
|
|
1920
|
+
);
|
|
1921
|
+
const { foundation, libraries, product } = await tokenWorkspace(packagePath);
|
|
1922
|
+
printJson(createCompareView(product, selectors, { foundation, libraries }));
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
if (command === "effective-token" || command === "explain-token") {
|
|
1926
|
+
const { foundation, libraries, product } = await tokenWorkspace(packagePath);
|
|
1927
|
+
const reference = tokenReference(args, product);
|
|
1928
|
+
const context = contextSelection(args);
|
|
1929
|
+
const result =
|
|
1930
|
+
command === "explain-token"
|
|
1931
|
+
? explainEffectiveToken(product, reference, { context, foundation, libraries })
|
|
1932
|
+
: resolveEffectiveToken(product, reference, { context, foundation, libraries });
|
|
1933
|
+
if (!result) {
|
|
1934
|
+
throw new SmallPenError("missing_token", "Token is not available", {
|
|
1935
|
+
reference,
|
|
1936
|
+
});
|
|
1937
|
+
}
|
|
1938
|
+
printJson(result);
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
if (command === "render" || command === "evidence") {
|
|
1942
|
+
printJson(await renderCommand(packagePath, args, command === "evidence"));
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
if (command === "inspect-view") {
|
|
1946
|
+
printJson(await inspectViewCommand(packagePath, args));
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
if (command === "render-matrix") {
|
|
1950
|
+
printJson(await renderMatrixCommand(packagePath, args));
|
|
1951
|
+
return;
|
|
1952
|
+
}
|
|
1953
|
+
if (command === "impact") {
|
|
1954
|
+
const { foundation, libraries, product } = await tokenWorkspace(packagePath);
|
|
1955
|
+
const tokenId = option(args, "--token-id");
|
|
1956
|
+
const tokenPath = option(args, "--path");
|
|
1957
|
+
const requestedPackageId = option(args, "--package-id");
|
|
1958
|
+
if ((tokenId === undefined) === (tokenPath === undefined)) {
|
|
1959
|
+
throw new SmallPenError(
|
|
1960
|
+
"missing_token_reference",
|
|
1961
|
+
"impact requires exactly one of --token-id or --path",
|
|
1962
|
+
);
|
|
1963
|
+
}
|
|
1964
|
+
const matches = [product, foundation, ...libraries]
|
|
1965
|
+
.filter(Boolean)
|
|
1966
|
+
.filter(
|
|
1967
|
+
(snapshot) =>
|
|
1968
|
+
requestedPackageId === undefined ||
|
|
1969
|
+
snapshot.manifest.packageId === requestedPackageId,
|
|
1970
|
+
)
|
|
1971
|
+
.flatMap((snapshot) => {
|
|
1972
|
+
const token = tokenId
|
|
1973
|
+
? snapshot.domain.tokens.get(tokenId)
|
|
1974
|
+
: [...snapshot.domain.tokens.values()].find(
|
|
1975
|
+
(candidate) => candidate.path === tokenPath,
|
|
1976
|
+
);
|
|
1977
|
+
return token ? [{ snapshot, token }] : [];
|
|
1978
|
+
});
|
|
1979
|
+
if (matches.length === 0) {
|
|
1980
|
+
throw new SmallPenError("missing_token", "Token is not available", {
|
|
1981
|
+
packageId: requestedPackageId,
|
|
1982
|
+
tokenId,
|
|
1983
|
+
path: tokenPath,
|
|
1984
|
+
});
|
|
1985
|
+
}
|
|
1986
|
+
if (matches.length > 1) {
|
|
1987
|
+
throw new SmallPenError(
|
|
1988
|
+
"ambiguous_token_reference",
|
|
1989
|
+
"Token exists in more than one Package; specify --package-id",
|
|
1990
|
+
{
|
|
1991
|
+
packageIds: matches.map(({ snapshot }) => snapshot.manifest.packageId),
|
|
1992
|
+
tokenId,
|
|
1993
|
+
path: tokenPath,
|
|
1994
|
+
},
|
|
1995
|
+
);
|
|
1996
|
+
}
|
|
1997
|
+
const [{ snapshot: owner, token }] = matches;
|
|
1998
|
+
printJson({
|
|
1999
|
+
locations: tokenImpact(product, token.id, owner.manifest.packageId),
|
|
2000
|
+
ownerRevision: owner.revision,
|
|
2001
|
+
packageId: owner.manifest.packageId,
|
|
2002
|
+
...workspaceRevisions(product, foundation, libraries),
|
|
2003
|
+
token: { id: token.id, path: token.path, type: token.type },
|
|
2004
|
+
});
|
|
2005
|
+
return;
|
|
2006
|
+
}
|
|
2007
|
+
if (command === "apply") {
|
|
2008
|
+
const batchPath = option(args, "--batch");
|
|
2009
|
+
if (!batchPath) {
|
|
2010
|
+
throw new SmallPenError(
|
|
2011
|
+
"missing_batch",
|
|
2012
|
+
"apply requires --batch BATCH.json",
|
|
2013
|
+
);
|
|
2014
|
+
}
|
|
2015
|
+
const batch = parseJson(
|
|
2016
|
+
await readFile(batchPath, "utf8"),
|
|
2017
|
+
"invalid_batch_json",
|
|
2018
|
+
{ path: batchPath },
|
|
2019
|
+
);
|
|
2020
|
+
await checkExternalComponentConsumers(packagePath, batch.operations ?? []);
|
|
2021
|
+
printJson(await applyBatch(packagePath, batch, args));
|
|
2022
|
+
return;
|
|
2023
|
+
}
|
|
2024
|
+
if (command === "flow" || command === "page") {
|
|
2025
|
+
const intentPath = option(args, "--intent");
|
|
2026
|
+
if (!intentPath) {
|
|
2027
|
+
throw new SmallPenError(
|
|
2028
|
+
"missing_flow_intent",
|
|
2029
|
+
"flow requires --intent INTENT.json",
|
|
2030
|
+
);
|
|
2031
|
+
}
|
|
2032
|
+
const intent = parseJson(
|
|
2033
|
+
await readFile(intentPath, "utf8"),
|
|
2034
|
+
"invalid_flow_intent_json",
|
|
2035
|
+
{ path: intentPath },
|
|
2036
|
+
);
|
|
2037
|
+
if (!isRecord(intent) || typeof intent.screenId !== "string") {
|
|
2038
|
+
throw new SmallPenError(
|
|
2039
|
+
"invalid_flow_intent",
|
|
2040
|
+
"flow intent requires screenId and nodes",
|
|
2041
|
+
);
|
|
2042
|
+
}
|
|
2043
|
+
if (!Array.isArray(intent.nodes) || intent.nodes.length === 0) {
|
|
2044
|
+
throw new SmallPenError(
|
|
2045
|
+
"invalid_flow_intent",
|
|
2046
|
+
"flow intent nodes must be a non-empty array",
|
|
2047
|
+
);
|
|
2048
|
+
}
|
|
2049
|
+
const { product: snapshot } = await tokenWorkspace(packagePath);
|
|
2050
|
+
const screenEntry = snapshot.manifest.entries.screens.find(
|
|
2051
|
+
(entry) => snapshot.entries[entry]?.id === intent.screenId,
|
|
2052
|
+
);
|
|
2053
|
+
if (!screenEntry) {
|
|
2054
|
+
throw new SmallPenError(
|
|
2055
|
+
"missing_screen",
|
|
2056
|
+
`Screen is not owned by this package: ${intent.screenId}`,
|
|
2057
|
+
);
|
|
2058
|
+
}
|
|
2059
|
+
const screen = snapshot.entries[screenEntry];
|
|
2060
|
+
const presentationId = intent.presentationId ?? screen.basePresentationId;
|
|
2061
|
+
const presentation = screen.presentations.find(
|
|
2062
|
+
(candidate) => candidate.id === presentationId,
|
|
2063
|
+
);
|
|
2064
|
+
if (!presentation) {
|
|
2065
|
+
throw new SmallPenError(
|
|
2066
|
+
"missing_presentation",
|
|
2067
|
+
`Presentation is not owned by this Screen: ${presentationId}`,
|
|
2068
|
+
);
|
|
2069
|
+
}
|
|
2070
|
+
// New flow nodes belong inside the presentation root by default. This keeps
|
|
2071
|
+
// the generated semantic tree and rendered view rooted at the existing
|
|
2072
|
+
// screen canvas; callers can pass parentId:null for an explicit root node.
|
|
2073
|
+
const parentId = intent.parentId === undefined
|
|
2074
|
+
? presentation.rootId
|
|
2075
|
+
: intent.parentId;
|
|
2076
|
+
const operations = intent.nodes.map((node, index) => ({
|
|
2077
|
+
index: intent.index === undefined ? undefined : intent.index + index,
|
|
2078
|
+
node,
|
|
2079
|
+
parentId,
|
|
2080
|
+
presentationId,
|
|
2081
|
+
screenId: intent.screenId,
|
|
2082
|
+
type: "add-presentation-node",
|
|
2083
|
+
}));
|
|
2084
|
+
printJson(
|
|
2085
|
+
await applyBatch(packagePath, {
|
|
2086
|
+
baseRevision: snapshot.revision,
|
|
2087
|
+
batchId: option(args, "--batch-id") ?? `${command}_${randomUUID()}`,
|
|
2088
|
+
operations,
|
|
2089
|
+
}, args),
|
|
2090
|
+
);
|
|
2091
|
+
return;
|
|
2092
|
+
}
|
|
2093
|
+
if (command === "token") {
|
|
2094
|
+
const intentPath = option(args, "--intent");
|
|
2095
|
+
if (!intentPath) {
|
|
2096
|
+
throw new SmallPenError(
|
|
2097
|
+
"missing_token_intent",
|
|
2098
|
+
"token requires --intent INTENT.json",
|
|
2099
|
+
);
|
|
2100
|
+
}
|
|
2101
|
+
const intent = parseJson(
|
|
2102
|
+
await readFile(intentPath, "utf8"),
|
|
2103
|
+
"invalid_token_intent_json",
|
|
2104
|
+
{ path: intentPath },
|
|
2105
|
+
);
|
|
2106
|
+
if (!isRecord(intent) || !Array.isArray(intent.operations) || intent.operations.length === 0) {
|
|
2107
|
+
throw new SmallPenError(
|
|
2108
|
+
"invalid_token_intent",
|
|
2109
|
+
"token intent operations must be a non-empty array",
|
|
2110
|
+
);
|
|
2111
|
+
}
|
|
2112
|
+
const { product: snapshot } = await tokenWorkspace(packagePath);
|
|
2113
|
+
printJson(
|
|
2114
|
+
await applyBatch(packagePath, {
|
|
2115
|
+
baseRevision: snapshot.revision,
|
|
2116
|
+
batchId: option(args, "--batch-id") ?? `token_${randomUUID()}`,
|
|
2117
|
+
operations: intent.operations,
|
|
2118
|
+
}, args),
|
|
2119
|
+
);
|
|
2120
|
+
return;
|
|
2121
|
+
}
|
|
2122
|
+
if (command === "import-media") {
|
|
2123
|
+
const mediaPath = option(args, "--file") ?? args[1];
|
|
2124
|
+
if (!mediaPath) {
|
|
2125
|
+
throw new SmallPenError(
|
|
2126
|
+
"missing_media_file",
|
|
2127
|
+
"import-media requires --file MEDIA_FILE",
|
|
2128
|
+
);
|
|
2129
|
+
}
|
|
2130
|
+
const bytes = new Uint8Array(await readFile(resolve(mediaPath)));
|
|
2131
|
+
const inspected = inspectMedia(bytes);
|
|
2132
|
+
const mediaId = option(args, "--media-id") ?? `media_${randomUUID()}`;
|
|
2133
|
+
const name = option(args, "--name") ?? mediaPath.split(/[\\/]/).pop();
|
|
2134
|
+
const print = await importMedia(packagePath, {
|
|
2135
|
+
bytes,
|
|
2136
|
+
height: inspected.height,
|
|
2137
|
+
id: mediaId,
|
|
2138
|
+
mimeType: inspected.mimeType,
|
|
2139
|
+
name,
|
|
2140
|
+
path: option(args, "--media-path") ?? "",
|
|
2141
|
+
width: inspected.width,
|
|
2142
|
+
});
|
|
2143
|
+
printJson({
|
|
2144
|
+
descriptor: print.descriptor,
|
|
2145
|
+
revision: print.result.revision,
|
|
2146
|
+
});
|
|
2147
|
+
return;
|
|
2148
|
+
}
|
|
2149
|
+
if (command === "remove-media") {
|
|
2150
|
+
const mediaId = option(args, "--media-id");
|
|
2151
|
+
if (!mediaId) {
|
|
2152
|
+
throw new SmallPenError(
|
|
2153
|
+
"missing_media_id",
|
|
2154
|
+
"remove-media requires --media-id MEDIA",
|
|
2155
|
+
);
|
|
2156
|
+
}
|
|
2157
|
+
const result = await removeMedia(packagePath, mediaId);
|
|
2158
|
+
printJson({ mediaId, removed: true, revision: result.revision });
|
|
2159
|
+
return;
|
|
2160
|
+
}
|
|
2161
|
+
if (command === "import-font") {
|
|
2162
|
+
const fontPath = option(args, "--file") ?? args[1];
|
|
2163
|
+
if (!fontPath) {
|
|
2164
|
+
throw new SmallPenError(
|
|
2165
|
+
"missing_font_file",
|
|
2166
|
+
"import-font requires --file FONT_FILE",
|
|
2167
|
+
);
|
|
2168
|
+
}
|
|
2169
|
+
const family = option(args, "--family");
|
|
2170
|
+
if (!family) {
|
|
2171
|
+
throw new SmallPenError(
|
|
2172
|
+
"missing_font_family",
|
|
2173
|
+
"import-font requires --family NAME",
|
|
2174
|
+
);
|
|
2175
|
+
}
|
|
2176
|
+
const bytes = new Uint8Array(await readFile(resolve(fontPath)));
|
|
2177
|
+
const signatureTtf = bytes[0] === 0x00 && bytes[1] === 0x01;
|
|
2178
|
+
const signatureOtf = bytes[0] === 0x4f && bytes[1] === 0x54;
|
|
2179
|
+
const signatureWoff = bytes[0] === 0x77 && bytes[1] === 0x4f && bytes[2] === 0x46 && bytes[3] === 0x46;
|
|
2180
|
+
const signatureWoff2 = bytes[0] === 0x77 && bytes[1] === 0x4f && bytes[2] === 0x46 && bytes[3] === 0x32;
|
|
2181
|
+
const files = {};
|
|
2182
|
+
if (signatureTtf) {
|
|
2183
|
+
inspectFont(bytes, "font/ttf");
|
|
2184
|
+
files.ttf = { bytes, mimeType: "font/ttf" };
|
|
2185
|
+
files.woff = {
|
|
2186
|
+
bytes: sfntToWoff(bytes, "font/ttf"),
|
|
2187
|
+
mimeType: "font/woff",
|
|
2188
|
+
};
|
|
2189
|
+
} else if (signatureOtf) {
|
|
2190
|
+
inspectFont(bytes, "font/otf");
|
|
2191
|
+
files.otf = { bytes, mimeType: "font/otf" };
|
|
2192
|
+
files.woff = {
|
|
2193
|
+
bytes: sfntToWoff(bytes, "font/otf"),
|
|
2194
|
+
mimeType: "font/woff",
|
|
2195
|
+
};
|
|
2196
|
+
} else if (signatureWoff2) {
|
|
2197
|
+
inspectFont(bytes, "font/woff2");
|
|
2198
|
+
files.woff2 = { bytes, mimeType: "font/woff2" };
|
|
2199
|
+
throw new SmallPenError(
|
|
2200
|
+
"unsupported_font_conversion",
|
|
2201
|
+
"WOFF2 cannot be converted without a WOFF2 decoder; supply TTF, OTF, or WOFF",
|
|
2202
|
+
{ mimeType: "font/woff2" },
|
|
2203
|
+
);
|
|
2204
|
+
} else if (signatureWoff) {
|
|
2205
|
+
inspectFont(bytes, "font/woff");
|
|
2206
|
+
files.woff = { bytes, mimeType: "font/woff" };
|
|
2207
|
+
} else {
|
|
2208
|
+
inspectFont(bytes, undefined);
|
|
2209
|
+
}
|
|
2210
|
+
const fontId = option(args, "--font-id") ?? `font_${randomUUID()}`;
|
|
2211
|
+
const style = option(args, "--style") ?? "normal";
|
|
2212
|
+
const weight = Number(option(args, "--weight") ?? 400);
|
|
2213
|
+
const variantId = option(args, "--variant-id") ?? `fvar_${randomUUID()}`;
|
|
2214
|
+
const { result, variant } = await importFontVariant(packagePath, {
|
|
2215
|
+
family,
|
|
2216
|
+
files,
|
|
2217
|
+
fontId,
|
|
2218
|
+
id: variantId,
|
|
2219
|
+
name: option(args, "--name") ?? `${style}-${weight}`,
|
|
2220
|
+
style,
|
|
2221
|
+
weight,
|
|
2222
|
+
});
|
|
2223
|
+
printJson({
|
|
2224
|
+
fontId,
|
|
2225
|
+
files: variant.files,
|
|
2226
|
+
revision: result.revision,
|
|
2227
|
+
variantId: variant.id,
|
|
2228
|
+
});
|
|
2229
|
+
return;
|
|
2230
|
+
}
|
|
2231
|
+
if (command === "library-refresh") {
|
|
2232
|
+
const resolution = await resolveCliWorkspace(packagePath);
|
|
2233
|
+
if (resolution.status !== "ready") {
|
|
2234
|
+
throw new SmallPenError(
|
|
2235
|
+
"repair_required",
|
|
2236
|
+
"SmallPen workspace requires Repair",
|
|
2237
|
+
{ conflicts: resolution.conflicts },
|
|
2238
|
+
);
|
|
2239
|
+
}
|
|
2240
|
+
const { libraries, product } = resolution.workspace;
|
|
2241
|
+
const selector = option(args, "--library");
|
|
2242
|
+
const declared = (product.manifest.libraries ?? []).filter(
|
|
2243
|
+
(library) => library.source?.type === "url",
|
|
2244
|
+
);
|
|
2245
|
+
const selected = selector
|
|
2246
|
+
? declared.find(
|
|
2247
|
+
(library) =>
|
|
2248
|
+
library.packageId === selector || library.source.url === selector,
|
|
2249
|
+
)
|
|
2250
|
+
: declared[0];
|
|
2251
|
+
if (!selected) {
|
|
2252
|
+
throw new SmallPenError(
|
|
2253
|
+
"missing_library_source",
|
|
2254
|
+
"No declared URL Library matches --library",
|
|
2255
|
+
{
|
|
2256
|
+
declaredIds: declared.map((library) => library.packageId),
|
|
2257
|
+
selector,
|
|
2258
|
+
},
|
|
2259
|
+
);
|
|
2260
|
+
}
|
|
2261
|
+
const before = libraries.find(
|
|
2262
|
+
(library) => library.manifest.packageId === selected.packageId,
|
|
2263
|
+
);
|
|
2264
|
+
const refreshed = await openRemoteLibrary(selected.source.url, {
|
|
2265
|
+
cacheRoot: defaultLibraryCacheRoot(),
|
|
2266
|
+
expectedPackageId: selected.packageId,
|
|
2267
|
+
refresh: true,
|
|
2268
|
+
});
|
|
2269
|
+
printJson({
|
|
2270
|
+
after: {
|
|
2271
|
+
cache: refreshed.remote.cache,
|
|
2272
|
+
packageId: refreshed.manifest.packageId,
|
|
2273
|
+
revision: refreshed.revision,
|
|
2274
|
+
},
|
|
2275
|
+
before: before
|
|
2276
|
+
? { packageId: before.manifest.packageId, revision: before.revision }
|
|
2277
|
+
: undefined,
|
|
2278
|
+
packageId: selected.packageId,
|
|
2279
|
+
revision: product.revision,
|
|
2280
|
+
sourceUrl: selected.source.url,
|
|
2281
|
+
});
|
|
2282
|
+
return;
|
|
2283
|
+
}
|
|
2284
|
+
if (command === "watch") {
|
|
2285
|
+
const interval = Math.max(100, Number(option(args, "--interval") ?? 500));
|
|
2286
|
+
// Default is unlimited: watch without --max-events must keep observing
|
|
2287
|
+
// (SP-041-A caught a fallback of 1 that exited after the first event).
|
|
2288
|
+
const maxEvents = integerOption(args, "--max-events", Number.POSITIVE_INFINITY, 10_000);
|
|
2289
|
+
const emit = (payload) =>
|
|
2290
|
+
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
2291
|
+
let previous;
|
|
2292
|
+
let events = 0;
|
|
2293
|
+
process.on("SIGINT", () => process.exit(0));
|
|
2294
|
+
for (;;) {
|
|
2295
|
+
try {
|
|
2296
|
+
const snapshot = await openPackage(packagePath);
|
|
2297
|
+
if (snapshot.revision !== previous) {
|
|
2298
|
+
previous = snapshot.revision;
|
|
2299
|
+
events += 1;
|
|
2300
|
+
emit({
|
|
2301
|
+
event: "revision",
|
|
2302
|
+
packageId: snapshot.manifest.packageId,
|
|
2303
|
+
revision: snapshot.revision,
|
|
2304
|
+
});
|
|
2305
|
+
if (maxEvents !== undefined && events >= maxEvents) return;
|
|
2306
|
+
}
|
|
2307
|
+
} catch (error) {
|
|
2308
|
+
events += 1;
|
|
2309
|
+
emit({
|
|
2310
|
+
code: error instanceof SmallPenError ? error.code : "internal_error",
|
|
2311
|
+
event: "invalid",
|
|
2312
|
+
message: error instanceof Error ? error.message : String(error),
|
|
2313
|
+
});
|
|
2314
|
+
if (maxEvents !== undefined && events >= maxEvents) return;
|
|
2315
|
+
}
|
|
2316
|
+
await delay(interval);
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
if (command === "repair") {
|
|
2320
|
+
printJson(await repairCommand(packagePath, args));
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
function errorDetails(error, args) {
|
|
2325
|
+
const details = error instanceof SmallPenError ? error.details : {};
|
|
2326
|
+
if (error?.code === "unknown_option" && typeof details.command === "string") {
|
|
2327
|
+
return {
|
|
2328
|
+
...details,
|
|
2329
|
+
nextOperations: [
|
|
2330
|
+
{ argv: [details.command, "--help"], operation: "smallpen.help" },
|
|
2331
|
+
],
|
|
2332
|
+
recovery: "Read the command help and correct the option explicitly before retrying. No option was guessed and no operation was applied.",
|
|
2333
|
+
};
|
|
2334
|
+
}
|
|
2335
|
+
if (
|
|
2336
|
+
["invalid_entry_json", "invalid_manifest_json"].includes(error?.code) &&
|
|
2337
|
+
typeof details.packagePath === "string"
|
|
2338
|
+
) {
|
|
2339
|
+
return {
|
|
2340
|
+
...details,
|
|
2341
|
+
entryPath: resolve(details.packagePath, details.entry ?? "manifest.json"),
|
|
2342
|
+
nextOperations: [
|
|
2343
|
+
{
|
|
2344
|
+
argv: ["validate", details.packagePath, "--json"],
|
|
2345
|
+
operation: "smallpen.validate",
|
|
2346
|
+
when: "after-restoring-valid-json",
|
|
2347
|
+
},
|
|
2348
|
+
],
|
|
2349
|
+
recovery:
|
|
2350
|
+
"Preserve the damaged file, then restore known-good valid JSON at entryPath. After restoring it, execute the validation argv. Validation does not repair or rewrite invalid JSON.",
|
|
2351
|
+
};
|
|
2352
|
+
}
|
|
2353
|
+
if (error?.code === "missing_component_id" && typeof args[1] === "string") {
|
|
2354
|
+
const remote = /^https?:\/\//.test(args[1]);
|
|
2355
|
+
return {
|
|
2356
|
+
...details,
|
|
2357
|
+
nextOperations: [
|
|
2358
|
+
...(!remote
|
|
2359
|
+
? [{
|
|
2360
|
+
argv: ["list", resolve(args[1]), "--kind", "components", "--json"],
|
|
2361
|
+
operation: "smallpen.list",
|
|
2362
|
+
}]
|
|
2363
|
+
: []),
|
|
2364
|
+
{ argv: ["component", "--help"], operation: "smallpen.help" },
|
|
2365
|
+
],
|
|
2366
|
+
recovery: remote
|
|
2367
|
+
? "Supply --component-id from a linked Product's search-components result and retain that result's owner locator. component --help describes the detail command; list does not accept URL locators."
|
|
2368
|
+
: "List this owner Package's Component Sets, then retry component with the selected --component-id and the same Package path.",
|
|
2369
|
+
};
|
|
2370
|
+
}
|
|
2371
|
+
if (
|
|
2372
|
+
args[0] === "init" &&
|
|
2373
|
+
["invalid_initialization_answer_json", "invalid_initialization_answer"].includes(error?.code) &&
|
|
2374
|
+
typeof details.questionId === "string"
|
|
2375
|
+
) {
|
|
2376
|
+
const retry = [...args];
|
|
2377
|
+
retry[1] = resolve(retry[1]);
|
|
2378
|
+
const stateIndex = retry.indexOf("--state");
|
|
2379
|
+
if (stateIndex !== -1) retry[stateIndex + 1] = resolve(retry[stateIndex + 1]);
|
|
2380
|
+
let replaced = false;
|
|
2381
|
+
for (let index = 2; index < retry.length; index += 1) {
|
|
2382
|
+
if (retry[index] === "--answer" && retry[index + 1]?.startsWith(`${details.questionId}=`)) {
|
|
2383
|
+
retry[index + 1] = `${details.questionId}=<JSON>`;
|
|
2384
|
+
replaced = true;
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
if (!replaced) retry.push("--answer", `${details.questionId}=<JSON>`);
|
|
2388
|
+
if (!retry.includes("--json")) retry.push("--json");
|
|
2389
|
+
if (!retry.includes("--locale")) retry.push("--locale", "zh-TW");
|
|
2390
|
+
return {
|
|
2391
|
+
...details,
|
|
2392
|
+
nextOperations: [{ argv: retry, operation: "smallpen.init.answer" }],
|
|
2393
|
+
recovery: "Replace <JSON> with a valid JSON answer and execute the retry argv.",
|
|
2394
|
+
};
|
|
2395
|
+
}
|
|
2396
|
+
if (error?.code === "stale_revision") {
|
|
2397
|
+
const locator = typeof args[1] === "string" ? resolve(args[1]) : "<package>";
|
|
2398
|
+
const commands = [
|
|
2399
|
+
{
|
|
2400
|
+
argv: ["read", locator, "--json"],
|
|
2401
|
+
explanation: "Read the current revision and confirm no conflicting update is discarded.",
|
|
2402
|
+
},
|
|
2403
|
+
{
|
|
2404
|
+
argv: [
|
|
2405
|
+
"apply",
|
|
2406
|
+
locator,
|
|
2407
|
+
"--batch",
|
|
2408
|
+
"<rebuilt-intent.json>",
|
|
2409
|
+
"--json",
|
|
2410
|
+
],
|
|
2411
|
+
explanation:
|
|
2412
|
+
"In the batch JSON set baseRevision to the actual revision and batchId to a new unique id, keep the remaining operations, then run this apply.",
|
|
2413
|
+
},
|
|
2414
|
+
];
|
|
2415
|
+
const remainingIntent = Array.isArray(details.nextOperations)
|
|
2416
|
+
? details.nextOperations.find(
|
|
2417
|
+
(operation) => operation.operation === "smallpen.apply.replay-intent",
|
|
2418
|
+
)?.args?.operations
|
|
2419
|
+
: undefined;
|
|
2420
|
+
return {
|
|
2421
|
+
...details,
|
|
2422
|
+
...(remainingIntent ? { remainingIntent } : {}),
|
|
2423
|
+
commands,
|
|
2424
|
+
nextOperations: Array.isArray(details.nextOperations)
|
|
2425
|
+
? details.nextOperations
|
|
2426
|
+
: [
|
|
2427
|
+
{
|
|
2428
|
+
args: { expectedRevision: details.actualRevision },
|
|
2429
|
+
operation: "smallpen.refresh",
|
|
2430
|
+
},
|
|
2431
|
+
{
|
|
2432
|
+
args: {
|
|
2433
|
+
baseRevision: details.actualRevision,
|
|
2434
|
+
newBatchId: "<unique-id>",
|
|
2435
|
+
},
|
|
2436
|
+
operation: "smallpen.apply.replay-intent",
|
|
2437
|
+
},
|
|
2438
|
+
],
|
|
2439
|
+
recovery:
|
|
2440
|
+
"The write was rejected atomically; nothing was applied. Read the actual revision, then rebuild the batch JSON with baseRevision set to that revision and a new unique batchId inside the JSON (apply takes no --batch-id option), and rerun apply --batch. Concurrent updates that are still needed must be re-applied on top.",
|
|
2441
|
+
};
|
|
2442
|
+
}
|
|
2443
|
+
if (error?.code === "missing_local_asset" && isRecord(details.reference)) {
|
|
2444
|
+
const locator = typeof args[1] === "string" ? resolve(args[1]) : "<package>";
|
|
2445
|
+
const assetId = details.reference.assetId;
|
|
2446
|
+
const isToken = typeof assetId === "string" && assetId.startsWith("tok_");
|
|
2447
|
+
const commands = [
|
|
2448
|
+
{
|
|
2449
|
+
argv: ["impact", locator, "--token-id", assetId, "--json"],
|
|
2450
|
+
explanation: "List every location that still binds this token.",
|
|
2451
|
+
},
|
|
2452
|
+
{
|
|
2453
|
+
argv: [
|
|
2454
|
+
"apply",
|
|
2455
|
+
locator,
|
|
2456
|
+
"--batch",
|
|
2457
|
+
"<batch.json with one clear-token-binding operation per impacted location>",
|
|
2458
|
+
"--json",
|
|
2459
|
+
],
|
|
2460
|
+
explanation:
|
|
2461
|
+
"Build the clear-token-binding batch against the current revision with a new unique batchId in the JSON, run this apply, then retry the token removal as another apply --batch with its own new batchId.",
|
|
2462
|
+
},
|
|
2463
|
+
];
|
|
2464
|
+
return {
|
|
2465
|
+
...details,
|
|
2466
|
+
commands,
|
|
2467
|
+
nextOperations: [
|
|
2468
|
+
{
|
|
2469
|
+
argv: commands[0].argv,
|
|
2470
|
+
operation: "smallpen.impact",
|
|
2471
|
+
},
|
|
2472
|
+
{
|
|
2473
|
+
argv: commands[1].argv,
|
|
2474
|
+
operation: "smallpen.apply",
|
|
2475
|
+
when: "after-clearing-live-references",
|
|
2476
|
+
},
|
|
2477
|
+
],
|
|
2478
|
+
recovery: isToken
|
|
2479
|
+
? "The removal was rejected atomically because the token is still referenced. List the live bindings, then clear each binding with a clear-token-binding batch JSON (current baseRevision, new unique batchId in the JSON) via apply --batch, and retry the removal the same way."
|
|
2480
|
+
: "The removal was rejected atomically because the asset is still referenced. Remove or retarget the referencing usage with an apply --batch whose JSON carries the current baseRevision and a new unique batchId, then retry.",
|
|
2481
|
+
};
|
|
2482
|
+
}
|
|
2483
|
+
if (error?.code === "unknown_command") {
|
|
2484
|
+
return {
|
|
2485
|
+
...details,
|
|
2486
|
+
nextOperations: [
|
|
2487
|
+
{
|
|
2488
|
+
args: [],
|
|
2489
|
+
argv: ["--help"],
|
|
2490
|
+
command: "smallpen --help",
|
|
2491
|
+
operation: "smallpen.help",
|
|
2492
|
+
},
|
|
2493
|
+
],
|
|
2494
|
+
};
|
|
2495
|
+
}
|
|
2496
|
+
return details;
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
const cliArgs = process.argv.slice(2);
|
|
2500
|
+
main(cliArgs).catch((error) => {
|
|
2501
|
+
printJson({
|
|
2502
|
+
error: {
|
|
2503
|
+
code: error instanceof SmallPenError ? error.code : "internal_error",
|
|
2504
|
+
details: errorDetails(error, cliArgs),
|
|
2505
|
+
message: error instanceof Error ? error.message : String(error),
|
|
2506
|
+
},
|
|
2507
|
+
});
|
|
2508
|
+
process.exitCode = 1;
|
|
2509
|
+
});
|