@smallpen/cli 0.1.0-alpha.3 → 0.1.0-alpha.5

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/smallpen.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { randomUUID } from "node:crypto";
3
+ import { createHash, randomUUID } from "node:crypto";
4
4
  import {
5
5
  mkdir,
6
6
  readdir,
@@ -20,7 +20,7 @@ import {
20
20
  createCompareView,
21
21
  createInitializationState,
22
22
  diffDrafts,
23
- designTokenWarningsForBatch,
23
+ designTokenAdviceForBatch,
24
24
  draftFromSnapshot,
25
25
  explainEffectiveToken,
26
26
  importTokens,
@@ -36,6 +36,7 @@ import {
36
36
  applyOperationBatch,
37
37
  createEvidence,
38
38
  defaultLibraryCacheRoot,
39
+ fontVariantName,
39
40
  importDraft,
40
41
  importFontVariant,
41
42
  importMedia,
@@ -52,11 +53,13 @@ import {
52
53
  } from "@smallpen/local-package";
53
54
 
54
55
  import { COMMAND_NAMES, printHelp } from "./help.mjs";
56
+ import { schemaTopic } from "./schema.mjs";
55
57
  import { warningOutput } from "./warning-output.mjs";
56
58
 
57
59
  const FLAG_OPTIONS = new Set([
58
60
  "--apply",
59
61
  "--base64",
62
+ "--compact",
60
63
  "--confirm",
61
64
  "--confirm-unmatched",
62
65
  "--diff",
@@ -67,9 +70,22 @@ const FLAG_OPTIONS = new Set([
67
70
  "--json",
68
71
  "-h",
69
72
  ]);
73
+ // Value options read with options(); every other value option is single.
74
+ const REPEATABLE_OPTIONS = new Set(["--answer", "--context", "--select", "--selector"]);
70
75
  const COMMAND_OPTIONS = {
71
76
  version: ["--json"],
72
- apply: ["--batch", "--confirm-unmatched", "--diff", "--dry-run", "--explain", "--json", "--warning-detail"],
77
+ schema: ["--json"],
78
+ apply: [
79
+ "--batch",
80
+ "--compact",
81
+ "--confirm-unmatched",
82
+ "--diff",
83
+ "--dry-run",
84
+ "--explain",
85
+ "--inverse-out",
86
+ "--json",
87
+ "--warning-detail",
88
+ ],
73
89
  catalog: ["--context", "--json"],
74
90
  component: ["--component-id", "--json"],
75
91
  compare: ["--json", "--selector"],
@@ -114,17 +130,52 @@ const COMMAND_OPTIONS = {
114
130
  ],
115
131
  "render-matrix": ["--contexts", "--json", "--output", "--scale"],
116
132
  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"],
133
+ flow: [
134
+ "--batch-id",
135
+ "--compact",
136
+ "--confirm-unmatched",
137
+ "--diff",
138
+ "--dry-run",
139
+ "--explain",
140
+ "--intent",
141
+ "--inverse-out",
142
+ "--json",
143
+ "--warning-detail",
144
+ ],
145
+ page: [
146
+ "--batch-id",
147
+ "--compact",
148
+ "--confirm-unmatched",
149
+ "--diff",
150
+ "--dry-run",
151
+ "--explain",
152
+ "--intent",
153
+ "--inverse-out",
154
+ "--json",
155
+ "--warning-detail",
156
+ ],
157
+ token: [
158
+ "--batch-id",
159
+ "--compact",
160
+ "--confirm-unmatched",
161
+ "--diff",
162
+ "--dry-run",
163
+ "--explain",
164
+ "--intent",
165
+ "--inverse-out",
166
+ "--json",
167
+ "--warning-detail",
168
+ ],
120
169
  "explain-token": ["--context", "--json", "--package-id", "--token-id"],
121
170
  init: ["--answer", "--answers", "--confirm", "--json", "--locale", "--state"],
122
171
  "import-draft": ["--input", "--json", "--kind", "--package-id"],
123
172
  "import-tokens": [
124
173
  "--apply",
125
174
  "--batch-id",
175
+ "--compact",
126
176
  "--dry-run",
127
177
  "--input",
178
+ "--inverse-out",
128
179
  "--json",
129
180
  "--select",
130
181
  "--set-name",
@@ -135,14 +186,13 @@ const COMMAND_OPTIONS = {
135
186
  "--file",
136
187
  "--font-id",
137
188
  "--json",
138
- "--media-path",
139
189
  "--name",
140
190
  "--style",
141
191
  "--variant-id",
142
192
  "--weight",
143
193
  ],
144
194
  "import-media": ["--file", "--json", "--media-id", "--media-path", "--name"],
145
- "library-refresh": ["--interval", "--json", "--library", "--max-events"],
195
+ "library-refresh": ["--json", "--library"],
146
196
  watch: ["--interval", "--json", "--max-events"],
147
197
  list: ["--json", "--kind", "--limit", "--offset"],
148
198
  read: ["--json"],
@@ -191,14 +241,51 @@ const COMMAND_OPTIONS = {
191
241
  "--type",
192
242
  "--value",
193
243
  ],
194
- tokens: ["--context", "--json"],
244
+ tokens: ["--context", "--json", "--locale"],
195
245
  validate: ["--json"],
196
246
  };
197
247
 
248
+ // Localized labels follow the environment unless --locale is given: any
249
+ // zh* locale selects the Chinese labels, everything else English.
250
+ function defaultLocale() {
251
+ const source =
252
+ process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || "";
253
+ return /^zh/i.test(source) ? "zh-TW" : "en";
254
+ }
255
+
256
+ function tokenLabels(locale) {
257
+ return /^zh/i.test(locale)
258
+ ? { context: "設計狀態", items: "有效設計變數" }
259
+ : { context: "Design Context", items: "Effective Tokens" };
260
+ }
261
+
198
262
  function isRecord(value) {
199
263
  return value !== null && typeof value === "object" && !Array.isArray(value);
200
264
  }
201
265
 
266
+ // Locale-independent order for JSON output: localeCompare follows the host
267
+ // LANG (Danish sorts "aa" after "ab"), which would reorder lists and pages.
268
+ function compareText(left, right) {
269
+ return left < right ? -1 : left > right ? 1 : 0;
270
+ }
271
+
272
+ // Caller-supplied input files fail with a typed error instead of a raw
273
+ // filesystem internal_error.
274
+ async function readInputFile(path, optionName, encoding) {
275
+ try {
276
+ return await readFile(path, encoding);
277
+ } catch (error) {
278
+ if (!["EACCES", "EISDIR", "ENOENT", "ENOTDIR", "EPERM"].includes(error?.code)) {
279
+ throw error;
280
+ }
281
+ throw new SmallPenError(
282
+ "unreadable_input_file",
283
+ `${optionName} file cannot be read: ${error.message}`,
284
+ { option: optionName, path: resolve(path), reason: error.code },
285
+ );
286
+ }
287
+ }
288
+
202
289
  function option(args, name) {
203
290
  const index = args.indexOf(name);
204
291
  if (index === -1) return undefined;
@@ -240,6 +327,7 @@ function validateCommandArguments(command, args) {
240
327
  "--help",
241
328
  "-h",
242
329
  ]);
330
+ const seen = new Set();
243
331
  for (let index = 2; index < args.length; index += 1) {
244
332
  const value = args[index];
245
333
  if (!value.startsWith("-")) {
@@ -256,7 +344,18 @@ function validateCommandArguments(command, args) {
256
344
  validOptions: [...allowed].sort(),
257
345
  });
258
346
  }
259
- if (!FLAG_OPTIONS.has(value)) index += 1;
347
+ if (FLAG_OPTIONS.has(value)) continue;
348
+ // option() reads only the first occurrence; a second value would be
349
+ // silently ignored.
350
+ if (seen.has(value) && !REPEATABLE_OPTIONS.has(value)) {
351
+ throw new SmallPenError(
352
+ "duplicate_option",
353
+ `${value} may be given only once`,
354
+ { command, option: value },
355
+ );
356
+ }
357
+ seen.add(value);
358
+ index += 1;
260
359
  }
261
360
  }
262
361
 
@@ -343,7 +442,7 @@ function printJson(value) {
343
442
  child instanceof Map
344
443
  ? Object.fromEntries(
345
444
  [...child.entries()].sort(([left], [right]) =>
346
- String(left).localeCompare(String(right)),
445
+ compareText(String(left), String(right)),
347
446
  ),
348
447
  )
349
448
  : child,
@@ -381,7 +480,7 @@ function inspect(snapshot) {
381
480
  screenId,
382
481
  }),
383
482
  ),
384
- ].sort((left, right) => left.id.localeCompare(right.id)),
483
+ ].sort((left, right) => compareText(left.id, right.id)),
385
484
  contexts: {
386
485
  axes: [...snapshot.domain.contextAxes.values()],
387
486
  profiles: [...snapshot.domain.contextProfiles.values()],
@@ -613,7 +712,42 @@ function explainBatch(before, prepared, batch) {
613
712
  return { diff, explain };
614
713
  }
615
714
 
715
+ // The full reply carries the inverse batch twice (inverseBatch and
716
+ // guidance.undo), which is most of its size. inverseBatch stays in the
717
+ // default reply because undo relies on it; --inverse-out saves it to a file
718
+ // and --compact drops it from the reply in favour of a short summary.
719
+ async function writeReply(result, args) {
720
+ const inverseOut = option(args, "--inverse-out");
721
+ const inverseBatchPath =
722
+ inverseOut !== undefined && result.inverseBatch && !result.dryRun
723
+ ? await writeAtomic(inverseOut, `${JSON.stringify(result.inverseBatch, null, 2)}\n`)
724
+ : undefined;
725
+ if (!flag(args, "--compact")) {
726
+ return inverseBatchPath ? { ...result, inverseBatchPath } : result;
727
+ }
728
+ const { guidance: _guidance, inverseBatch, ...rest } = result;
729
+ const files = (result.changedFiles?.length ?? 0) + (result.deletedFiles?.length ?? 0);
730
+ const warnings = result.warningSummary?.total ?? result.warnings?.length ?? 0;
731
+ const verb = result.dryRun ? "Previewed" : result.alreadyApplied ? "Already applied" : "Applied";
732
+ return {
733
+ ...rest,
734
+ ...(inverseBatch ? { inverseOperationCount: inverseBatch.operations.length } : {}),
735
+ ...(inverseBatchPath ? { inverseBatchPath } : {}),
736
+ summary:
737
+ `${verb} ${result.batchId}: revision ${result.revision}, ` +
738
+ `${files} changed file(s), ${result.affectedIds?.length ?? 0} affected id(s), ` +
739
+ `${warnings} warning(s).` +
740
+ (inverseBatchPath || !inverseBatch || result.dryRun
741
+ ? ""
742
+ : " The inverse batch was not kept: rerun writes with --inverse-out FILE to save it for undo."),
743
+ };
744
+ }
745
+
616
746
  async function applyBatch(packagePath, batch, args) {
747
+ return writeReply(await prepareAndApplyBatch(packagePath, batch, args), args);
748
+ }
749
+
750
+ async function prepareAndApplyBatch(packagePath, batch, args) {
617
751
  const detail = option(args, "--warning-detail") ?? "compact";
618
752
  if (!["compact", "full"].includes(detail)) {
619
753
  throw new SmallPenError("invalid_warning_detail", "--warning-detail must be compact or full", {
@@ -626,11 +760,27 @@ async function applyBatch(packagePath, batch, args) {
626
760
  "CLI Operation Batch JSON cannot contain binary blob writes",
627
761
  );
628
762
  }
763
+ // High-level commands rebuild their batch from the current revision, so a
764
+ // retry with the same --batch-id after success carries the recorded result
765
+ // as its base and may no longer prepare (its nodes already exist). Consult
766
+ // the ledger first; previews never write and skip it.
767
+ if (
768
+ args[0] !== "apply" &&
769
+ option(args, "--batch-id") !== undefined &&
770
+ !flag(args, "--dry-run") &&
771
+ !flag(args, "--explain") &&
772
+ !flag(args, "--diff")
773
+ ) {
774
+ const replayed = await replayRecordedBatch(packagePath, batch, { rebuilt: true });
775
+ if (replayed) return replayed;
776
+ }
629
777
  // 通用 apply 仍然能修复处于 Repair 的 Product;Token 建议不能变成写入门禁。
630
- const { foundation, product } = await tokenAdviceWorkspace(packagePath);
778
+ const { foundation, libraries, product } = await tokenAdviceWorkspace(packagePath);
631
779
  let prepared;
632
780
  try {
633
- prepared = await prepareOperationBatch(product, batch);
781
+ // Foundation and Library components let preparation check Instance
782
+ // override paths that target them.
783
+ prepared = await prepareOperationBatch(product, batch, { foundation, libraries });
634
784
  } catch (error) {
635
785
  if (error?.code === "stale_revision") {
636
786
  const replayed = await replayRecordedBatch(packagePath, batch);
@@ -638,13 +788,17 @@ async function applyBatch(packagePath, batch, args) {
638
788
  }
639
789
  throw error;
640
790
  }
641
- const warnings = designTokenWarningsForBatch(prepared.snapshot, batch, {
791
+ const { suppressed, warnings } = designTokenAdviceForBatch(prepared.snapshot, batch, {
642
792
  foundation,
643
793
  });
644
794
  // Import warnings have their own schema and are not design-style advice.
645
795
  const advice = args[0] === "import-tokens"
646
796
  ? { warnings }
647
- : warningOutput(warnings, detail, { confirmUnmatched: flag(args, "--confirm-unmatched") });
797
+ : warningOutput(warnings, detail, {
798
+ confirmUnmatched: flag(args, "--confirm-unmatched"),
799
+ suppressed,
800
+ });
801
+ const change = changeSummary(product, prepared, batch);
648
802
  if (flag(args, "--explain") || flag(args, "--diff")) {
649
803
  // explain/diff are read-only previews in any combination; neither flag
650
804
  // writes, with or without --dry-run (CLI-AI-TOPIC: "explain and diff
@@ -652,19 +806,37 @@ async function applyBatch(packagePath, batch, args) {
652
806
  return {
653
807
  ...prepared.result,
654
808
  ...explainBatch(product, prepared, batch),
809
+ ...change,
655
810
  dryRun: true,
656
811
  ...advice,
657
812
  };
658
813
  }
659
814
  if (flag(args, "--dry-run")) {
660
- return { ...prepared.result, dryRun: true, ...advice };
815
+ return { ...prepared.result, ...change, dryRun: true, ...advice };
661
816
  }
662
817
  return {
663
818
  ...(await applyOperationBatch(packagePath, batch)),
819
+ ...change,
664
820
  ...advice,
665
821
  };
666
822
  }
667
823
 
824
+ // A valid batch can still leave every byte as it was (for example a value
825
+ // that is already set). Say so instead of reporting a silent success.
826
+ function changeSummary(before, prepared, batch) {
827
+ if (prepared.snapshot.revision !== before.revision) return { changed: true };
828
+ return {
829
+ changed: false,
830
+ noChange: {
831
+ code: "no_change",
832
+ message:
833
+ `The batch is valid but changes nothing: the ${batch.operations.length} ` +
834
+ "operation(s) leave the Package at its current revision. Check the " +
835
+ "target ids and values (smallpen read-view PACKAGE --format semantic --json).",
836
+ },
837
+ };
838
+ }
839
+
668
840
  function searchValue(args) {
669
841
  const color = option(args, "--color");
670
842
  const source = option(args, "--value");
@@ -799,17 +971,24 @@ async function initializeCommand(workspacePath, args) {
799
971
  const answersPath = option(args, "--answers");
800
972
  if (answersPath) {
801
973
  fileAnswers = parseJson(
802
- await readFile(answersPath, "utf8"),
974
+ await readInputFile(answersPath, "--answers", "utf8"),
803
975
  "invalid_initialization_answers_json",
804
976
  { path: answersPath },
805
977
  );
978
+ if (!isRecord(fileAnswers)) {
979
+ throw new SmallPenError(
980
+ "invalid_initialization_answers_json",
981
+ "--answers must contain a JSON object keyed by question id",
982
+ { path: answersPath },
983
+ );
984
+ }
806
985
  }
807
986
  const answers = {
808
987
  ...(persisted.answers ?? {}),
809
988
  ...fileAnswers,
810
989
  ...answerArguments(args),
811
990
  };
812
- const locale = option(args, "--locale") ?? "zh-TW";
991
+ const locale = option(args, "--locale") ?? defaultLocale();
813
992
  let state = createInitializationState(answers, {
814
993
  locale,
815
994
  statePath,
@@ -832,7 +1011,22 @@ async function initializeCommand(workspacePath, args) {
832
1011
  if (state.status === "needs_input") {
833
1012
  state.nextQuestion.continuation.args[1] = resolve(workspacePath);
834
1013
  state.nextQuestion.continuation.args.push("--locale", locale);
835
- return { ...state, statePath };
1014
+ return {
1015
+ ...state,
1016
+ answersTemplate:
1017
+ "smallpen schema init prints every question and a complete answers file for --answers",
1018
+ // Without --state a call never reads earlier answers, so a chain of
1019
+ // single --answer calls would keep only the last one.
1020
+ ...(explicitStatePath
1021
+ ? {}
1022
+ : {
1023
+ stateNotice:
1024
+ "This call did not read earlier answers because --state was not given. " +
1025
+ "Pass --state with the statePath below on every call (as continuation.args does) " +
1026
+ "to collect answers one at a time, or answer everything at once with --answers FILE.",
1027
+ }),
1028
+ statePath,
1029
+ };
836
1030
  }
837
1031
  const confirmation = {
838
1032
  args: [
@@ -862,7 +1056,160 @@ async function initializeCommand(workspacePath, args) {
862
1056
  status: "initialized",
863
1057
  }),
864
1058
  );
865
- return { ...initialized, statePath };
1059
+ return { ...initialized, ...(await initializedSummary(initialized)), statePath };
1060
+ }
1061
+
1062
+ // The init reply names every id the first writes need (Packages, Screen,
1063
+ // root node, starter Tokens and Component Sets), so an Agent can start
1064
+ // without reading the new files.
1065
+ async function initializedSummary({ foundationPath, productPath }) {
1066
+ const foundation = await openPackage(foundationPath);
1067
+ const product = await openPackage(productPath);
1068
+ const screenEntry = product.manifest.entries.screens.find(
1069
+ (entry) => product.entries[entry].id === product.manifest.defaultScreenId,
1070
+ );
1071
+ const screen = product.entries[screenEntry];
1072
+ const presentation = screen.presentations.find(
1073
+ ({ id }) => id === screen.basePresentationId,
1074
+ );
1075
+ const root = presentation.nodes[presentation.rootId];
1076
+ const packageSummary = (snapshot) => ({
1077
+ packageId: snapshot.manifest.packageId,
1078
+ path: snapshot.locator,
1079
+ revision: snapshot.revision,
1080
+ });
1081
+ return {
1082
+ packages: {
1083
+ foundation: packageSummary(foundation),
1084
+ product: packageSummary(product),
1085
+ },
1086
+ start: {
1087
+ contextAxes: [...foundation.domain.contextAxes.values()].map(
1088
+ ({ defaultValue, id, values }) => ({
1089
+ defaultValue,
1090
+ id,
1091
+ values: values.map((value) => value.id),
1092
+ }),
1093
+ ),
1094
+ presentationId: presentation.id,
1095
+ rootNode: { height: root.height, id: root.id, width: root.width },
1096
+ scenarioIds: [...product.domain.scenarios.keys()].sort(compareText),
1097
+ screenId: screen.id,
1098
+ },
1099
+ seeded: {
1100
+ componentSets: [...foundation.domain.componentSets.values()].map(
1101
+ ({ axes, id, name, variants }) => ({
1102
+ axes: axes.map((axis) => axis.id),
1103
+ componentSetId: id,
1104
+ name,
1105
+ packageId: foundation.manifest.packageId,
1106
+ variants: variants.map((variant) => ({
1107
+ rootId: variant.rootId,
1108
+ selection: variant.selection,
1109
+ variantId: variant.id,
1110
+ })),
1111
+ }),
1112
+ ),
1113
+ note:
1114
+ "Starter content. put-token or put-component-set with one of these ids replaces it " +
1115
+ "whole (no merge). Write Tokens and Component Sets to packages.foundation.path; " +
1116
+ "write Screens and nodes to packages.product.path.",
1117
+ tokens: [...foundation.domain.tokens.values()].map(
1118
+ ({ filePath, id, path, rawValue, type }) => ({
1119
+ filePath,
1120
+ packageId: foundation.manifest.packageId,
1121
+ path,
1122
+ tokenId: id,
1123
+ type,
1124
+ value: rawValue,
1125
+ }),
1126
+ ),
1127
+ },
1128
+ };
1129
+ }
1130
+
1131
+ // The renderer reports one font_render_fallback per text node. Collapse them
1132
+ // to one diagnostic per missing family that names the fonts it can draw and
1133
+ // the import-font command that adds the missing one.
1134
+ async function renderEvidence(product, options) {
1135
+ const bundle = await createEvidence(product, options);
1136
+ const fallbacks = new Map();
1137
+ const diagnostics = [];
1138
+ for (const diagnostic of bundle.render.diagnostics) {
1139
+ if (diagnostic.code !== "font_render_fallback") {
1140
+ diagnostics.push(diagnostic);
1141
+ continue;
1142
+ }
1143
+ const requestedFont =
1144
+ diagnostic.details?.requestedFont ??
1145
+ /substituted .+ for (.+)$/.exec(diagnostic.message)?.[1] ??
1146
+ "<family>";
1147
+ let collapsed = fallbacks.get(requestedFont);
1148
+ if (!collapsed) {
1149
+ collapsed = { diagnostic, nodeIds: [], paths: [], requestedFont };
1150
+ fallbacks.set(requestedFont, collapsed);
1151
+ diagnostics.push(collapsed);
1152
+ }
1153
+ if (diagnostic.nodeId !== undefined) collapsed.nodeIds.push(diagnostic.nodeId);
1154
+ if (diagnostic.path !== undefined) collapsed.paths.push(diagnostic.path);
1155
+ }
1156
+ if (fallbacks.size === 0) return bundle;
1157
+ const imported = [
1158
+ ...new Set(
1159
+ [options.foundation, ...(options.libraries ?? []), product]
1160
+ .filter(Boolean)
1161
+ .flatMap((snapshot) =>
1162
+ snapshot.manifest.entries.assets.flatMap((entry) =>
1163
+ (snapshot.entries[entry].fonts ?? []).map(({ family }) => family),
1164
+ ),
1165
+ ),
1166
+ ),
1167
+ ].sort(compareText);
1168
+ const availableFonts = [
1169
+ "Source Sans Pro (bundled)",
1170
+ ...imported.map((family) => `${family} (imported)`),
1171
+ ];
1172
+ const collapsedEntries = new Set(fallbacks.values());
1173
+ const explained = diagnostics.map((entry) => {
1174
+ if (!collapsedEntries.has(entry)) return entry;
1175
+ const { requestedFont } = entry;
1176
+ return {
1177
+ availableFonts,
1178
+ code: entry.diagnostic.code,
1179
+ details: {
1180
+ ...entry.diagnostic.details,
1181
+ requestedFont,
1182
+ substituteFont: entry.diagnostic.details?.substituteFont ?? "Source Sans Pro",
1183
+ },
1184
+ message:
1185
+ `${entry.diagnostic.message} on ${entry.nodeIds.length} text node(s): ` +
1186
+ `${requestedFont} is neither bundled nor imported. Available fonts: ` +
1187
+ `${availableFonts.join(", ")}. Import ${requestedFont} with smallpen import-font ` +
1188
+ "(once per weight/style; see nextOperations), or use an available family.",
1189
+ nextOperations: [
1190
+ {
1191
+ argv: [
1192
+ "import-font",
1193
+ product.locator,
1194
+ "--file",
1195
+ `<${requestedFont} .ttf|.otf|.woff file>`,
1196
+ "--family",
1197
+ requestedFont,
1198
+ "--weight",
1199
+ "<400|700|...>",
1200
+ "--json",
1201
+ ],
1202
+ operation: "smallpen.import-font",
1203
+ },
1204
+ ],
1205
+ nodeIds: entry.nodeIds,
1206
+ paths: entry.paths,
1207
+ requestedFont,
1208
+ };
1209
+ });
1210
+ bundle.render.diagnostics = explained;
1211
+ bundle.evidence.diagnostics = explained;
1212
+ return bundle;
866
1213
  }
867
1214
 
868
1215
  async function deliverImage(
@@ -900,7 +1247,7 @@ async function renderCommand(packagePath, args, evidenceOnly) {
900
1247
  }
901
1248
  const workspace = await tokenWorkspace(packagePath);
902
1249
  const { foundation, libraries, product } = workspace;
903
- const bundle = await createEvidence(product, {
1250
+ const bundle = await renderEvidence(product, {
904
1251
  foundation,
905
1252
  libraries,
906
1253
  scale: numberOption(args, "--scale", 1),
@@ -950,13 +1297,13 @@ async function inspectViewCommand(packagePath, args) {
950
1297
  const semantic = readDesignView(product, {
951
1298
  foundation,
952
1299
  libraries,
953
- locale: option(args, "--locale") ?? "zh-TW",
1300
+ locale: option(args, "--locale") ?? defaultLocale(),
954
1301
  selector: { ...selector, viewFormat: "semantic" },
955
1302
  });
956
1303
  const wireframe = readDesignView(product, {
957
1304
  foundation,
958
1305
  libraries,
959
- locale: option(args, "--locale") ?? "zh-TW",
1306
+ locale: option(args, "--locale") ?? defaultLocale(),
960
1307
  selector: { ...selector, viewFormat: "wireframe" },
961
1308
  });
962
1309
  const context = semantic.selection.context;
@@ -971,7 +1318,7 @@ async function inspectViewCommand(packagePath, args) {
971
1318
  wireframe: wireframe.result,
972
1319
  };
973
1320
  if (flag(args, "--include-image")) {
974
- const bundle = await createEvidence(product, {
1321
+ const bundle = await renderEvidence(product, {
975
1322
  foundation,
976
1323
  libraries,
977
1324
  scale: numberOption(args, "--scale", 1),
@@ -996,7 +1343,7 @@ async function renderMatrixCommand(packagePath, args) {
996
1343
  );
997
1344
  }
998
1345
  const contexts = parseJson(
999
- await readFile(contextsPath, "utf8"),
1346
+ await readInputFile(contextsPath, "--contexts", "utf8"),
1000
1347
  "invalid_render_contexts_json",
1001
1348
  { path: contextsPath },
1002
1349
  );
@@ -1025,7 +1372,7 @@ async function renderMatrixCommand(packagePath, args) {
1025
1372
  },
1026
1373
  );
1027
1374
  }
1028
- const bundle = await createEvidence(product, {
1375
+ const bundle = await renderEvidence(product, {
1029
1376
  foundation,
1030
1377
  libraries,
1031
1378
  scale,
@@ -1096,7 +1443,7 @@ function tokenImpact(snapshot, tokenId, packageId) {
1096
1443
  }
1097
1444
  }
1098
1445
  return locations.sort((left, right) =>
1099
- JSON.stringify(left).localeCompare(JSON.stringify(right)),
1446
+ compareText(JSON.stringify(left), JSON.stringify(right)),
1100
1447
  );
1101
1448
  }
1102
1449
 
@@ -1130,7 +1477,7 @@ function componentDetails(snapshot, componentId) {
1130
1477
  scenario.target.component.assetId === componentId,
1131
1478
  )
1132
1479
  .map(({ id, name, target }) => ({ id, name, target }))
1133
- .sort((left, right) => left.id.localeCompare(right.id)),
1480
+ .sort((left, right) => compareText(left.id, right.id)),
1134
1481
  };
1135
1482
  }
1136
1483
 
@@ -1240,7 +1587,7 @@ async function repairOperations(resolution, conflict, action, args) {
1240
1587
  );
1241
1588
  }
1242
1589
  const asset = parseJson(
1243
- await readFile(assetPath, "utf8"),
1590
+ await readInputFile(assetPath, "--asset", "utf8"),
1244
1591
  "invalid_recreated_asset_json",
1245
1592
  { path: assetPath },
1246
1593
  );
@@ -1315,7 +1662,11 @@ function insertionAdvice(owner, product, component) {
1315
1662
  const instanceNode = {
1316
1663
  children: [],
1317
1664
  height: dimensions.height,
1318
- id: `node_instance_${randomUUID().slice(0, 8)}`,
1665
+ // Derived from the revision so a repeated read returns the same advice.
1666
+ id: `node_instance_${createHash("sha256")
1667
+ .update(`${product.revision}\0${component.packageId}\0${component.id}`)
1668
+ .digest("hex")
1669
+ .slice(0, 8)}`,
1319
1670
  instance: {
1320
1671
  component: { assetId: component.id, packageId: component.packageId },
1321
1672
  variant,
@@ -1532,6 +1883,25 @@ async function main(args) {
1532
1883
  else process.stdout.write(`${version}\n`);
1533
1884
  return;
1534
1885
  }
1886
+ if (command === "schema") {
1887
+ if (flag(args, "--help") || flag(args, "-h")) {
1888
+ printHelp("schema");
1889
+ return;
1890
+ }
1891
+ const positional = args.slice(1).filter((value) => !value.startsWith("-"));
1892
+ validateCommandArguments("schema", ["schema", "", ...args.slice(1).filter(
1893
+ (value) => value.startsWith("-"),
1894
+ )]);
1895
+ if (positional.length > 2) {
1896
+ throw new SmallPenError(
1897
+ "unexpected_argument",
1898
+ `Unexpected positional argument: ${positional[2]}`,
1899
+ { argument: positional[2], command },
1900
+ );
1901
+ }
1902
+ printJson(schemaTopic(positional[0], positional[1]));
1903
+ return;
1904
+ }
1535
1905
  if (!COMMAND_NAMES.includes(command)) {
1536
1906
  throw new SmallPenError("unknown_command", `Unknown command: ${command}`, {
1537
1907
  validCommands: COMMAND_NAMES,
@@ -1566,7 +1936,7 @@ async function main(args) {
1566
1936
  "import-draft requires --input FILE",
1567
1937
  );
1568
1938
  }
1569
- const input = await readFile(inputPath);
1939
+ const input = await readInputFile(inputPath, "--input");
1570
1940
  printJson(
1571
1941
  await importDraft({
1572
1942
  ...(kind === "figma"
@@ -1588,7 +1958,7 @@ async function main(args) {
1588
1958
  );
1589
1959
  }
1590
1960
  const document = parseJson(
1591
- await readFile(inputPath, "utf8"),
1961
+ await readInputFile(inputPath, "--input", "utf8"),
1592
1962
  "invalid_import_json",
1593
1963
  { path: inputPath },
1594
1964
  );
@@ -1643,7 +2013,7 @@ async function main(args) {
1643
2013
  );
1644
2014
  }
1645
2015
  const selections = parseJson(
1646
- await readFile(selectionsPath, "utf8"),
2016
+ await readInputFile(selectionsPath, "--selections", "utf8"),
1647
2017
  "invalid_draft_selections_json",
1648
2018
  { path: selectionsPath },
1649
2019
  );
@@ -1695,7 +2065,7 @@ async function main(args) {
1695
2065
  const offset = integerOption(args, "--offset", 0);
1696
2066
  const limit = integerOption(args, "--limit", 100, 100);
1697
2067
  const all = listDomain(snapshot, kind).sort((left, right) =>
1698
- String(left.item.id ?? "").localeCompare(String(right.item.id ?? "")),
2068
+ compareText(String(left.item.id ?? ""), String(right.item.id ?? "")),
1699
2069
  );
1700
2070
  printJson({
1701
2071
  items: all.slice(offset, offset + limit),
@@ -1717,7 +2087,7 @@ async function main(args) {
1717
2087
  printJson({
1718
2088
  context,
1719
2089
  items: listEffectiveTokens(product, { context, foundation, libraries }),
1720
- labels: { context: "設計狀態", items: "有效設計變數" },
2090
+ labels: tokenLabels(option(args, "--locale") ?? defaultLocale()),
1721
2091
  packageId: product.manifest.packageId,
1722
2092
  ...workspaceRevisions(product, foundation, libraries),
1723
2093
  });
@@ -1789,14 +2159,14 @@ async function main(args) {
1789
2159
  foundation,
1790
2160
  libraries,
1791
2161
  limit: integerOption(args, "--limit", 20, 100),
1792
- locale: option(args, "--locale") ?? "zh-TW",
2162
+ locale: option(args, "--locale") ?? defaultLocale(),
1793
2163
  offset: integerOption(args, "--offset", 0),
1794
2164
  selector: designSelector(args),
1795
2165
  });
1796
2166
  if (command === "discover") {
1797
2167
  printJson(read.discovery);
1798
2168
  } else if (read.format === "screenshot") {
1799
- const bundle = await createEvidence(product, {
2169
+ const bundle = await renderEvidence(product, {
1800
2170
  foundation,
1801
2171
  libraries,
1802
2172
  scale: 1,
@@ -2013,10 +2383,17 @@ async function main(args) {
2013
2383
  );
2014
2384
  }
2015
2385
  const batch = parseJson(
2016
- await readFile(batchPath, "utf8"),
2386
+ await readInputFile(batchPath, "--batch", "utf8"),
2017
2387
  "invalid_batch_json",
2018
2388
  { path: batchPath },
2019
2389
  );
2390
+ if (!isRecord(batch)) {
2391
+ throw new SmallPenError(
2392
+ "invalid_batch",
2393
+ "Operation Batch must be a JSON object",
2394
+ { path: batchPath },
2395
+ );
2396
+ }
2020
2397
  await checkExternalComponentConsumers(packagePath, batch.operations ?? []);
2021
2398
  printJson(await applyBatch(packagePath, batch, args));
2022
2399
  return;
@@ -2030,7 +2407,7 @@ async function main(args) {
2030
2407
  );
2031
2408
  }
2032
2409
  const intent = parseJson(
2033
- await readFile(intentPath, "utf8"),
2410
+ await readInputFile(intentPath, "--intent", "utf8"),
2034
2411
  "invalid_flow_intent_json",
2035
2412
  { path: intentPath },
2036
2413
  );
@@ -2073,14 +2450,43 @@ async function main(args) {
2073
2450
  const parentId = intent.parentId === undefined
2074
2451
  ? presentation.rootId
2075
2452
  : 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
- }));
2453
+ // overrides on an INSTANCE intent node compile to set-instance-override
2454
+ // operations after the node is added, in the same atomic batch, so batch
2455
+ // preparation checks each override path and value against the component.
2456
+ const overrideOperations = [];
2457
+ const operations = intent.nodes.map((intentNode, index) => {
2458
+ let node = intentNode;
2459
+ if (isRecord(intentNode) && Object.hasOwn(intentNode, "overrides")) {
2460
+ const { overrides, ...rest } = intentNode;
2461
+ if (intentNode.type !== "INSTANCE" || !isRecord(overrides)) {
2462
+ throw new SmallPenError(
2463
+ "invalid_flow_intent",
2464
+ 'overrides is only allowed on INSTANCE intent nodes, as {"<sourceNodeId>:<field>": value}',
2465
+ { nodeId: intentNode.id, nodeIndex: index },
2466
+ );
2467
+ }
2468
+ node = rest;
2469
+ for (const [overridePath, value] of Object.entries(overrides)) {
2470
+ overrideOperations.push({
2471
+ nodeId: intentNode.id,
2472
+ overridePath,
2473
+ presentationId,
2474
+ screenId: intent.screenId,
2475
+ type: "set-instance-override",
2476
+ value,
2477
+ });
2478
+ }
2479
+ }
2480
+ return {
2481
+ index: intent.index === undefined ? undefined : intent.index + index,
2482
+ node,
2483
+ parentId,
2484
+ presentationId,
2485
+ screenId: intent.screenId,
2486
+ type: "add-presentation-node",
2487
+ };
2488
+ });
2489
+ operations.push(...overrideOperations);
2084
2490
  printJson(
2085
2491
  await applyBatch(packagePath, {
2086
2492
  baseRevision: snapshot.revision,
@@ -2099,7 +2505,7 @@ async function main(args) {
2099
2505
  );
2100
2506
  }
2101
2507
  const intent = parseJson(
2102
- await readFile(intentPath, "utf8"),
2508
+ await readInputFile(intentPath, "--intent", "utf8"),
2103
2509
  "invalid_token_intent_json",
2104
2510
  { path: intentPath },
2105
2511
  );
@@ -2120,14 +2526,14 @@ async function main(args) {
2120
2526
  return;
2121
2527
  }
2122
2528
  if (command === "import-media") {
2123
- const mediaPath = option(args, "--file") ?? args[1];
2529
+ const mediaPath = option(args, "--file");
2124
2530
  if (!mediaPath) {
2125
2531
  throw new SmallPenError(
2126
2532
  "missing_media_file",
2127
2533
  "import-media requires --file MEDIA_FILE",
2128
2534
  );
2129
2535
  }
2130
- const bytes = new Uint8Array(await readFile(resolve(mediaPath)));
2536
+ const bytes = new Uint8Array(await readInputFile(mediaPath, "--file"));
2131
2537
  const inspected = inspectMedia(bytes);
2132
2538
  const mediaId = option(args, "--media-id") ?? `media_${randomUUID()}`;
2133
2539
  const name = option(args, "--name") ?? mediaPath.split(/[\\/]/).pop();
@@ -2141,7 +2547,9 @@ async function main(args) {
2141
2547
  width: inspected.width,
2142
2548
  });
2143
2549
  printJson({
2550
+ batchId: print.result.batchId,
2144
2551
  descriptor: print.descriptor,
2552
+ inverseBatch: print.result.inverseBatch,
2145
2553
  revision: print.result.revision,
2146
2554
  });
2147
2555
  return;
@@ -2155,11 +2563,17 @@ async function main(args) {
2155
2563
  );
2156
2564
  }
2157
2565
  const result = await removeMedia(packagePath, mediaId);
2158
- printJson({ mediaId, removed: true, revision: result.revision });
2566
+ printJson({
2567
+ batchId: result.batchId,
2568
+ inverseBatch: result.inverseBatch,
2569
+ mediaId,
2570
+ removed: true,
2571
+ revision: result.revision,
2572
+ });
2159
2573
  return;
2160
2574
  }
2161
2575
  if (command === "import-font") {
2162
- const fontPath = option(args, "--file") ?? args[1];
2576
+ const fontPath = option(args, "--file");
2163
2577
  if (!fontPath) {
2164
2578
  throw new SmallPenError(
2165
2579
  "missing_font_file",
@@ -2173,7 +2587,7 @@ async function main(args) {
2173
2587
  "import-font requires --family NAME",
2174
2588
  );
2175
2589
  }
2176
- const bytes = new Uint8Array(await readFile(resolve(fontPath)));
2590
+ const bytes = new Uint8Array(await readInputFile(fontPath, "--file"));
2177
2591
  const signatureTtf = bytes[0] === 0x00 && bytes[1] === 0x01;
2178
2592
  const signatureOtf = bytes[0] === 0x4f && bytes[1] === 0x54;
2179
2593
  const signatureWoff = bytes[0] === 0x77 && bytes[1] === 0x4f && bytes[2] === 0x46 && bytes[3] === 0x46;
@@ -2216,13 +2630,15 @@ async function main(args) {
2216
2630
  files,
2217
2631
  fontId,
2218
2632
  id: variantId,
2219
- name: option(args, "--name") ?? `${style}-${weight}`,
2633
+ name: option(args, "--name") ?? fontVariantName(weight, style),
2220
2634
  style,
2221
2635
  weight,
2222
2636
  });
2223
2637
  printJson({
2224
- fontId,
2638
+ batchId: result.batchId,
2225
2639
  files: variant.files,
2640
+ fontId,
2641
+ inverseBatch: result.inverseBatch,
2226
2642
  revision: result.revision,
2227
2643
  variantId: variant.id,
2228
2644
  });
@@ -2271,6 +2687,8 @@ async function main(args) {
2271
2687
  cache: refreshed.remote.cache,
2272
2688
  packageId: refreshed.manifest.packageId,
2273
2689
  revision: refreshed.revision,
2690
+ // A failed fetch with a verified cache reports "stale": keep the reason.
2691
+ warning: refreshed.remote.warning,
2274
2692
  },
2275
2693
  before: before
2276
2694
  ? { packageId: before.manifest.packageId, revision: before.revision }
@@ -2282,20 +2700,34 @@ async function main(args) {
2282
2700
  return;
2283
2701
  }
2284
2702
  if (command === "watch") {
2285
- const interval = Math.max(100, Number(option(args, "--interval") ?? 500));
2703
+ // Validate before clamping: Number("abc") is NaN and would poll nonstop.
2704
+ const interval = Math.max(
2705
+ 100,
2706
+ integerOption(args, "--interval", 500, 3_600_000),
2707
+ );
2286
2708
  // Default is unlimited: watch without --max-events must keep observing
2287
2709
  // (SP-041-A caught a fallback of 1 that exited after the first event).
2288
2710
  const maxEvents = integerOption(args, "--max-events", Number.POSITIVE_INFINITY, 10_000);
2711
+ if (maxEvents === 0) {
2712
+ throw new SmallPenError(
2713
+ "invalid_integer_option",
2714
+ "--max-events requires an integer from 1 through 10000",
2715
+ { maximum: 10_000, name: "--max-events", value: "0" },
2716
+ );
2717
+ }
2289
2718
  const emit = (payload) =>
2290
2719
  process.stdout.write(`${JSON.stringify(payload)}\n`);
2720
+ // The last emitted state: an unchanged invalid state is not repeated
2721
+ // every poll, and recovery reports the revision again even when it is
2722
+ // the one before the Package became invalid.
2291
2723
  let previous;
2292
2724
  let events = 0;
2293
2725
  process.on("SIGINT", () => process.exit(0));
2294
2726
  for (;;) {
2295
2727
  try {
2296
2728
  const snapshot = await openPackage(packagePath);
2297
- if (snapshot.revision !== previous) {
2298
- previous = snapshot.revision;
2729
+ if (`revision:${snapshot.revision}` !== previous) {
2730
+ previous = `revision:${snapshot.revision}`;
2299
2731
  events += 1;
2300
2732
  emit({
2301
2733
  event: "revision",
@@ -2305,13 +2737,17 @@ async function main(args) {
2305
2737
  if (maxEvents !== undefined && events >= maxEvents) return;
2306
2738
  }
2307
2739
  } catch (error) {
2308
- events += 1;
2309
- emit({
2740
+ const invalid = {
2310
2741
  code: error instanceof SmallPenError ? error.code : "internal_error",
2311
2742
  event: "invalid",
2312
2743
  message: error instanceof Error ? error.message : String(error),
2313
- });
2314
- if (maxEvents !== undefined && events >= maxEvents) return;
2744
+ };
2745
+ if (`invalid:${invalid.code}:${invalid.message}` !== previous) {
2746
+ previous = `invalid:${invalid.code}:${invalid.message}`;
2747
+ events += 1;
2748
+ emit(invalid);
2749
+ if (maxEvents !== undefined && events >= maxEvents) return;
2750
+ }
2315
2751
  }
2316
2752
  await delay(interval);
2317
2753
  }
@@ -2386,7 +2822,7 @@ function errorDetails(error, args) {
2386
2822
  }
2387
2823
  if (!replaced) retry.push("--answer", `${details.questionId}=<JSON>`);
2388
2824
  if (!retry.includes("--json")) retry.push("--json");
2389
- if (!retry.includes("--locale")) retry.push("--locale", "zh-TW");
2825
+ if (!retry.includes("--locale")) retry.push("--locale", defaultLocale());
2390
2826
  return {
2391
2827
  ...details,
2392
2828
  nextOperations: [{ argv: retry, operation: "smallpen.init.answer" }],