@vincentt-xr/cli 0.15.1 → 0.16.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 (84) hide show
  1. package/dist/cli/args.js +1 -0
  2. package/dist/cli/index.js +177 -4
  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/clusters.js +10 -1
  21. package/dist/dx/command.d.ts +13 -4
  22. package/dist/dx/command.js +26 -5
  23. package/dist/dx/report.d.ts +39 -7
  24. package/dist/dx/report.js +71 -20
  25. package/dist/dx/send.d.ts +22 -4
  26. package/dist/dx/send.js +29 -8
  27. package/dist/dx/skeleton.js +19 -0
  28. package/dist/dx/unbiddenVerbs.d.ts +25 -0
  29. package/dist/dx/unbiddenVerbs.js +39 -0
  30. package/dist/lib/exitCodes.d.ts +24 -0
  31. package/dist/lib/exitCodes.js +28 -0
  32. package/dist/lib/initCommand.d.ts +3 -1
  33. package/dist/lib/initCommand.js +10 -0
  34. package/dist/lib/initFrom.js +12 -2
  35. package/dist/lib/lifecycle.d.ts +5 -1
  36. package/dist/lib/projects.d.ts +8 -1
  37. package/dist/lib/projects.js +21 -4
  38. package/dist/platform/allowlist.d.ts +36 -0
  39. package/dist/platform/allowlist.js +34 -0
  40. package/dist/platform/behind.d.ts +41 -0
  41. package/dist/platform/behind.js +82 -0
  42. package/dist/platform/command.d.ts +16 -0
  43. package/dist/platform/command.js +90 -0
  44. package/dist/platform/footer.d.ts +60 -0
  45. package/dist/platform/footer.js +101 -0
  46. package/dist/platform/notice.d.ts +29 -0
  47. package/dist/platform/notice.js +66 -0
  48. package/dist/platform/offSwitch.d.ts +19 -0
  49. package/dist/platform/offSwitch.js +21 -0
  50. package/dist/platform/outdated.d.ts +25 -0
  51. package/dist/platform/outdated.js +246 -0
  52. package/dist/platform/releaseCache.d.ts +69 -0
  53. package/dist/platform/releaseCache.js +140 -0
  54. package/dist/platform/releaseFetch.d.ts +51 -0
  55. package/dist/platform/releaseFetch.js +150 -0
  56. package/dist/platform/resolve.d.ts +30 -0
  57. package/dist/platform/resolve.js +128 -0
  58. package/dist/platform/templateUpgrade.d.ts +56 -0
  59. package/dist/platform/templateUpgrade.js +476 -0
  60. package/dist/platform/upgradeReport.d.ts +132 -0
  61. package/dist/platform/upgradeReport.js +258 -0
  62. package/dist/platform/versionSummary.d.ts +82 -0
  63. package/dist/platform/versionSummary.js +181 -0
  64. package/dist/platform/zonePath.d.ts +154 -0
  65. package/dist/platform/zonePath.js +286 -0
  66. package/dist/platform/zones.d.ts +64 -0
  67. package/dist/platform/zones.js +79 -0
  68. package/dist/publish/api.d.ts +15 -4
  69. package/dist/publish/api.js +19 -5
  70. package/dist/publish/command.d.ts +10 -0
  71. package/dist/publish/command.js +7 -1
  72. package/dist/publish/copy.d.ts +14 -1
  73. package/dist/publish/copy.js +32 -3
  74. package/dist/scaffold/index.d.ts +50 -3
  75. package/dist/scaffold/index.js +61 -1
  76. package/dist/shared/config.d.ts +16 -0
  77. package/dist/shared/config.js +9 -1
  78. package/package.json +1 -1
  79. package/dist/dx/qa.f7.fixtures.d.ts +0 -73
  80. package/dist/dx/qa.f7.fixtures.js +0 -58
  81. package/dist/publish/qa.f4.fixtures.d.ts +0 -25
  82. package/dist/publish/qa.f4.fixtures.js +0 -140
  83. package/dist/relay/qa.f12.fixtures.d.ts +0 -154
  84. package/dist/relay/qa.f12.fixtures.js +0 -358
@@ -1,4 +1,5 @@
1
1
  import type { ResolvedConfig } from "../shared/config.js";
