@vincentt-xr/cli 0.15.2 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/cli/args.js +1 -0
  2. package/dist/cli/index.js +198 -3
  3. package/dist/cli/usage-text.js +27 -0
  4. package/dist/cli/verb-surface.d.ts +3 -1
  5. package/dist/cli/verb-surface.js +12 -0
  6. package/dist/domain/api.d.ts +51 -0
  7. package/dist/domain/api.js +145 -0
  8. package/dist/domain/command.d.ts +75 -0
  9. package/dist/domain/command.js +467 -0
  10. package/dist/domain/copy.d.ts +334 -0
  11. package/dist/domain/copy.js +952 -0
  12. package/dist/domain/hostname.d.ts +57 -0
  13. package/dist/domain/hostname.js +116 -0
  14. package/dist/domain/names.d.ts +78 -0
  15. package/dist/domain/names.js +113 -0
  16. package/dist/domain/routedVerbs.d.ts +10 -0
  17. package/dist/domain/routedVerbs.js +30 -0
  18. package/dist/domain/wire.d.ts +140 -0
  19. package/dist/domain/wire.js +174 -0
  20. package/dist/dx/command.d.ts +13 -4
  21. package/dist/dx/command.js +26 -5
  22. package/dist/dx/report.d.ts +39 -7
  23. package/dist/dx/report.js +71 -20
  24. package/dist/dx/send.d.ts +22 -4
  25. package/dist/dx/send.js +29 -8
  26. package/dist/dx/skeleton.js +19 -0
  27. package/dist/dx/unbiddenVerbs.d.ts +25 -0
  28. package/dist/dx/unbiddenVerbs.js +39 -0
  29. package/dist/lib/initCommand.d.ts +3 -1
  30. package/dist/lib/initCommand.js +10 -0
  31. package/dist/lib/projects.d.ts +8 -1
  32. package/dist/lib/projects.js +12 -2
  33. package/dist/platform/allowlist.d.ts +36 -0
  34. package/dist/platform/allowlist.js +34 -0
  35. package/dist/platform/behind.d.ts +41 -0
  36. package/dist/platform/behind.js +82 -0
  37. package/dist/platform/command.d.ts +16 -0
  38. package/dist/platform/command.js +90 -0
  39. package/dist/platform/footer.d.ts +60 -0
  40. package/dist/platform/footer.js +101 -0
  41. package/dist/platform/notice.d.ts +29 -0
  42. package/dist/platform/notice.js +66 -0
  43. package/dist/platform/offSwitch.d.ts +19 -0
  44. package/dist/platform/offSwitch.js +21 -0
  45. package/dist/platform/outdated.d.ts +25 -0
  46. package/dist/platform/outdated.js +246 -0
  47. package/dist/platform/releaseCache.d.ts +69 -0
  48. package/dist/platform/releaseCache.js +140 -0
  49. package/dist/platform/releaseFetch.d.ts +51 -0
  50. package/dist/platform/releaseFetch.js +150 -0
  51. package/dist/platform/resolve.d.ts +30 -0
  52. package/dist/platform/resolve.js +128 -0
  53. package/dist/platform/templateUpgrade.d.ts +56 -0
  54. package/dist/platform/templateUpgrade.js +476 -0
  55. package/dist/platform/upgradeReport.d.ts +132 -0
  56. package/dist/platform/upgradeReport.js +258 -0
  57. package/dist/platform/versionSummary.d.ts +82 -0
  58. package/dist/platform/versionSummary.js +181 -0
  59. package/dist/platform/zonePath.d.ts +154 -0
  60. package/dist/platform/zonePath.js +286 -0
  61. package/dist/platform/zones.d.ts +64 -0
  62. package/dist/platform/zones.js +79 -0
  63. package/dist/preview/api.d.ts +19 -0
  64. package/dist/preview/api.js +29 -0
  65. package/dist/publish/api.d.ts +32 -5
  66. package/dist/publish/api.js +19 -5
  67. package/dist/publish/command.d.ts +10 -0
  68. package/dist/publish/command.js +30 -2
  69. package/dist/publish/copy.d.ts +14 -1
  70. package/dist/publish/copy.js +32 -3
  71. package/dist/scaffold/index.d.ts +50 -3
  72. package/dist/scaffold/index.js +61 -1
  73. package/dist/shared/config.d.ts +16 -0
  74. package/dist/shared/config.js +9 -1
  75. package/dist/shared/countLimit.d.ts +109 -0
  76. package/dist/shared/countLimit.js +169 -0
  77. package/package.json +1 -1
  78. package/dist/dx/qa.f7.fixtures.d.ts +0 -73
  79. package/dist/dx/qa.f7.fixtures.js +0 -58
  80. package/dist/publish/qa.f4.fixtures.d.ts +0 -25
  81. package/dist/publish/qa.f4.fixtures.js +0 -140
  82. package/dist/relay/qa.f12.fixtures.d.ts +0 -154
  83. package/dist/relay/qa.f12.fixtures.js +0 -358
@@ -0,0 +1,154 @@
1
+ /**
2
+ * The path floor: whether a path may be written into a creator's repository at
3
+ * all. Purpose-built, and deliberately NOT `publish/paths.ts`.
4
+ *
5
+ * The publish validator was written to bound an S3 key for a dist upload, where
6
+ * the worst case is a weird object name. This bounds a DESTINATION FOR AN
7
+ * OVERWRITE IN SOMEONE ELSE'S REPOSITORY, where the worst case is code
8
+ * execution. Run against the zone corpus it accepts `.git/hooks/pre-commit`,
9
+ * `.git/config`, `.env`, `.npmrc`, `.gitignore`,
10
+ * `.github/workflows/release.yml`, `node_modules/.bin/esbuild`,
11
+ * `pnpm-lock.yaml` — and `src/Scene.tsx`, the one file the entire safety
12
+ * argument names. It rejects only `../` and `.`-segments and normalizes no
13
+ * unicode, so an RTL override, a Cyrillic homoglyph and a division slash all
14
+ * pass. Its `validatePathSet` also hard-requires `index.html`, so it FAILS the
15
+ * legitimate zone set — a reuse would have gone red on the happy path and the
16
+ * cheapest fix would have been to drop the control while the record still
17
+ * claimed it.
18
+ *
19
+ * THE MANIFEST IS NEVER CONSULTED HERE. It decides which of the two zones a
20
+ * PERMISSIBLE path belongs to; it is never the authority for whether a path is
21
+ * permissible. A boundary with no floor under it is not a boundary — it is a
22
+ * suggestion the manifest could withdraw. So this code is identical for every
23
+ * template version and runs BEFORE the manifest is read.
24
+ */
25
+ /** The closed reason enum. Every refusal names the rule that fired. */
26
+ export type ZonePathRejection = "non_ascii" | "denied_by_name" | "malformed" | "dot_segment" | "node_modules" | "top_level" | "extension" | "too_deep" | "too_long" | "duplicate" | "case_duplicate" | "in_both_zones";
27
+ export interface ZonePathError {
28
+ rule: ZonePathRejection;
29
+ path: string;
30
+ }
31
+ export type ZonePathResult = {
32
+ ok: true;
33
+ path: string;
34
+ } | {
35
+ ok: false;
36
+ } & ZonePathError;
37
+ /**
38
+ * Rule 2 — denied by name, checked BEFORE any zone lookup.
39
+ *
40
+ * `src/Scene.tsx` is the creator's surface and the file the whole safety
41
+ * argument rests on. It is the ONLY entry here that needs rule 2 to survive:
42
+ * it passes every other rule (a `src/` path with an allowed extension), so
43
+ * without this list a manifest listing it under `platform` would be obeyed. The
44
+ * grounding contracts are the creator's own agent contract and are additionally
45
+ * refused by rules 6 and 7 — they are named here so rule 2 is the rule that
46
+ * fires for them, which is what the corpus asserts.
47
+ *
48
+ * A manifest listing any of these under `platform` is REFUSED rather than
49
+ * obeyed. That is why this cannot be expressed as "not in the creator list":
50
+ * the manifest is the thing being distrusted.
51
+ *
52
+ * ONLY `src/Scene.tsx` IS NAMED HERE, and the three grounding contracts are
53
+ * deliberately absent — not weakened, ALREADY REFUSED, twice, before this rule
54
+ * is consulted. Each is a single-segment root path, and rule 6 admits a
55
+ * single-segment path only if it is in ALLOWED_ROOT_FILES, which none of them is;
56
+ * rule 7's extension allowlist refuses `.md` again. Naming them would therefore
57
+ * add no refusal, while putting a vendor-specific agent filename into CLI source
58
+ * — which f12 tripwire (a) sweeps for absolutely, because the platform must never
59
+ * be able to tell which agent is driving it. A name assembled from fragments to
60
+ * satisfy both would be worse than either: the source would no longer contain a
61
+ * string the next reader can grep, so the code would lie about what it refuses.
62
+ *
63
+ * `src/Scene.tsx` genuinely needs this rule: it is a two-segment path under `src`,
64
+ * so rules 6 and 7 both ADMIT it, and only a deny-by-name stops a manifest that
65
+ * lists the creator's own file as a write target. It is also the one file the
66
+ * whole safety argument rests on.
67
+ *
68
+ * NOTHING HERE BRANCHES on which of these files exists, which is the property
69
+ * the tripwire actually protects.
70
+ */
71
+ export declare const DENIED_BY_NAME: readonly string[];
72
+ /**
73
+ * Rule 2's predicate, CASE-INSENSITIVE, exported so the floor and the write site
74
+ * cannot drift.
75
+ *
76
+ * An exact match was a hole, and it was the worst available hole: `src/scene.tsx`
77
+ * passed every rule, and on a case-insensitive filesystem (macOS default,
78
+ * Windows) the write RESOLVED ONTO the creator's `src/Scene.tsx` — destroying the
79
+ * one file the entire safety argument rests on, with no backup, no pre-flight
80
+ * check and no revert, all ruled out deliberately. Worse, the report then named
81
+ * the destroyed file under LEFT ALONE while filing the destruction as a clean
82
+ * replacement, so the only compensating control pointed away from the damage.
83
+ *
84
+ * Case is the ASCII member of rule 1's family: the reason unicode is normalized
85
+ * is that a path can render as one file and BE another, defeating the human
86
+ * review of the manifest diff, which is the last control once the mechanical ones
87
+ * are gone. A case-only spelling does exactly that in plain ASCII.
88
+ *
89
+ * Case-insensitive is correct on a case-SENSITIVE filesystem too: there, a
90
+ * manifest naming a case-variant of the creator's surface creates a second file
91
+ * shadowing theirs, which is hostile for a different reason.
92
+ */
93
+ export declare function isDeniedByName(path: string): boolean;
94
+ /**
95
+ * Rule 6 — the top-level allowlist for a single-segment path.
96
+ *
97
+ * Refusal by name-absence rather than by a rule enumerating every hazard, so a
98
+ * file class nobody predicted (`pnpm-lock.yaml`, a `Makefile`, a `Dockerfile`)
99
+ * is refused without anyone having thought of it.
100
+ *
101
+ * `.eslintrc.json` is DELIBERATELY ABSENT even though the template ships it and
102
+ * the record's example manifest lists it under `platform`. Rule 4 refuses every
103
+ * dot-leading segment and runs first, so an entry here would be unreachable —
104
+ * and rule 4 is the arm that kills `.env`, `.npmrc`, `.gitignore` and
105
+ * `.github/…`. Admitting one dotfile means either narrowing rule 4 or carrying a
106
+ * per-name exception through the floor, and both weaken the control that exists
107
+ * because a dotfile overwrite is the credential-adjacent case. The consequence
108
+ * is that `.eslintrc.json` joins `scripts/*.mjs` in the reported-not-upgraded
109
+ * set. Raised as a fork against the record's §5 example rather than resolved by
110
+ * widening the floor.
111
+ */
112
+ export declare const ALLOWED_ROOT_FILES: readonly string[];
113
+ /** Rule 6 — the only directories a multi-segment path may start in. */
114
+ export declare const ALLOWED_TOP_DIRS: readonly string[];
115
+ /** Rule 7 — the extension allowlist. Closes `.yaml`, `.yml`, `.sh`, extensionless. */
116
+ export declare const ALLOWED_EXTENSIONS: readonly string[];
117
+ /**
118
+ * Validate ONE path against rules 1-8. Rule 9 is set-level and lives in
119
+ * `validateZoneSet`.
120
+ *
121
+ * The order is load-bearing in one place and one place only: rule 2 runs before
122
+ * anything that could consult a zone. An innocuous-looking reorder that looks
123
+ * up the zone first and denies after is the regression this ordering exists to
124
+ * prevent, and it is invisible in a diff.
125
+ */
126
+ export declare function validateZonePath(raw: string): ZonePathResult;
127
+ /**
128
+ * The same floor, told which zone the entry was listed in.
129
+ *
130
+ * Rule 2 applies to the OVERWRITE zone. `src/Scene.tsx` listed under `creator`
131
+ * is the correct and required content of a legitimate manifest — `creator` is
132
+ * the never-written list, so a denied name there is the manifest agreeing with
133
+ * the floor rather than attacking it. Applying rule 2 to both arrays refuses the
134
+ * legitimate zone set, which is the same shape of defect as the publish
135
+ * validator's `index.html` requirement: a control that fails the happy path gets
136
+ * dropped under build pressure while the record still claims it exists.
137
+ *
138
+ * Rules 1 and 3-8 apply to BOTH zones, because they bound what a manifest may
139
+ * NAME at all, and an unlisted-but-named path is still a path this code reads.
140
+ */
141
+ export declare function validateZonePathIn(raw: string, zone: "platform" | "creator"): ZonePathResult;
142
+ export interface ZoneSet {
143
+ platform: string[];
144
+ creator: string[];
145
+ }
146
+ /**
147
+ * Rule 9 — the set level. ONE failure refuses the WHOLE manifest.
148
+ *
149
+ * A per-entry skip looks like robustness and is the smuggling path: it is
150
+ * exactly how one hostile entry rides in behind twenty good ones, and the twenty
151
+ * good writes are what make the report look normal. There is no partial accept
152
+ * and no `continue` in this function.
153
+ */
154
+ export declare function validateZoneSet(set: ZoneSet): ZonePathError | undefined;
@@ -0,0 +1,286 @@
1
+ /**
2
+ * The path floor: whether a path may be written into a creator's repository at
3
+ * all. Purpose-built, and deliberately NOT `publish/paths.ts`.
4
+ *
5
+ * The publish validator was written to bound an S3 key for a dist upload, where
6
+ * the worst case is a weird object name. This bounds a DESTINATION FOR AN
7
+ * OVERWRITE IN SOMEONE ELSE'S REPOSITORY, where the worst case is code
8
+ * execution. Run against the zone corpus it accepts `.git/hooks/pre-commit`,
9
+ * `.git/config`, `.env`, `.npmrc`, `.gitignore`,
10
+ * `.github/workflows/release.yml`, `node_modules/.bin/esbuild`,
11
+ * `pnpm-lock.yaml` — and `src/Scene.tsx`, the one file the entire safety
12
+ * argument names. It rejects only `../` and `.`-segments and normalizes no
13
+ * unicode, so an RTL override, a Cyrillic homoglyph and a division slash all
14
+ * pass. Its `validatePathSet` also hard-requires `index.html`, so it FAILS the
15
+ * legitimate zone set — a reuse would have gone red on the happy path and the
16
+ * cheapest fix would have been to drop the control while the record still
17
+ * claimed it.
18
+ *
19
+ * THE MANIFEST IS NEVER CONSULTED HERE. It decides which of the two zones a
20
+ * PERMISSIBLE path belongs to; it is never the authority for whether a path is
21
+ * permissible. A boundary with no floor under it is not a boundary — it is a
22
+ * suggestion the manifest could withdraw. So this code is identical for every
23
+ * template version and runs BEFORE the manifest is read.
24
+ */
25
+ /**
26
+ * Rule 2 — denied by name, checked BEFORE any zone lookup.
27
+ *
28
+ * `src/Scene.tsx` is the creator's surface and the file the whole safety
29
+ * argument rests on. It is the ONLY entry here that needs rule 2 to survive:
30
+ * it passes every other rule (a `src/` path with an allowed extension), so
31
+ * without this list a manifest listing it under `platform` would be obeyed. The
32
+ * grounding contracts are the creator's own agent contract and are additionally
33
+ * refused by rules 6 and 7 — they are named here so rule 2 is the rule that
34
+ * fires for them, which is what the corpus asserts.
35
+ *
36
+ * A manifest listing any of these under `platform` is REFUSED rather than
37
+ * obeyed. That is why this cannot be expressed as "not in the creator list":
38
+ * the manifest is the thing being distrusted.
39
+ *
40
+ * ONLY `src/Scene.tsx` IS NAMED HERE, and the three grounding contracts are
41
+ * deliberately absent — not weakened, ALREADY REFUSED, twice, before this rule
42
+ * is consulted. Each is a single-segment root path, and rule 6 admits a
43
+ * single-segment path only if it is in ALLOWED_ROOT_FILES, which none of them is;
44
+ * rule 7's extension allowlist refuses `.md` again. Naming them would therefore
45
+ * add no refusal, while putting a vendor-specific agent filename into CLI source
46
+ * — which f12 tripwire (a) sweeps for absolutely, because the platform must never
47
+ * be able to tell which agent is driving it. A name assembled from fragments to
48
+ * satisfy both would be worse than either: the source would no longer contain a
49
+ * string the next reader can grep, so the code would lie about what it refuses.
50
+ *
51
+ * `src/Scene.tsx` genuinely needs this rule: it is a two-segment path under `src`,
52
+ * so rules 6 and 7 both ADMIT it, and only a deny-by-name stops a manifest that
53
+ * lists the creator's own file as a write target. It is also the one file the
54
+ * whole safety argument rests on.
55
+ *
56
+ * NOTHING HERE BRANCHES on which of these files exists, which is the property
57
+ * the tripwire actually protects.
58
+ */
59
+ export const DENIED_BY_NAME = Object.freeze(["src/Scene.tsx"]);
60
+ /**
61
+ * Rule 2's predicate, CASE-INSENSITIVE, exported so the floor and the write site
62
+ * cannot drift.
63
+ *
64
+ * An exact match was a hole, and it was the worst available hole: `src/scene.tsx`
65
+ * passed every rule, and on a case-insensitive filesystem (macOS default,
66
+ * Windows) the write RESOLVED ONTO the creator's `src/Scene.tsx` — destroying the
67
+ * one file the entire safety argument rests on, with no backup, no pre-flight
68
+ * check and no revert, all ruled out deliberately. Worse, the report then named
69
+ * the destroyed file under LEFT ALONE while filing the destruction as a clean
70
+ * replacement, so the only compensating control pointed away from the damage.
71
+ *
72
+ * Case is the ASCII member of rule 1's family: the reason unicode is normalized
73
+ * is that a path can render as one file and BE another, defeating the human
74
+ * review of the manifest diff, which is the last control once the mechanical ones
75
+ * are gone. A case-only spelling does exactly that in plain ASCII.
76
+ *
77
+ * Case-insensitive is correct on a case-SENSITIVE filesystem too: there, a
78
+ * manifest naming a case-variant of the creator's surface creates a second file
79
+ * shadowing theirs, which is hostile for a different reason.
80
+ */
81
+ export function isDeniedByName(path) {
82
+ const lowered = path.toLowerCase();
83
+ return DENIED_BY_NAME.some((denied) => denied.toLowerCase() === lowered);
84
+ }
85
+ /**
86
+ * Rule 6 — the top-level allowlist for a single-segment path.
87
+ *
88
+ * Refusal by name-absence rather than by a rule enumerating every hazard, so a
89
+ * file class nobody predicted (`pnpm-lock.yaml`, a `Makefile`, a `Dockerfile`)
90
+ * is refused without anyone having thought of it.
91
+ *
92
+ * `.eslintrc.json` is DELIBERATELY ABSENT even though the template ships it and
93
+ * the record's example manifest lists it under `platform`. Rule 4 refuses every
94
+ * dot-leading segment and runs first, so an entry here would be unreachable —
95
+ * and rule 4 is the arm that kills `.env`, `.npmrc`, `.gitignore` and
96
+ * `.github/…`. Admitting one dotfile means either narrowing rule 4 or carrying a
97
+ * per-name exception through the floor, and both weaken the control that exists
98
+ * because a dotfile overwrite is the credential-adjacent case. The consequence
99
+ * is that `.eslintrc.json` joins `scripts/*.mjs` in the reported-not-upgraded
100
+ * set. Raised as a fork against the record's §5 example rather than resolved by
101
+ * widening the floor.
102
+ */
103
+ export const ALLOWED_ROOT_FILES = Object.freeze([
104
+ "index.html",
105
+ "package.json",
106
+ "tsconfig.json",
107
+ "tsconfig.node.json",
108
+ "vitest.config.ts",
109
+ "esbuild.config.mjs",
110
+ ]);
111
+ /** Rule 6 — the only directories a multi-segment path may start in. */
112
+ export const ALLOWED_TOP_DIRS = Object.freeze(["src", "public"]);
113
+ /** Rule 7 — the extension allowlist. Closes `.yaml`, `.yml`, `.sh`, extensionless. */
114
+ export const ALLOWED_EXTENSIONS = Object.freeze([
115
+ ".tsx",
116
+ ".ts",
117
+ ".css",
118
+ ".mjs",
119
+ ".json",
120
+ ".html",
121
+ ".d.ts",
122
+ ]);
123
+ const MAX_SEGMENTS = 4;
124
+ const MAX_PATH_BYTES = 256;
125
+ const NON_ASCII = /[^\x20-\x7e]/;
126
+ const DRIVE_LETTER = /^[a-zA-Z]:/;
127
+ const CONTROL_BYTES = /[\x00-\x1f\x7f-\x9f]/;
128
+ const SURROGATE = /[\ud800-\udfff]/;
129
+ /**
130
+ * Validate ONE path against rules 1-8. Rule 9 is set-level and lives in
131
+ * `validateZoneSet`.
132
+ *
133
+ * The order is load-bearing in one place and one place only: rule 2 runs before
134
+ * anything that could consult a zone. An innocuous-looking reorder that looks
135
+ * up the zone first and denies after is the regression this ordering exists to
136
+ * prevent, and it is invisible in a diff.
137
+ */
138
+ export function validateZonePath(raw) {
139
+ return validateZonePathIn(raw, "platform");
140
+ }
141
+ /**
142
+ * The same floor, told which zone the entry was listed in.
143
+ *
144
+ * Rule 2 applies to the OVERWRITE zone. `src/Scene.tsx` listed under `creator`
145
+ * is the correct and required content of a legitimate manifest — `creator` is
146
+ * the never-written list, so a denied name there is the manifest agreeing with
147
+ * the floor rather than attacking it. Applying rule 2 to both arrays refuses the
148
+ * legitimate zone set, which is the same shape of defect as the publish
149
+ * validator's `index.html` requirement: a control that fails the happy path gets
150
+ * dropped under build pressure while the record still claims it exists.
151
+ *
152
+ * Rules 1 and 3-8 apply to BOTH zones, because they bound what a manifest may
153
+ * NAME at all, and an unlisted-but-named path is still a path this code reads.
154
+ */
155
+ export function validateZonePathIn(raw, zone) {
156
+ // Rule 1 — NFC-normalize, then reject any non-ASCII byte.
157
+ //
158
+ // Normalizing FIRST matters: a decomposed sequence and its composed form are
159
+ // the same file, and comparing the un-normalized string against a deny list
160
+ // would miss one of the two spellings. Then ASCII-only, because every hazard
161
+ // in this class survives every other rule: a division slash is ONE segment so
162
+ // no segment rule sees it, and an RTL override, a Cyrillic homoglyph and a
163
+ // trailing zero-width space each render as an ordinary path in a report or a
164
+ // manifest diff while naming a different file. That defeats the human review
165
+ // of the manifest diff, which is the only control left once the mechanical
166
+ // ones are satisfied. The template has no non-ASCII filename and never will.
167
+ const path = raw.normalize("NFC");
168
+ if (NON_ASCII.test(path))
169
+ return reject("non_ascii", raw);
170
+ // Rule 2 — denied by name. BEFORE the zone lookup, and before the cheaper
171
+ // structural rules, so the refusal a reader cares about is the one reported.
172
+ if (zone === "platform" && isDeniedByName(path)) {
173
+ return reject("denied_by_name", raw);
174
+ }
175
+ // Rule 3 — the arms of the publish validator that were right, restated rather
176
+ // than imported. Restated because importing would couple this floor to a
177
+ // module whose threat model is a different one, and the next edit there would
178
+ // silently change what may be written into a repository.
179
+ if (path.includes("\\"))
180
+ return reject("malformed", raw);
181
+ if (path.startsWith("/"))
182
+ return reject("malformed", raw);
183
+ if (DRIVE_LETTER.test(path))
184
+ return reject("malformed", raw);
185
+ if (path.includes("%"))
186
+ return reject("malformed", raw);
187
+ // Control bytes and lone surrogates are already excluded by rule 1 (both are
188
+ // outside printable ASCII); the checks stay because rule 3 is asserted by
189
+ // name and a future widening of rule 1 must not silently drop them.
190
+ if (CONTROL_BYTES.test(path))
191
+ return reject("malformed", raw);
192
+ if (SURROGATE.test(path))
193
+ return reject("malformed", raw);
194
+ // Rule 8 — length, before the split, so a pathological string is bounded
195
+ // before any per-segment work runs over it.
196
+ if (Buffer.byteLength(path, "utf8") > MAX_PATH_BYTES)
197
+ return reject("too_long", raw);
198
+ const segments = path.split("/");
199
+ // Rule 8 — depth.
200
+ if (segments.length > MAX_SEGMENTS)
201
+ return reject("too_deep", raw);
202
+ // Rule 5 — `node_modules` as a first segment. BEFORE rule 4, so the rule
203
+ // REPORTED for `node_modules/.bin/esbuild` is the one that names why it is
204
+ // dangerous (a binary the creator's next build executes) rather than the
205
+ // incidental dot on its second segment. Both refuse it; the attribution is
206
+ // what a reader and the corpus depend on, and rule 5 exists precisely because
207
+ // it is not redundant with rule 4.
208
+ if (segments[0] === "node_modules")
209
+ return reject("node_modules", raw);
210
+ // Rule 4 — the dotfile rule the publish validator has none of. `.env`,
211
+ // `.npmrc`, `.gitignore`, `.git/hooks/pre-commit` and `.github/…` all die
212
+ // here. `..` and `.` are covered by the same clause rather than separately,
213
+ // because "begins with a dot" is the superset and stating it once is what
214
+ // makes the rule hard to narrow by accident.
215
+ for (const seg of segments) {
216
+ if (seg === "")
217
+ return reject("dot_segment", raw);
218
+ if (seg.startsWith("."))
219
+ return reject("dot_segment", raw);
220
+ }
221
+ // Rule 6 — the top-level allowlist.
222
+ if (segments.length === 1) {
223
+ if (!ALLOWED_ROOT_FILES.includes(segments[0]))
224
+ return reject("top_level", raw);
225
+ }
226
+ else if (!ALLOWED_TOP_DIRS.includes(segments[0])) {
227
+ // `scripts/dev.mjs` is refused HERE, and that is correct and deliberate:
228
+ // the template's build scripts execute on the creator's machine, which is
229
+ // the closest thing in the template to the `node_modules/.bin` case. They
230
+ // move to the report's not-permitted-to-be-upgraded set rather than the
231
+ // allowlist being widened to admit them.
232
+ return reject("top_level", raw);
233
+ }
234
+ // Rule 7 — extension. `.d.ts` is checked before `.ts` would match it, but
235
+ // both are allowed so the order is not load-bearing; the check is a suffix
236
+ // test rather than a parse so `vite-env.d.ts` needs no special case.
237
+ if (!ALLOWED_EXTENSIONS.some((ext) => path.endsWith(ext))) {
238
+ return reject("extension", raw);
239
+ }
240
+ return { ok: true, path };
241
+ }
242
+ function reject(rule, path) {
243
+ return { ok: false, rule, path };
244
+ }
245
+ /**
246
+ * Rule 9 — the set level. ONE failure refuses the WHOLE manifest.
247
+ *
248
+ * A per-entry skip looks like robustness and is the smuggling path: it is
249
+ * exactly how one hostile entry rides in behind twenty good ones, and the twenty
250
+ * good writes are what make the report look normal. There is no partial accept
251
+ * and no `continue` in this function.
252
+ */
253
+ export function validateZoneSet(set) {
254
+ const seen = new Set();
255
+ const seenLower = new Set();
256
+ const platform = new Set();
257
+ for (const zone of ["platform", "creator"]) {
258
+ for (const raw of set[zone]) {
259
+ const result = validateZonePathIn(raw, zone);
260
+ if (!result.ok)
261
+ return { rule: result.rule, path: result.path };
262
+ const p = result.path;
263
+ // Reported as in-both-zones rather than as a plain duplicate, because the
264
+ // two say different things to whoever fixes the manifest: a duplicate is a
265
+ // typo, and a path in both arrays is a file whose OWNER is contested —
266
+ // which either destroys creator work or leaves plumbing stale, depending
267
+ // on which list a reader assumes wins.
268
+ if (zone === "creator" && platform.has(p)) {
269
+ return { rule: "in_both_zones", path: p };
270
+ }
271
+ if (seen.has(p))
272
+ return { rule: "duplicate", path: p };
273
+ // A case-duplicate is two names for one file on macOS and Windows and two
274
+ // files on Linux, so the manifest means different things on different
275
+ // machines — and the overwrite set is the wrong place to discover that.
276
+ const lower = p.toLowerCase();
277
+ if (seenLower.has(lower))
278
+ return { rule: "case_duplicate", path: p };
279
+ seen.add(p);
280
+ seenLower.add(lower);
281
+ if (zone === "platform")
282
+ platform.add(p);
283
+ }
284
+ }
285
+ return undefined;
286
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `.vincentt-template.json` — the template's own per-version zone manifest.
3
+ *
4
+ * It is the authority for WHICH OF THE TWO ZONES a permissible path belongs to.
5
+ * It is never the authority for WHETHER a path is permissible: that is the
6
+ * floor, which is code, ships in the CLI, is identical for every template
7
+ * version, and runs BEFORE anything here consults a zone.
8
+ *
9
+ * Per-version because a project scaffolded from 1.2.0 needs 1.2.0's set, and
10
+ * only the 1.2.0 tag authoritatively knows it. Enumerated arrays with no globs,
11
+ * because the default must be LEAVE ALONE — an implicit rule claims every file
12
+ * the creator ever added, including one named `Scene2.tsx`, and the day a
13
+ * template file ships unclassified the default becomes destroy the creator's
14
+ * work.
15
+ *
16
+ * AN UNRECOGNIZED `schema` IS BLOCKED, never best-effort. A manifest this CLI
17
+ * does not understand is a manifest whose overwrite set it cannot bound.
18
+ */
19
+ import { type ZonePathError, type ZoneSet } from "./zonePath.js";
20
+ /** The only schema this CLI understands. */
21
+ export declare const SUPPORTED_SCHEMA = 1;
22
+ export type ManifestRefusal = {
23
+ kind: "unparseable";
24
+ } | {
25
+ kind: "unsupported_schema";
26
+ schema: unknown;
27
+ } | {
28
+ kind: "malformed";
29
+ } | {
30
+ kind: "floor";
31
+ error: ZonePathError;
32
+ };
33
+ export type ManifestResult = {
34
+ ok: true;
35
+ zones: ZoneSet;
36
+ } | {
37
+ ok: false;
38
+ refusal: ManifestRefusal;
39
+ };
40
+ /**
41
+ * Parse and floor-validate a manifest document.
42
+ *
43
+ * The floor runs over the WHOLE set before any caller may read a zone, and ONE
44
+ * failing entry refuses the WHOLE manifest. A per-entry skip looks like
45
+ * robustness and is the smuggling path — it is how one hostile entry rides in
46
+ * behind twenty good ones, with the twenty good writes making the report look
47
+ * normal.
48
+ */
49
+ export declare function parseZoneManifest(raw: string): ManifestResult;
50
+ export type Zone = "platform" | "creator" | "unlisted";
51
+ /**
52
+ * Which zone a path is in.
53
+ *
54
+ * `unlisted` is a first-class answer and it means LEAVE ALONE — never read,
55
+ * never written. Every file the creator added is unlisted, which is the whole
56
+ * reason the lists are enumerated rather than inferred.
57
+ *
58
+ * This may only be called on a manifest the floor has already accepted, which is
59
+ * why it takes a validated `ZoneSet` rather than a raw document: the type is the
60
+ * reminder that permissibility was decided elsewhere and earlier.
61
+ */
62
+ export declare function zoneOf(zones: ZoneSet, path: string): Zone;
63
+ /** The overwrite set: exactly the `platform` list, in manifest order. */
64
+ export declare function overwriteSet(zones: ZoneSet): string[];
@@ -0,0 +1,79 @@
1
+ /**
2
+ * `.vincentt-template.json` — the template's own per-version zone manifest.
3
+ *
4
+ * It is the authority for WHICH OF THE TWO ZONES a permissible path belongs to.
5
+ * It is never the authority for WHETHER a path is permissible: that is the
6
+ * floor, which is code, ships in the CLI, is identical for every template
7
+ * version, and runs BEFORE anything here consults a zone.
8
+ *
9
+ * Per-version because a project scaffolded from 1.2.0 needs 1.2.0's set, and
10
+ * only the 1.2.0 tag authoritatively knows it. Enumerated arrays with no globs,
11
+ * because the default must be LEAVE ALONE — an implicit rule claims every file
12
+ * the creator ever added, including one named `Scene2.tsx`, and the day a
13
+ * template file ships unclassified the default becomes destroy the creator's
14
+ * work.
15
+ *
16
+ * AN UNRECOGNIZED `schema` IS BLOCKED, never best-effort. A manifest this CLI
17
+ * does not understand is a manifest whose overwrite set it cannot bound.
18
+ */
19
+ import { validateZoneSet } from "./zonePath.js";
20
+ /** The only schema this CLI understands. */
21
+ export const SUPPORTED_SCHEMA = 1;
22
+ /**
23
+ * Parse and floor-validate a manifest document.
24
+ *
25
+ * The floor runs over the WHOLE set before any caller may read a zone, and ONE
26
+ * failing entry refuses the WHOLE manifest. A per-entry skip looks like
27
+ * robustness and is the smuggling path — it is how one hostile entry rides in
28
+ * behind twenty good ones, with the twenty good writes making the report look
29
+ * normal.
30
+ */
31
+ export function parseZoneManifest(raw) {
32
+ let doc;
33
+ try {
34
+ doc = JSON.parse(raw);
35
+ }
36
+ catch {
37
+ return { ok: false, refusal: { kind: "unparseable" } };
38
+ }
39
+ if (typeof doc !== "object" || doc === null) {
40
+ return { ok: false, refusal: { kind: "malformed" } };
41
+ }
42
+ const { schema, platform, creator } = doc;
43
+ if (schema !== SUPPORTED_SCHEMA) {
44
+ return { ok: false, refusal: { kind: "unsupported_schema", schema } };
45
+ }
46
+ if (!isStringArray(platform) || !isStringArray(creator)) {
47
+ return { ok: false, refusal: { kind: "malformed" } };
48
+ }
49
+ const zones = { platform, creator };
50
+ const error = validateZoneSet(zones);
51
+ if (error !== undefined)
52
+ return { ok: false, refusal: { kind: "floor", error } };
53
+ return { ok: true, zones };
54
+ }
55
+ function isStringArray(v) {
56
+ return Array.isArray(v) && v.every((x) => typeof x === "string");
57
+ }
58
+ /**
59
+ * Which zone a path is in.
60
+ *
61
+ * `unlisted` is a first-class answer and it means LEAVE ALONE — never read,
62
+ * never written. Every file the creator added is unlisted, which is the whole
63
+ * reason the lists are enumerated rather than inferred.
64
+ *
65
+ * This may only be called on a manifest the floor has already accepted, which is
66
+ * why it takes a validated `ZoneSet` rather than a raw document: the type is the
67
+ * reminder that permissibility was decided elsewhere and earlier.
68
+ */
69
+ export function zoneOf(zones, path) {
70
+ if (zones.platform.includes(path))
71
+ return "platform";
72
+ if (zones.creator.includes(path))
73
+ return "creator";
74
+ return "unlisted";
75
+ }
76
+ /** The overwrite set: exactly the `platform` list, in manifest order. */
77
+ export function overwriteSet(zones) {
78
+ return [...zones.platform];
79
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ResolvedConfig } from "../shared/config.js";
2
+ import type { CountLimitDetail } from "../shared/countLimit.js";
2
3
  import { BackendError } from "../lib/backend.js";
3
4
  /** The mint response. Its key set is the cross-repo pin (contract table 2.11). */
4
5
  export interface MintedPreview {
@@ -27,6 +28,24 @@ export declare class PreviewRateLimitedError extends BackendError {
27
28
  export declare class PreviewConflictError extends BackendError {
28
29
  constructor(message: string);
29
30
  }
31
+ /**
32
+ * The org is at its concurrent-live-preview ceiling.
33
+ *
34
+ * IT IS A SECOND 409 ON THE MINT ROUTE and it is told from the first by its
35
+ * CODE, never by its status. The two mean opposite things to a creator: a
36
+ * conflict says a preview for this folder is already running and to go watch it;
37
+ * the ceiling says no preview started and the organization is full. Collapsing
38
+ * them would tell a creator at their ceiling to watch a preview that does not
39
+ * exist.
40
+ *
41
+ * It carries the whole detail because the refusal IS the notice: the figures and
42
+ * the org name are the only things that make it actionable, and the CLI cannot
43
+ * obtain either anywhere else.
44
+ */
45
+ export declare class PreviewLimitError extends BackendError {
46
+ readonly detail: CountLimitDetail;
47
+ constructor(message: string, detail: CountLimitDetail);
48
+ }
30
49
  /**
31
50
  * The session is gone — `preview_session_ended`, the CLI's terminal branch.
32
51
  *
@@ -23,6 +23,28 @@ export class PreviewConflictError extends BackendError {
23
23
  this.name = "PreviewConflictError";
24
24
  }
25
25
  }
26
+ /**
27
+ * The org is at its concurrent-live-preview ceiling.
28
+ *
29
+ * IT IS A SECOND 409 ON THE MINT ROUTE and it is told from the first by its
30
+ * CODE, never by its status. The two mean opposite things to a creator: a
31
+ * conflict says a preview for this folder is already running and to go watch it;
32
+ * the ceiling says no preview started and the organization is full. Collapsing
33
+ * them would tell a creator at their ceiling to watch a preview that does not
34
+ * exist.
35
+ *
36
+ * It carries the whole detail because the refusal IS the notice: the figures and
37
+ * the org name are the only things that make it actionable, and the CLI cannot
38
+ * obtain either anywhere else.
39
+ */
40
+ export class PreviewLimitError extends BackendError {
41
+ detail;
42
+ constructor(message, detail) {
43
+ super(message, 409);
44
+ this.detail = detail;
45
+ this.name = "PreviewLimitError";
46
+ }
47
+ }
26
48
  /**
27
49
  * The session is gone — `preview_session_ended`, the CLI's terminal branch.
28
50
  *
@@ -104,6 +126,13 @@ export async function mintPreviewSession(cfg, projectId, key, fetchImpl = fetch)
104
126
  throw new ProjectUnreachableError(body.message ?? "This project is not available to you.");
105
127
  }
106
128
  if (res.status === 409) {
129
+ // TWO 409s NOW LAND HERE and the CODE is what separates them. Reading the
130
+ // status alone would render the ceiling refusal as "a preview is already
131
+ // running for this folder" — a sentence that is false, that names a
132
+ // preview the creator does not have, and that sends them to watch it.
133
+ if (body.code === "preview_limit") {
134
+ throw new PreviewLimitError(body.message ?? "this organization is at its preview limit", body.detail ?? {});
135
+ }
107
136
  throw new PreviewConflictError(body.message ?? "a preview is already running for this folder");
108
137
  }
109
138
  throw new BackendError(body.message ?? `could not start a preview (${res.status})`, res.status);