2
+ import { type BuiltWith } from "../platform/resolve.js";
2
3
  export interface PublishDeps {
3
4
  print: (line: string) => void;
4
5
  /** Raw write, for the redrawn progress line only. */
@@ -21,6 +22,15 @@ export interface PublishDeps {
21
22
  /** The active org's slug, for the usage deep link on a plan-limit refusal. */
22
23
  orgSlug?: () => Promise<string | undefined>;
23
24
  consoleUrl?: string;
25
+ /**
26
+ * What this project resolved on this machine, attached to the version row.
27
+ *
28
+ * Injected so the publish path can be tested without a `node_modules` tree,
29
+ * and defaulted to f17's SHARED resolver — the same one the report path calls,
30
+ * because two payloads carrying byte-identical values for one project is the
31
+ * mechanical proof there is one resolver and not two that drifted.
32
+ */
33
+ builtWith?: () => Promise<BuiltWith | undefined>;
24
34
  }
25
35
  export interface PublishOptions {
26
36
  distDir?: string;
@@ -17,6 +17,7 @@ import { classifyDist } from "./classify.js";
17
17
  import { createProgress } from "./progress.js";
18
18
  import { UploadFailed, uploadAll } from "./upload.js";
19
19
  import * as copy from "./copy.js";
20
+ import { resolveBuiltWith } from "../platform/resolve.js";
20
21
  const COMMIT_RETRIES = 3;
21
22
  const COMMIT_RETRY_DELAY_MS = 2000;
22
23
  export async function publishCommand(deps, opts = {}) {
@@ -140,6 +141,10 @@ export async function publishCommand(deps, opts = {}) {
140
141
  return 0;
141
142
  }
142
143
  }
144
+ // Resolved AFTER the confirmation and never before it: a refused publish must
145
+ // read nothing about the creator's installed packages. A fault here yields
146
+ // undefined and the field is omitted — it can never fail a publish.
147
+ const builtWith = await (deps.builtWith ?? (() => resolveBuiltWith(deps.cwd)))().catch(() => undefined);
143
148
  return runUpload(deps, cfg, {
144
149
  projectId: binding.projectId,
145
150
  distDir,
@@ -148,6 +153,7 @@ export async function publishCommand(deps, opts = {}) {
148
153
  address,
149
154
  liveVersion: project.liveVersion ?? undefined,
150
155
  fetchImpl,
156
+ builtWith,
151
157
  org: {
152
158
  name: orgName || undefined,
153
159
  slug: (await deps.orgSlug?.()) || undefined,
@@ -158,7 +164,7 @@ export async function publishCommand(deps, opts = {}) {
158
164
  async function runUpload(deps, cfg, i) {
159
165
  let opened;
160
166
  try {
161
- opened = await openUpload(cfg, i.projectId, i.manifest.files, i.note, i.fetchImpl);
167
+ opened = await openUpload(cfg, i.projectId, i.manifest.files, i.note, i.fetchImpl, i.builtWith);
162
168
  }
163
169
  catch (err) {
164
170
  for (const l of openErrorLines(err, i.address, i.liveVersion, i.org))
@@ -205,5 +205,18 @@ export declare function rateLimited(retryAfterSec: number): string[];
205
205
  * none.
206
206
  */
207
207
  export declare function consoleVerb(verb: string, versionsUrl?: string): string[];
208
- /** The six verbs that land on that block. */
208
+ /**
209
+ * The verbs that land on that block — f4's and f10's six, plus f15's five.
210
+ *
211
+ * NONE OF THESE GAINS A HANDLER. They are routed to an answer precisely because
212
+ * they do not exist: f15 deliberately gives the CLI no checkout verb, since the
213
+ * authorized actor is a human reading consequence copy in a browser and a verb
214
+ * whose whole contribution is printing a URL is a browser flow with a shell in
215
+ * front of it.
216
+ *
217
+ * Routing them rather than ignoring them is about f15's actual actor. A human
218
+ * who types `vincentt upgrade` and gets "unrecognised command" shrugs and reads
219
+ * the help. AN AGENT RETRIES, or guesses a neighbouring verb, and neither
220
+ * terminates.
221
+ */
209
222
  export declare const CONSOLE_ONLY_VERBS: readonly string[];
@@ -476,9 +476,19 @@ export function planLimitRefused(i) {
476
476
  "",
477
477
  ...alignedRows(rows),
478
478
  "",
479
+ // f15 FALSIFIED THE OLD SENTENCE. It said restoring was "a manual action by a
480
+ // Vincentt operator" — true until a customer could pay for it themselves, and
481
+ // false the moment checkout shipped.
482
+ //
483
+ // "cannot be done from the CLI" STAYS, and is the load-bearing half: an agent
484
+ // told only that publishing failed hunts for a flag, and naming the remedy as
485
+ // something outside this tool is what stops the loop.
486
+ //
487
+ // The restore is deliberately not promised as instant — it is about two
488
+ // minutes, and copy that promises instant is copy that lies on a slow tick.
479
489
  ` Published addresses were taken offline${takenOn} because the organization`,
480
- " went over its monthly traffic allowance. Restoring this is a manual action",
481
- " by a Vincentt operator and cannot be done from the CLI.",
490
+ " went over its monthly traffic allowance. Taking a paid plan restores them",
491
+ " automatically, in the console — it cannot be done from the CLI.",
482
492
  "",
483
493
  " Every version is still stored and the address is still yours.",
484
494
  "",
@@ -681,7 +691,20 @@ export function consoleVerb(verb, versionsUrl) {
681
691
  }
682
692
  return out;
683
693
  }
684
- /** The six verbs that land on that block. */
694
+ /**
695
+ * The verbs that land on that block — f4's and f10's six, plus f15's five.
696
+ *
697
+ * NONE OF THESE GAINS A HANDLER. They are routed to an answer precisely because
698
+ * they do not exist: f15 deliberately gives the CLI no checkout verb, since the
699
+ * authorized actor is a human reading consequence copy in a browser and a verb
700
+ * whose whole contribution is printing a URL is a browser flow with a shell in
701
+ * front of it.
702
+ *
703
+ * Routing them rather than ignoring them is about f15's actual actor. A human
704
+ * who types `vincentt upgrade` and gets "unrecognised command" shrugs and reads
705
+ * the help. AN AGENT RETRIES, or guesses a neighbouring verb, and neither
706
+ * terminates.
707
+ */
685
708
  export const CONSOLE_ONLY_VERBS = [
686
709
  "rollback",
687
710
  "unpublish",
@@ -689,4 +712,10 @@ export const CONSOLE_ONLY_VERBS = [
689
712
  "versions",
690
713
  "revert",
691
714
  "take-offline",
715
+ // f15 — checkout is console-only, by design.
716
+ "upgrade",
717
+ "billing",
718
+ "plan",
719
+ "subscribe",
720
+ "pay",
692
721
  ];
@@ -11,8 +11,14 @@ export declare const TEMPLATE_REPO = "https://github.com/vincentt-xr/v2-template
11
11
  * immutable, which is what makes that an escape hatch rather than a coin flip.
12
12
  */
13
13
  export declare const DEFAULT_TEMPLATE_REF = "latest";
14
- /** Injectable git runner (real git by default; a fake in tests avoids the network). */
15
- export type GitRunner = (args: string[]) => Promise<void>;
14
+ /**
15
+ * Injectable git runner (real git by default; a fake in tests avoids the network).
16
+ *
17
+ * Resolves to git's stdout. A fake that returns nothing still satisfies the type,
18
+ * so the tag resolution below treats a void result the same as empty output —
19
+ * unresolved rather than a crash.
20
+ */
21
+ export type GitRunner = (args: string[]) => Promise<string | void>;
16
22
  /**
17
23
  * True when `dir` has no app yet (no package.json) → safe to scaffold into.
18
24
  * A directory that already holds an app is left untouched (project_create just binds).
@@ -46,12 +52,53 @@ export interface ScaffoldOptions {
46
52
  /** Test seam — defaults to spawning real git. */
47
53
  runGit?: GitRunner;
48
54
  }
55
+ /**
56
+ * The concrete version tag the clone actually landed on, or `undefined` when the
57
+ * commit carries none.
58
+ *
59
+ * `git describe --tags` is the obvious call and it is WRONG here: against a clone
60
+ * of the moving tag it answers `latest`, which is the exact non-information this
61
+ * resolution exists to replace. `tag --points-at HEAD` lists every tag on the
62
+ * cloned commit instead — `latest` alongside the version tag it was force-moved
63
+ * onto — and the version tag is selected by shape.
64
+ *
65
+ * When several concrete versions share a commit (a re-tagged release), the
66
+ * HIGHEST is recorded: the tags name the same tree, so any is a correct baseline,
67
+ * and picking by precedence rather than by git's output order keeps the recorded
68
+ * value stable across clones.
69
+ */
70
+ export declare function pickConcreteVersionTag(tagOutput: string | void): string | undefined;
71
+ /** What a scaffold resolved. `templateVersion` absent = it could not be established. */
72
+ export interface ScaffoldResult {
73
+ /**
74
+ * The concrete version tag the clone landed on. ABSENT rather than `latest`
75
+ * when unresolvable: a moving tag recorded as a baseline is worse than no
76
+ * baseline, because a later upgrade would trust it and widen what it replaces.
77
+ */
78
+ templateVersion?: string;
79
+ /** The ref that was ASKED for — `latest` on the default path. Diagnostic only. */
80
+ requestedRef?: string;
81
+ }
82
+ /**
83
+ * The shape an INJECTED scaffold may return. `void` is permitted, and reads as
84
+ * "this fake resolved no version" — which is the no-baseline state, already a
85
+ * first-class value here.
86
+ *
87
+ * Deliberately looser than what `scaffoldFromTemplate` itself returns: the real
88
+ * function always reports, while a caller's test double has nothing to report
89
+ * and must not be forced to invent a version to satisfy a type.
90
+ */
91
+ export type ScaffoldOutcome = ScaffoldResult | void;
49
92
  /**
50
93
  * Scaffold the template into `targetDir`. Clones to a temp dir, strips `.git`,
51
94
  * copies the files in (never overwriting anything already there), then inits a
52
95
  * fresh repo. Throws a clear error if the clone fails (e.g. no git access).
96
+ *
97
+ * Returns what it resolved. The requested ref is not the answer: `latest` is
98
+ * force-updated by the template's release workflow, so recording it preserves
99
+ * nothing — every project from any date would report the same string.
53
100
  */
54
- export declare function scaffoldFromTemplate(targetDir: string, opts?: ScaffoldOptions): Promise<void>;
101
+ export declare function scaffoldFromTemplate(targetDir: string, opts?: ScaffoldOptions): Promise<ScaffoldResult>;
55
102
  export type PackageManager = "pnpm" | "yarn" | "npm";
56
103
  /**
57
104
  * Pick the package manager the scaffolded template is set up for, by its lockfile.
@@ -23,7 +23,8 @@ export const TEMPLATE_REPO = "https://github.com/vincentt-xr/v2-template.git";
23
23
  */
24
24
  export const DEFAULT_TEMPLATE_REF = "latest";
25
25
  const realGit = async (args) => {
26
- await execFileAsync("git", args);
26
+ const { stdout } = await execFileAsync("git", args);
27
+ return stdout;
27
28
  };
28
29
  /**
29
30
  * True when `dir` has no app yet (no package.json) → safe to scaffold into.
@@ -71,10 +72,57 @@ export class ScaffoldRefError extends Error {
71
72
  this.name = "ScaffoldRefError";
72
73
  }
73
74
  }
75
+ /**
76
+ * A concrete `MAJOR.MINOR.PATCH` version tag, anchored at both ends.
77
+ *
78
+ * Anchored because a template repo carries tags a version compare cannot use:
79
+ * `latest` itself, and anything merely version-SHAPED (`1.4.3-extra`). Recording
80
+ * one of those is the defect this whole resolution exists to remove.
81
+ */
82
+ const CONCRETE_VERSION_TAG = /^[0-9]+\.[0-9]+\.[0-9]+$/;
83
+ /**
84
+ * The concrete version tag the clone actually landed on, or `undefined` when the
85
+ * commit carries none.
86
+ *
87
+ * `git describe --tags` is the obvious call and it is WRONG here: against a clone
88
+ * of the moving tag it answers `latest`, which is the exact non-information this
89
+ * resolution exists to replace. `tag --points-at HEAD` lists every tag on the
90
+ * cloned commit instead — `latest` alongside the version tag it was force-moved
91
+ * onto — and the version tag is selected by shape.
92
+ *
93
+ * When several concrete versions share a commit (a re-tagged release), the
94
+ * HIGHEST is recorded: the tags name the same tree, so any is a correct baseline,
95
+ * and picking by precedence rather than by git's output order keeps the recorded
96
+ * value stable across clones.
97
+ */
98
+ export function pickConcreteVersionTag(tagOutput) {
99
+ if (!tagOutput)
100
+ return undefined;
101
+ const versions = tagOutput
102
+ .split(/\r?\n/)
103
+ .map((line) => line.trim())
104
+ .filter((line) => CONCRETE_VERSION_TAG.test(line));
105
+ if (versions.length === 0)
106
+ return undefined;
107
+ return versions.sort(compareVersionDesc)[0];
108
+ }
109
+ function compareVersionDesc(a, b) {
110
+ const pa = a.split(".").map(Number);
111
+ const pb = b.split(".").map(Number);
112
+ for (let i = 0; i < 3; i++) {
113
+ if (pa[i] !== pb[i])
114
+ return pb[i] - pa[i];
115
+ }
116
+ return 0;
117
+ }
74
118
  /**
75
119
  * Scaffold the template into `targetDir`. Clones to a temp dir, strips `.git`,
76
120
  * copies the files in (never overwriting anything already there), then inits a
77
121
  * fresh repo. Throws a clear error if the clone fails (e.g. no git access).
122
+ *
123
+ * Returns what it resolved. The requested ref is not the answer: `latest` is
124
+ * force-updated by the template's release workflow, so recording it preserves
125
+ * nothing — every project from any date would report the same string.
78
126
  */
79
127
  export async function scaffoldFromTemplate(targetDir, opts = {}) {
80
128
  const repo = opts.repo ?? TEMPLATE_REPO;
@@ -97,6 +145,17 @@ export async function scaffoldFromTemplate(targetDir, opts = {}) {
97
145
  }
98
146
  throw new Error(`Could not clone the ${repo} template (check your git access): ${msg}`);
99
147
  }
148
+ // BEFORE the strip below — `.git` is the only place the concrete tag is
149
+ // readable, and the copy into the target deliberately does not carry it.
150
+ // A failure here must not fail the scaffold: the files are correct either
151
+ // way, and the honest outcome is a missing baseline, not a missing app.
152
+ let templateVersion;
153
+ try {
154
+ templateVersion = pickConcreteVersionTag(await runGit(["-C", tmp, "tag", "--points-at", "HEAD"]));
155
+ }
156
+ catch {
157
+ templateVersion = undefined;
158
+ }
100
159
  await fs.rm(path.join(tmp, ".git"), { recursive: true, force: true });
101
160
  // Copy template contents in without clobbering anything already in the target.
102
161
  for (const entry of await fs.readdir(tmp)) {
@@ -110,6 +169,7 @@ export async function scaffoldFromTemplate(targetDir, opts = {}) {
110
169
  if (!(await isGitRepo(targetDir))) {
111
170
  await runGit(["-C", targetDir, "init"]);
112
171
  }
172
+ return { templateVersion, requestedRef: ref };
113
173
  }
114
174
  finally {
115
175
  await fs.rm(tmp, { recursive: true, force: true });
@@ -18,6 +18,20 @@ export interface MachineConfig {
18
18
  */
19
19
  dxSendEnabledAt?: string;
20
20
  }
21
+ /**
22
+ * What the project was scaffolded from — the drift baseline a later upgrade
23
+ * compares against.
24
+ *
25
+ * The whole object is ABSENT when the scaffold could not resolve a concrete
26
+ * version, and absent is a first-class state every reader must handle. The
27
+ * alternative — recording the moving `latest` tag — is strictly worse than
28
+ * absence: a reader cannot tell it apart from a real baseline, so it would be
29
+ * trusted, and an upgrade trusting a wrong baseline widens what it overwrites.
30
+ */
31
+ export interface TemplateProvenance {
32
+ /** A concrete `MAJOR.MINOR.PATCH` tag. Never `latest`, never a branch. */
33
+ version: string;
34
+ }
21
35
  export interface ProjectBinding {
22
36
  /** The backend Project id this working tree publishes to. */
23
37
  projectId: string;
@@ -25,6 +39,8 @@ export interface ProjectBinding {
25
39
  slug: string;
26
40
  /** Optional per-project API override; else the machine config's apiUrl. */
27
41
  apiUrl?: string;
42
+ /** Absent = no recorded baseline. See TemplateProvenance. */
43
+ template?: TemplateProvenance;
28
44
  }
29
45
  export declare const MACHINE_CONFIG_PATH: string;
30
46
  export declare function projectBindingPath(cwd: string): string;
@@ -176,7 +176,15 @@ async function isGitRoot(dir) {
176
176
  export async function writeProjectBinding(cwd, binding) {
177
177
  await fs.mkdir(path.join(cwd, BINDING_DIR), { recursive: true });
178
178
  const p = projectBindingPath(cwd);
179
- await fs.writeFile(p, JSON.stringify(binding, null, 2) + "\n", "utf8");
179
+ // A caller that writes a whole binding without a `template` (`link`, a re-bind)
180
+ // must not DROP a baseline the scaffold recorded: the tree it describes is
181
+ // unchanged by a re-bind, and a lost baseline downgrades the next upgrade to
182
+ // the reporting path with nothing on screen to explain why.
183
+ const existing = binding.template
184
+ ? undefined
185
+ : (await readJsonIfExists(p))?.template;
186
+ const next = existing ? { ...binding, template: existing } : binding;
187
+ await fs.writeFile(p, JSON.stringify(next, null, 2) + "\n", "utf8");
180
188
  await ensureGitignored(cwd, `${BINDING_DIR}/`);
181
189
  // `create` and `link` WRITE a binding rather than resolve one, so they never
182
190
  // pass through the resolver's hook. Between the two, every way a folder comes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentt-xr/cli",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
4
4
  "description": "Vincentt CLI — the vincentt command-line dev loop (login, create, publish, preview, diagnostics, feedback, relay)",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -1,73 +0,0 @@
1
- /**
2
- * QA fixtures for the dx-agent-consent delta (f7).
3
- *
4
- * Declared in the change's qa.md `## Fixtures` section. All three are
5
- * machine-config state, which f7 has never had a fixture for — its existing
6
- * fixtures (F7_JOURNAL_FRICTION, F7_REPORT_EXPIRED, …) are journal-side or
7
- * server-side.
8
- *
9
- * F7_CONFIG_SIGNED_IN is written by driving the REAL writeMachineConfig(), never
10
- * by hand-writing JSON. A hand-written config asserts against a shape the CLI
11
- * never produces — the f1 orgs/memberships failure at file scope, where a mock
12
- * of state the API could actually produce drifted from the API and stayed green.
13
- *
14
- * These are QA-owned (qa.* prefix) and deliberately separate from dev's test
15
- * files in the same package.
16
- */
17
- import type { JournalEntry } from "./journal.js";
18
- /** The honest baseline config, as the CLI itself writes it. */
19
- export declare const F7_CONFIG_SIGNED_IN: {
20
- readonly apiUrl: "https://api.test";
21
- readonly consoleUrl: "https://console.test";
22
- readonly pat: "vct_alice";
23
- readonly activeOrg: "alice";
24
- };
25
- /**
26
- * The instant every fixture is dated against. 145 days after the grant in
27
- * F7_CONFIG_ENABLED, and before DX_UNATTENDED_SUNSET.
28
- */
29
- export declare const F7_NOW: number;
30
- /** 145 days before F7_NOW. The AGE is the point — see qa.md on F7_CONFIG_ENABLED. */
31
- export declare const F7_ENABLED_AT = "2026-04-01T00:00:00.000Z";
32
- /**
33
- * The single pinned human-facing shape. Every human surface must render this
34
- * byte-for-byte; the age is the load-bearing half, so it is pinned as a literal
35
- * rather than computed (a computed expectation would follow a broken
36
- * implementation wherever it went).
37
- */
38
- export declare const F7_PINNED_ENABLEMENT_LINE = "Unattended send: ENABLED since 2026-04-01 (145 days)";
39
- /** F7_CONFIG_SIGNED_IN + durable enablement, aged. */
40
- export declare const F7_CONFIG_ENABLED: {
41
- readonly dxSendEnabled: true;
42
- readonly dxSendEnabledAt: "2026-04-01T00:00:00.000Z";
43
- readonly apiUrl: "https://api.test";
44
- readonly consoleUrl: "https://console.test";
45
- readonly pat: "vct_alice";
46
- readonly activeOrg: "alice";
47
- };
48
- /**
49
- * Every shape the enablement read must treat as DISABLED.
50
- *
51
- * Rows B, C, E, F, G are TRUTHY IN JAVASCRIPT — `if (cfg.dxSendEnabled)` passes
52
- * every one of them. That is the entire reason this fixture exists, and why the
53
- * read is specified as `=== true`. The worst is B ("false"): what a person
54
- * hand-editing this file writes when trying to turn the feature OFF.
55
- *
56
- * `raw` rows (K, L) are written as bytes rather than as an object, because the
57
- * property under test is that an UNPARSEABLE config fails closed instead of
58
- * throwing through into an unrelated verb.
59
- */
60
- export interface MalformedRow {
61
- id: string;
62
- why: string;
63
- /** A config object to serialize, or undefined when `raw` carries the bytes. */
64
- patch?: Record<string, unknown>;
65
- /** Literal file bytes, for the rows that are not valid JSON at all. */
66
- raw?: string;
67
- }
68
- export declare const F7_CONFIG_MALFORMED_ENABLEMENT: MalformedRow[];
69
- /**
70
- * F7_JOURNAL_FRICTION, as the record declares it: three runs of the same verb in
71
- * a few minutes, which is a repetition cluster.
72
- */
73
- export declare function f7JournalFriction(now?: number): JournalEntry[];
@@ -1,58 +0,0 @@
1
- /** The honest baseline config, as the CLI itself writes it. */
2
- export const F7_CONFIG_SIGNED_IN = {
3
- apiUrl: "https://api.test",
4
- consoleUrl: "https://console.test",
5
- pat: "vct_alice",
6
- activeOrg: "alice",
7
- };
8
- /**
9
- * The instant every fixture is dated against. 145 days after the grant in
10
- * F7_CONFIG_ENABLED, and before DX_UNATTENDED_SUNSET.
11
- */
12
- export const F7_NOW = Date.parse("2026-08-24T12:00:00.000Z");
13
- /** 145 days before F7_NOW. The AGE is the point — see qa.md on F7_CONFIG_ENABLED. */
14
- export const F7_ENABLED_AT = "2026-04-01T00:00:00.000Z";
15
- /**
16
- * The single pinned human-facing shape. Every human surface must render this
17
- * byte-for-byte; the age is the load-bearing half, so it is pinned as a literal
18
- * rather than computed (a computed expectation would follow a broken
19
- * implementation wherever it went).
20
- */
21
- export const F7_PINNED_ENABLEMENT_LINE = "Unattended send: ENABLED since 2026-04-01 (145 days)";
22
- /** F7_CONFIG_SIGNED_IN + durable enablement, aged. */
23
- export const F7_CONFIG_ENABLED = {
24
- ...F7_CONFIG_SIGNED_IN,
25
- dxSendEnabled: true,
26
- dxSendEnabledAt: F7_ENABLED_AT,
27
- };
28
- export const F7_CONFIG_MALFORMED_ENABLEMENT = [
29
- { id: "A", why: 'truthy STRING "true"', patch: { dxSendEnabled: "true", dxSendEnabledAt: F7_ENABLED_AT } },
30
- { id: "B", why: 'truthy in JS, means false to a human', patch: { dxSendEnabled: "false", dxSendEnabledAt: F7_ENABLED_AT } },
31
- { id: "C", why: 'truthy in JS ("0")', patch: { dxSendEnabled: "0", dxSendEnabledAt: F7_ENABLED_AT } },
32
- { id: "D", why: "truthy, not the boolean (1)", patch: { dxSendEnabled: 1, dxSendEnabledAt: F7_ENABLED_AT } },
33
- { id: "E", why: "truthy ({} — what a refactor grouping the fields produces)", patch: { dxSendEnabled: {}, dxSendEnabledAt: F7_ENABLED_AT } },
34
- { id: "F", why: "truthy ([])", patch: { dxSendEnabled: [], dxSendEnabledAt: F7_ENABLED_AT } },
35
- { id: "G", why: 'truthy ("garbage")', patch: { dxSendEnabled: "garbage", dxSendEnabledAt: F7_ENABLED_AT } },
36
- { id: "H", why: "key absent entirely", patch: {} },
37
- { id: "I", why: "enabled true, dxSendEnabledAt absent", patch: { dxSendEnabled: true } },
38
- { id: "J", why: 'enabled true, dxSendEnabledAt "not-a-date"', patch: { dxSendEnabled: true, dxSendEnabledAt: "not-a-date" } },
39
- { id: "K", why: "file truncated mid-JSON", raw: '{"pat":"vct_alice","dxSendEn' },
40
- { id: "L", why: "file empty (0 bytes)", raw: "" },
41
- ];
42
- /**
43
- * F7_JOURNAL_FRICTION, as the record declares it: three runs of the same verb in
44
- * a few minutes, which is a repetition cluster.
45
- */
46
- export function f7JournalFriction(now = F7_NOW) {
47
- const entry = (minutesAgo) => ({
48
- ts: new Date(now - minutesAgo * 60_000).toISOString(),
49
- v: "0.7.0",
50
- verb: "preview",
51
- skel: "preview",
52
- exit: 0,
53
- ms: 800,
54
- err: null,
55
- wt: "abc123",
56
- });
57
- return [entry(3), entry(2), entry(1)];
58
- }
@@ -1,25 +0,0 @@
1
- export type DistFixtureName = "CLEAN" | "TEMPLATE" | "MISSING_META" | "DEAD_IMAGE" | "MULTI_HTML" | "ADVERSARIAL" | "NO_DIST" | "OVERSIZE";
2
- /** The template defaults corpus the CLI ships, used verbatim by TEMPLATE. */
3
- export declare const TEMPLATE_TITLE = "Vite + React";
4
- export declare const TEMPLATE_DESCRIPTION = "Vite + React + TypeScript starter";
5
- /**
6
- * distFixture materializes one named tree under a fresh temp dir and returns
7
- * its cwd. The caller removes it.
8
- */
9
- export declare function distFixture(name: DistFixtureName): Promise<string>;
10
- export declare function removeFixture(cwd: string): Promise<void>;
11
- /**
12
- * The SHARED ADVERSARIAL ROWS. This list is the fixture the API's Go table test
13
- * and this suite both read; row-count parity is asserted in both runners.
14
- *
15
- * `reason` is the closed-enum value the API answers with. A row whose reason
16
- * differs between the two runners is a validator that disagrees with itself
17
- * across a repo boundary, which shows up only as a publish that cannot succeed.
18
- */
19
- export interface AdversarialRow {
20
- name: string;
21
- path: string;
22
- /** "" means the row must be ACCEPTED. */
23
- reason: string;
24
- }
25
- export declare function adversarialRows(): AdversarialRow[];
@@ -1,140 +0,0 @@
1
- // QA f4 · the eight on-disk `dist/` trees the catalog names (4C / 4E).
2
- //
3
- // A distFixture is a REAL DIRECTORY, not a mock: the checklist parses HTML off
4
- // disk and the manifest hashes bytes off disk, so a fixture that stubbed either
5
- // would be asserting against a shape the product never reads.
6
- //
7
- // `ADVERSARIAL` is shared BYTE-FOR-BYTE with the API's validateManifest table —
8
- // one set of rows, read by both the Go table test and this vitest suite. A row
9
- // present in one runner and absent in the other fails the parity assertion, or
10
- // the shared fixture degrades into two fixtures with one name (f3's QA-F3-G11
11
- // lesson).
12
- import { promises as fs } from "node:fs";
13
- import os from "node:os";
14
- import path from "node:path";
15
- /** The template defaults corpus the CLI ships, used verbatim by TEMPLATE. */
16
- export const TEMPLATE_TITLE = "Vite + React";
17
- export const TEMPLATE_DESCRIPTION = "Vite + React + TypeScript starter";
18
- function html(opts) {
19
- const parts = ["<!doctype html><html><head>"];
20
- if (opts.title !== undefined)
21
- parts.push(`<title>${opts.title}</title>`);
22
- if (opts.ogTitle !== undefined) {
23
- parts.push(`<meta property="og:title" content="${opts.ogTitle}">`);
24
- }
25
- if (opts.description !== undefined) {
26
- parts.push(`<meta name="description" content="${opts.description}">`);
27
- }
28
- if (opts.ogImage !== undefined) {
29
- parts.push(`<meta property="og:image" content="${opts.ogImage}">`);
30
- }
31
- parts.push("</head><body><div id=\"root\"></div></body></html>");
32
- return parts.join("");
33
- }
34
- /**
35
- * distFixture materializes one named tree under a fresh temp dir and returns
36
- * its cwd. The caller removes it.
37
- */
38
- export async function distFixture(name) {
39
- const cwd = await fs.mkdtemp(path.join(os.tmpdir(), `vct-f4-${name.toLowerCase()}-`));
40
- const dist = path.join(cwd, "dist");
41
- if (name === "NO_DIST") {
42
- // The ABSENCE of a dist/ directory IS the fixture.
43
- return cwd;
44
- }
45
- await fs.mkdir(dist, { recursive: true });
46
- switch (name) {
47
- case "CLEAN": {
48
- // A real title, description and og:image; og.png PRESENT; 6 files.
49
- await fs.writeFile(path.join(dist, "index.html"), html({
50
- title: "Harbour Wing — an AR walk",
51
- description: "A guided AR tour of the Harbour Wing collection.",
52
- ogImage: "/og.png",
53
- }));
54
- await fs.writeFile(path.join(dist, "og.png"), "png-bytes-clean");
55
- await fs.mkdir(path.join(dist, "assets"), { recursive: true });
56
- await fs.writeFile(path.join(dist, "assets", "app-a1b2c3.js"), "export const a=1;");
57
- await fs.writeFile(path.join(dist, "assets", "app-d4e5f6.css"), "body{margin:0}");
58
- await fs.writeFile(path.join(dist, "favicon.ico"), "ico-bytes");
59
- await fs.writeFile(path.join(dist, "model.glb"), "glb-bytes");
60
- return cwd;
61
- }
62
- case "TEMPLATE": {
63
- // The template's own strings, untouched.
64
- await fs.writeFile(path.join(dist, "index.html"), html({
65
- title: TEMPLATE_TITLE,
66
- description: TEMPLATE_DESCRIPTION,
67
- ogImage: "/og.png",
68
- }));
69
- await fs.writeFile(path.join(dist, "og.png"), "png-bytes-template");
70
- return cwd;
71
- }
72
- case "MISSING_META": {
73
- // A title ONLY — no description, no og:image.
74
- await fs.writeFile(path.join(dist, "index.html"), html({ title: "Harbour Wing — an AR walk" }));
75
- return cwd;
76
- }
77
- case "DEAD_IMAGE": {
78
- // og:image references /og.png, which is ABSENT from the tree. This is the
79
- // ONLY fixture that produces a ✗.
80
- await fs.writeFile(path.join(dist, "index.html"), html({
81
- title: "Harbour Wing — an AR walk",
82
- description: "A guided AR tour of the Harbour Wing collection.",
83
- ogImage: "/og.png",
84
- }));
85
- return cwd;
86
- }
87
- case "MULTI_HTML": {
88
- await fs.writeFile(path.join(dist, "index.html"), html({
89
- title: "Harbour Wing — an AR walk",
90
- description: "A guided AR tour of the Harbour Wing collection.",
91
- ogImage: "/og.png",
92
- }));
93
- await fs.writeFile(path.join(dist, "og.png"), "png-bytes-multi");
94
- // gallery.html has its OWN, DIFFERING findings.
95
- await fs.writeFile(path.join(dist, "gallery.html"), html({ title: TEMPLATE_TITLE }));
96
- return cwd;
97
- }
98
- case "ADVERSARIAL": {
99
- // Paths only, and NO index.html at the root. The tree cannot literally
100
- // contain "../escape.js" — the ROWS are the fixture, and they are shared
101
- // with the Go table test through adversarialRows() below.
102
- await fs.mkdir(path.join(dist, "sub"), { recursive: true });
103
- await fs.writeFile(path.join(dist, "sub", "index.html"), html({ title: "nested" }));
104
- return cwd;
105
- }
106
- case "OVERSIZE": {
107
- // The bound variants are driven by SYNTHETIC MANIFEST ROWS rather than
108
- // real bytes: writing 612 MB to a temp dir on every run would make the
109
- // suite unusable, and the bound is checked against declared sizes before
110
- // a byte moves — which is exactly the property under test.
111
- await fs.writeFile(path.join(dist, "index.html"), html({ title: "big" }));
112
- return cwd;
113
- }
114
- }
115
- return cwd;
116
- }
117
- export async function removeFixture(cwd) {
118
- await fs.rm(cwd, { recursive: true, force: true });
119
- }
120
- export function adversarialRows() {
121
- return [
122
- { name: "absolute", path: "/a.js", reason: "absolute_path" },
123
- { name: "traversal", path: "../a.js", reason: "path_traversal" },
124
- { name: "traversal_after_normalize", path: "a/../../b.js", reason: "path_traversal" },
125
- { name: "backslash", path: "a\\b.js", reason: "backslash" },
126
- { name: "empty_segment", path: "a//b.js", reason: "empty_segment" },
127
- { name: "over_1024_bytes", path: `${"a".repeat(1025)}.js`, reason: "path_too_long" },
128
- { name: "control_char", path: `a${String.fromCharCode(7)}b.js`, reason: "control_char" },
129
- { name: "percent", path: "a%20b.js", reason: "bad_encoding" },
130
- { name: "percent_2e2e", path: "a%2e%2e/b.js", reason: "bad_encoding" },
131
- { name: "reserved_platform", path: "_platform/x.js", reason: "reserved_path" },
132
- { name: "reserved_platform2", path: "_platform2/x.js", reason: "reserved_path" },
133
- { name: "reserved_leading_underscore", path: "_anything/x.js", reason: "reserved_path" },
134
- { name: "dot_segment", path: ".", reason: "path_traversal" },
135
- { name: "dotdot_segment", path: "..", reason: "path_traversal" },
136
- // THE ACCEPTED ROW. Without one the table passes on a validator that
137
- // rejects everything.
138
- { name: "nested_leading_underscore_is_ALLOWED", path: "a/_b.js", reason: "" },
139
- ];
140
- }