@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
@@ -20,9 +20,24 @@ const ELIDED = "<v>";
20
20
  * on-disk journal and any report built from it. Lowercase kebab-case is exactly
21
21
  * what slugs and project names are made of, so no character-class check catches
22
22
  * this — only a list of the verbs that actually exist.
23
+ *
24
+ * THIS SET MUST COVER EVERY DISPATCHED VERB, and a missing one is not a leak but
25
+ * a mis-bucketing: `journal.ts` records `verbOf(argv)`, which answers `<verb>`
26
+ * for anything absent here, so every invocation of a missing verb clusters under
27
+ * the placeholder and its friction is invisible. That also broke `lastContext()`
28
+ * — `report.ts` skips entries whose verb is `report`, which never matched while
29
+ * `report` was absent, so the context line a creator consents to could be the
30
+ * skeleton of their own `report` invocation rather than the command before it.
31
+ *
32
+ * Adding a verb CANNOT widen what a positional carries: `skeleton()` consults
33
+ * this set only when `out.length === 0`, so it reaches the FIRST token only, and
34
+ * every later positional is elided regardless. The historical leak lived in the
35
+ * first token.
23
36
  */
24
37
  const KNOWN_VERBS = new Set([
38
+ "assets",
25
39
  "create",
40
+ "domain",
26
41
  "dx",
27
42
  "feedback",
28
43
  "init",
@@ -31,9 +46,12 @@ const KNOWN_VERBS = new Set([
31
46
  "logout",
32
47
  "logs",
33
48
  "network",
49
+ "outdated",
34
50
  "preview",
51
+ "projects",
35
52
  "publish",
36
53
  "relay",
54
+ "report",
37
55
  "trace",
38
56
  "unlink",
39
57
  "version",
@@ -56,6 +74,7 @@ const KNOWN_FLAGS = new Set([
56
74
  "--json",
57
75
  "--limit",
58
76
  "--name",
77
+ "--no-context",
59
78
  "--no-install",
60
79
  "--no-qr",
61
80
  "--note",
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The verbs unbidden output may not contain.
3
+ *
4
+ * Unbidden output lands in an agent's transcript, and an agent that reads an
5
+ * instruction will simply obey it. Naming that a command exists is information;
6
+ * telling a caller to transmit — or to grant itself permission to — is not. A
7
+ * REFUSAL may name both, because a refusal is solicited
8
+ * (D-A-refusal-is-solicited-output), so this list constrains only output the
9
+ * caller did not ask for.
10
+ *
11
+ * It is exported rather than restated at each site because every additional
12
+ * consumer is another chance for the lists to drift apart, and a guard that has
13
+ * quietly stopped matching its sibling still passes.
14
+ *
15
+ * `please`, `should` and `must` are not verbs. They are here because they are
16
+ * how an imperative gets smuggled past a verb-only list ("you should enable
17
+ * this"), and the guard is about directing rather than about grammar.
18
+ */
19
+ export declare const UNBIDDEN_FORBIDDEN_VERBS: readonly ["run", "send", "report", "submit", "share", "try", "use", "execute", "enable", "please", "should", "must"];
20
+ export type UnbiddenForbiddenVerb = (typeof UNBIDDEN_FORBIDDEN_VERBS)[number];
21
+ /**
22
+ * Word-boundary, case-insensitive — the form every consumer needs, built here so
23
+ * a consumer cannot get the boundary or the flags subtly wrong.
24
+ */
25
+ export declare function unbiddenVerbPattern(verb: string): RegExp;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The verbs unbidden output may not contain.
3
+ *
4
+ * Unbidden output lands in an agent's transcript, and an agent that reads an
5
+ * instruction will simply obey it. Naming that a command exists is information;
6
+ * telling a caller to transmit — or to grant itself permission to — is not. A
7
+ * REFUSAL may name both, because a refusal is solicited
8
+ * (D-A-refusal-is-solicited-output), so this list constrains only output the
9
+ * caller did not ask for.
10
+ *
11
+ * It is exported rather than restated at each site because every additional
12
+ * consumer is another chance for the lists to drift apart, and a guard that has
13
+ * quietly stopped matching its sibling still passes.
14
+ *
15
+ * `please`, `should` and `must` are not verbs. They are here because they are
16
+ * how an imperative gets smuggled past a verb-only list ("you should enable
17
+ * this"), and the guard is about directing rather than about grammar.
18
+ */
19
+ export const UNBIDDEN_FORBIDDEN_VERBS = Object.freeze([
20
+ "run",
21
+ "send",
22
+ "report",
23
+ "submit",
24
+ "share",
25
+ "try",
26
+ "use",
27
+ "execute",
28
+ "enable",
29
+ "please",
30
+ "should",
31
+ "must",
32
+ ]);
33
+ /**
34
+ * Word-boundary, case-insensitive — the form every consumer needs, built here so
35
+ * a consumer cannot get the boundary or the flags subtly wrong.
36
+ */
37
+ export function unbiddenVerbPattern(verb) {
38
+ return new RegExp(`\\b${verb}\\b`, "i");
39
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Every exit code the CLI returns. B-F12-1: APPEND-ONLY — a code may be added,
3
+ * an existing code's meaning never changes. Removing or repurposing one breaks
4
+ * grounding that already shipped into a creator's repository and is never read
5
+ * back, so the platform cannot warn about it or repair it.
6
+ */
7
+ export declare const EXIT: {
8
+ /** The command did what it was asked. */
9
+ readonly OK: 0;
10
+ /** The command failed and nothing lasting was produced. */
11
+ readonly FAILED: 1;
12
+ /**
13
+ * The project source was written by this invocation, but no address was
14
+ * reserved: no account on this machine, and no browser to get one. The
15
+ * creator holds a working local project; only registration is deferred.
16
+ */
17
+ readonly DELIVERED_NOT_REGISTERED: 3;
18
+ /** A platform call needed an account and the command did not answer for it. */
19
+ readonly NOT_AUTHENTICATED: 4;
20
+ /** A bounded wait elapsed with nothing handed over. */
21
+ readonly WINDOW_ELAPSED: 64;
22
+ /** The channel ended before anything was handed over. */
23
+ readonly CHANNEL_ENDED: 65;
24
+ };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Every exit code the CLI returns. B-F12-1: APPEND-ONLY — a code may be added,
3
+ * an existing code's meaning never changes. Removing or repurposing one breaks
4
+ * grounding that already shipped into a creator's repository and is never read
5
+ * back, so the platform cannot warn about it or repair it.
6
+ */
7
+ export const EXIT = {
8
+ /** The command did what it was asked. */
9
+ OK: 0,
10
+ /** The command failed and nothing lasting was produced. */
11
+ FAILED: 1,
12
+ /**
13
+ * The project source was written by this invocation, but no address was
14
+ * reserved: no account on this machine, and no browser to get one. The
15
+ * creator holds a working local project; only registration is deferred.
16
+ */
17
+ DELIVERED_NOT_REGISTERED: 3,
18
+ /** A platform call needed an account and the command did not answer for it. */
19
+ NOT_AUTHENTICATED: 4,
20
+ /** A bounded wait elapsed with nothing handed over. */
21
+ WINDOW_ELAPSED: 64,
22
+ /** The channel ended before anything was handed over. */
23
+ CHANNEL_ENDED: 65,
24
+ };
25
+ // 2 is deliberately unallocated. Shells conventionally use 2 for misuse of a
26
+ // builtin, and a CLI that returns it invites a caller to misread a shell error
27
+ // as ours. The gap is recorded here so a later change does not fill it by
28
+ // looking for the lowest free number.
@@ -1,10 +1,11 @@
1
+ import { type ScaffoldOutcome } from "../scaffold/index.js";
1
2
  import type { RegisterDeps } from "./initFrom.js";
2
3
  import { type SourceRef } from "./sourceRef.js";
3
4
  import type { Ask, Confirm } from "../publish/prompt.js";
4
5
  export interface InitDeps {
5
6
  print: (line: string) => void;
6
7
  cwd: string;
7
- scaffold?: (targetDir: string, opts: ScaffoldCall) => Promise<void>;
8
+ scaffold?: (targetDir: string, opts: ScaffoldCall) => Promise<ScaffoldOutcome>;
8
9
  install?: (targetDir: string) => Promise<string>;
9
10
  isEmpty?: (targetDir: string) => Promise<boolean>;
10
11
  /** Free-text prompt. Absent → not a TTY, so nothing may be asked. */
@@ -20,6 +21,7 @@ export interface InitDeps {
20
21
  register?: (deps: Pick<RegisterDeps, "print" | "targetDir" | "dirLabel" | "ask">, opts: {
21
22
  name?: string;
22
23
  slug?: string;
24
+ templateVersion?: string;
23
25
  }) => Promise<number>;
24
26
  }
25
27
  /**
@@ -93,6 +93,10 @@ export async function initCommand(deps, opts = {}) {
93
93
  const scaffold = deps.scaffold ??
94
94
  ((dir, o) => scaffoldFromTemplate(dir, o));
95
95
  try {
96
+ // The result is deliberately discarded here. `init` writes no
97
+ // `.vincentt/project.json` at all on the bare path (that is the whole
98
+ // no-account wedge), and its only registering path is `--from`, whose
99
+ // baseline is refused below for naming a different repository.
96
100
  await scaffold(target, opts.from
97
101
  ? { repo: templateCloneURL(opts.from.owner, opts.from.repo), ref: opts.from.tag }
98
102
  : { ref: opts.template });
@@ -164,6 +168,12 @@ export async function initCommand(deps, opts = {}) {
164
168
  const register = deps.register;
165
169
  if (!register)
166
170
  return 0;
171
+ // NO baseline on the `--from` path, and that is not an oversight. A gallery
172
+ // source is a different repository, so a version tag in it names that repo's
173
+ // release and not a v2-template version — recording it would hand a later
174
+ // upgrade a baseline that resolves against the wrong remote entirely, which is
175
+ // worse than the no-baseline path it takes instead. `register` is unreachable
176
+ // unless `--from` was given, so this call never carries one.
167
177
  const code = await register({ print: deps.print, targetDir: target, dirLabel: label, ask: deps.ask }, { name: opts.name, slug: opts.slug });
168
178
  if (code !== 0)
169
179
  return code;
@@ -10,6 +10,7 @@
10
10
  // be wrong.
11
11
  import path from "node:path";
12
12
  import { SlugTakenError } from "./backend.js";
13
+ import { EXIT } from "./exitCodes.js";
13
14
  import { slugify } from "./slug.js";
14
15
  import { formatSourceRef } from "./sourceRef.js";
15
16
  /** How many times the address prompt re-asks before falling back to the printed block. */
@@ -70,7 +71,9 @@ export async function registerAfterScaffold(deps, opts = {}) {
70
71
  if (!token) {
71
72
  if (!deps.ask) {
72
73
  deps.print("");
73
- deps.print("✗ Not signed in, and signing in needs a browser.");
74
+ deps.print(scaffolded
75
+ ? "→ Not signed in, so no address is reserved yet."
76
+ : "✗ Not signed in, and signing in needs a browser.");
74
77
  deps.print("");
75
78
  if (scaffolded)
76
79
  reassure(deps.print, deps.dirLabel);
@@ -81,9 +84,16 @@ export async function registerAfterScaffold(deps, opts = {}) {
81
84
  deps.print("");
82
85
  deps.print(" Then finish:");
83
86
  resume(deps.print, deps.dirLabel, resumeCommand, needsCd);
87
+ if (scaffolded) {
88
+ deps.print("");
89
+ // B-F2-9(a): the open route must be named in something the creator's
90
+ // agent reads. On a non-TTY this output IS that artifact — the TTY
91
+ // branch's login prompt, which carries the same line, is unreachable here.
92
+ deps.print(" Or don't — the local dev loop needs no account.");
93
+ }
84
94
  deps.print("");
85
95
  deps.print(closing);
86
- return 1;
96
+ return scaffolded ? EXIT.DELIVERED_NOT_REGISTERED : EXIT.FAILED;
87
97
  }
88
98
  deps.print("");
89
99
  deps.print("→ Now registering the project so it gets an address.");
@@ -36,7 +36,11 @@ export interface CreateOptions {
36
36
  export interface CreateResult {
37
37
  /** The full transcript, exactly as the CLI prints it. */
38
38
  message: string;
39
- /** Non-zero when nothing was created (already linked, address taken, no org). */
39
+ /**
40
+ * Non-zero when the address was not reserved. Code 1 means nothing was created
41
+ * (already linked, address taken, no org); code 3 means the project source was
42
+ * written but registration was deferred for want of an account.
43
+ */
40
44
  exitCode: number;
41
45
  }
42
46
  /**
@@ -1,6 +1,7 @@
1
1
  import { type FolderCase } from "./classifyTarget.js";
2
2
  import { type OrgSource } from "./initFrom.js";
3
3
  import type { ScaffoldCall } from "./initCommand.js";
4
+ import { type ScaffoldOutcome } from "../scaffold/index.js";
4
5
  import type { Ask } from "../publish/prompt.js";
5
6
  import { type ResolvedConfig } from "../shared/config.js";
6
7
  export interface ProjectCommandDeps {
@@ -33,7 +34,7 @@ export interface CreateCommandDeps extends ProjectCommandDeps {
33
34
  mkdir?: (dir: string) => Promise<void>;
34
35
  /** Resolve a path through its symlinks, for the classify→write re-check. */
35
36
  realpath?: (dir: string) => Promise<string>;
36
- scaffold?: (dir: string, opts: ScaffoldCall) => Promise<void>;
37
+ scaffold?: (dir: string, opts: ScaffoldCall) => Promise<ScaffoldOutcome>;
37
38
  install?: (dir: string) => Promise<string>;
38
39
  detectPm?: (dir: string) => Promise<string>;
39
40
  /** Free-text prompt. Absent → not a TTY, so nothing may be asked. */
@@ -60,6 +61,12 @@ export interface CreateCommandOptions {
60
61
  * bounded re-ask). See the throw site.
61
62
  */
62
63
  rethrowSlugTaken?: boolean;
64
+ /**
65
+ * The concrete template version the scaffold above this call resolved, recorded
66
+ * into the binding as the drift baseline. Absent when nothing was scaffolded
67
+ * (registering a folder that already holds an app) or when resolution failed.
68
+ */
69
+ templateVersion?: string;
63
70
  }
64
71
  /**
65
72
  * `vincentt create` — a TWO-HALF verb with one seam.
@@ -8,6 +8,7 @@ import { promises as fs } from "node:fs";
8
8
  import path from "node:path";
9
9
  import { BackendError, SlugTakenError, createProject, getProject, } from "./backend.js";
10
10
  import { classifyTarget } from "./classifyTarget.js";
11
+ import { EXIT } from "./exitCodes.js";
11
12
  import { registerAfterScaffold } from "./initFrom.js";
12
13
  import { slugify } from "./slug.js";
13
14
  import { detectPackageManager, installDependencies, scaffoldFromTemplate, } from "../scaffold/index.js";
@@ -85,6 +86,10 @@ export async function createCommand(deps, opts = {}) {
85
86
  let installed = true;
86
87
  let pm = "npm";
87
88
  let actedOn = dirLabel;
89
+ // Case 2 (an app already on disk) leaves this absent: this command did not
90
+ // scaffold that tree, so it knows nothing about which template version it came
91
+ // from, and inventing one is the wrong-baseline failure.
92
+ let templateVersion;
88
93
  if (folderCase === "empty") {
89
94
  const wrote = await scaffoldInto(deps, target, dirLabel, opts);
90
95
  if (!wrote.ok)
@@ -92,6 +97,7 @@ export async function createCommand(deps, opts = {}) {
92
97
  scaffolded = true;
93
98
  installed = wrote.installed;
94
99
  pm = wrote.pm;
100
+ templateVersion = wrote.templateVersion;
95
101
  }
96
102
  else {
97
103
  // Case 2 writes too — a binding and a .gitignore line — so it runs the same
@@ -156,6 +162,7 @@ export async function createCommand(deps, opts = {}) {
156
162
  dir: opts.dir,
157
163
  name,
158
164
  slug,
165
+ templateVersion,
159
166
  rethrowSlugTaken: true,
160
167
  }),
161
168
  },
@@ -169,7 +176,10 @@ export async function createCommand(deps, opts = {}) {
169
176
  name: opts.name ?? path.basename(target),
170
177
  slug: opts.slug ?? slugify(opts.name ?? path.basename(target)),
171
178
  });
172
- if (code !== 0)
179
+ // The gate is DELIVERY, not zero. A run that wrote the source and deferred
180
+ // only the address still delivered a project, so it still prints how to run
181
+ // it. The endings that stay exit 1 delivered nothing and correctly skip this.
182
+ if (code !== 0 && code !== EXIT.DELIVERED_NOT_REGISTERED)
173
183
  return code;
174
184
  if (scaffolded) {
175
185
  // This reader has never run the dev server, so the block that fits is the one
@@ -187,7 +197,10 @@ export async function createCommand(deps, opts = {}) {
187
197
  for (const line of alignedSteps(steps))
188
198
  deps.print(line);
189
199
  }
190
- return 0;
200
+ // Printing the steps must not launder the ending: a delivered-not-registered
201
+ // run still reports 3, so a caller never reads success for an unregistered
202
+ // project.
203
+ return code;
191
204
  }
192
205
  /** Two columns, aligned on the widest command, so the block reads as a table. */
193
206
  function alignedSteps(steps) {
@@ -273,8 +286,9 @@ async function scaffoldInto(deps, target, dirLabel, opts) {
273
286
  // which it could — which is exactly what `--template`/`--from` being refused at
274
287
  // the parser makes structurally true, and what the install below rests on.
275
288
  const scaffold = deps.scaffold ?? ((dir, o) => scaffoldFromTemplate(dir, o));
289
+ let scaffoldResult;
276
290
  try {
277
- await scaffold(target, {});
291
+ scaffoldResult = await scaffold(target, {});
278
292
  }
279
293
  catch (err) {
280
294
  // No `✓ Created`, so no registration is attempted, and this arm carries
@@ -300,7 +314,7 @@ async function scaffoldInto(deps, target, dirLabel, opts) {
300
314
  const pm = await detect(target).catch(() => "npm");
301
315
  deps.print("");
302
316
  deps.print(`✓ Created ${dirLabel}`);
303
- return { ok: true, installed, pm };
317
+ return { ok: true, installed, pm, templateVersion: scaffoldResult?.templateVersion };
304
318
  }
305
319
  /**
306
320
  * The registration half — the binding guard's remainder, the slug derivation, the
@@ -348,6 +362,9 @@ export async function registerProject(deps, opts = {}) {
348
362
  await writeProjectBinding(target, {
349
363
  projectId: created.projectId,
350
364
  slug: created.slug,
365
+ // Omitted, never `latest`: absence is the honest no-baseline state, and it is
366
+ // the one that makes a later upgrade report rather than overwrite.
367
+ ...(opts.templateVersion ? { template: { version: opts.templateVersion } } : {}),
351
368
  });
352
369
  deps.print(`✓ Created ${created.name} in ${org.org.name}`);
353
370
  deps.print(` Address reserved: ${created.liveUrl}`);
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The packages the platform publishes, frozen.
3
+ *
4
+ * This list is what makes `B-F17-1` a structural property rather than a promise:
5
+ * the resolver ITERATES it and reads only the manifests it names, so a
6
+ * third-party package name is never in the CLI's memory — not read, not
7
+ * filtered, not dropped. "Filter the creator's dependency graph" produces the
8
+ * same wire payload today and leaks the whole graph the first time someone
9
+ * spreads the intermediate value into a log line or a report.
10
+ *
11
+ * ORDER IS PART OF THE CONTRACT. Every surface that renders a version set
12
+ * renders it in this order — the notice's rows, `outdated`'s sections, and the
13
+ * report's one-line summary — so two surfaces can never disagree about
14
+ * sequence, and the wire field's key order is stable for the byte-identity
15
+ * assertion across the publish and report payloads.
16
+ */
17
+ export interface PlatformPackage {
18
+ /** The package identity — the string in the creator's own dependency manifest. */
19
+ name: string;
20
+ /**
21
+ * The short name used only where a person reads three names in one pass (the
22
+ * payload preview's summary line). The WIRE always carries `name`: an agent
23
+ * handed `sdk` cannot find it in a `package.json`.
24
+ */
25
+ short: string;
26
+ }
27
+ export declare const PLATFORM_PACKAGES: readonly PlatformPackage[];
28
+ /**
29
+ * The template's short name. It is NOT a package: it has no `node_modules`
30
+ * entry, it was copied rather than installed, and its version comes from the
31
+ * project binding. It is named here so the surfaces that render it last are
32
+ * reading one constant rather than each spelling it.
33
+ */
34
+ export declare const TEMPLATE_COMPONENT = "template";
35
+ export declare function isPlatformPackage(name: string): boolean;
36
+ export declare function shortNameOf(name: string): string;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The packages the platform publishes, frozen.
3
+ *
4
+ * This list is what makes `B-F17-1` a structural property rather than a promise:
5
+ * the resolver ITERATES it and reads only the manifests it names, so a
6
+ * third-party package name is never in the CLI's memory — not read, not
7
+ * filtered, not dropped. "Filter the creator's dependency graph" produces the
8
+ * same wire payload today and leaks the whole graph the first time someone
9
+ * spreads the intermediate value into a log line or a report.
10
+ *
11
+ * ORDER IS PART OF THE CONTRACT. Every surface that renders a version set
12
+ * renders it in this order — the notice's rows, `outdated`'s sections, and the
13
+ * report's one-line summary — so two surfaces can never disagree about
14
+ * sequence, and the wire field's key order is stable for the byte-identity
15
+ * assertion across the publish and report payloads.
16
+ */
17
+ export const PLATFORM_PACKAGES = Object.freeze([
18
+ Object.freeze({ name: "@vincentt-xr/sdk", short: "sdk" }),
19
+ Object.freeze({ name: "@vincentt-xr/harness", short: "harness" }),
20
+ Object.freeze({ name: "@vincentt-xr/analytics", short: "analytics" }),
21
+ ]);
22
+ /**
23
+ * The template's short name. It is NOT a package: it has no `node_modules`
24
+ * entry, it was copied rather than installed, and its version comes from the
25
+ * project binding. It is named here so the surfaces that render it last are
26
+ * reading one constant rather than each spelling it.
27
+ */
28
+ export const TEMPLATE_COMPONENT = "template";
29
+ export function isPlatformPackage(name) {
30
+ return PLATFORM_PACKAGES.some((p) => p.name === name);
31
+ }
32
+ export function shortNameOf(name) {
33
+ return PLATFORM_PACKAGES.find((p) => p.name === name)?.short ?? name;
34
+ }
@@ -0,0 +1,41 @@
1
+ import type { PlatformRelease } from "./releaseCache.js";
2
+ export interface BehindRow {
3
+ /** The package identity — the string in the creator's own manifest. */
4
+ name: string;
5
+ installed: string;
6
+ current: string;
7
+ /** Whether the platform authored a note. NEVER the note itself. */
8
+ hasMigrationNote: boolean;
9
+ }
10
+ export interface BehindNotice {
11
+ /** The release identifier, stated because this is what the notice fires once per. */
12
+ release: string;
13
+ rows: BehindRow[];
14
+ /** How many rows carry an authored note. Counted, never quoted. */
15
+ noteCount: number;
16
+ }
17
+ export interface InstalledSet {
18
+ /** Keyed by package identity. */
19
+ packages: Record<string, string>;
20
+ /** The recorded template baseline. Absent = not recorded. */
21
+ template?: string;
22
+ }
23
+ /**
24
+ * Build the notice struct, or `undefined` when there is nothing to say.
25
+ *
26
+ * A component the composed set does not mention is NOT BEHIND and is not
27
+ * reported: on the day this ships every component is absent, which is neither
28
+ * behind nor up to date, and describing it would put a row of nothing in front
29
+ * of every creator. Absent is absent — never inferred, never rendered as a
30
+ * value, never dressed up as a state.
31
+ */
32
+ export declare function behindFor(installed: InstalledSet, current: PlatformRelease | undefined): BehindNotice | undefined;
33
+ /**
34
+ * Whether this machine has already announced this release.
35
+ *
36
+ * Equality on the identifier, and the announced value is READ FROM THE CACHE
37
+ * rather than derived. Time governs how often we ask; the fact governs whether
38
+ * we speak — so the second run today is silent, the fortieth is silent, and so
39
+ * is tomorrow's, until the identifier changes.
40
+ */
41
+ export declare function alreadyAnnounced(announcedRelease: string | undefined, release: string): boolean;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The comparison: this project's versions against what the platform publishes.
3
+ *
4
+ * THE OUTPUT TYPE CARRIES NO COMMAND STRING, NO URL, AND NO NOTE BODY. That is
5
+ * the primary guard on the unbidden notice and it is structural: an instruction
6
+ * is UNRENDERABLE rather than merely unwritten, because the renderer is never
7
+ * handed anything it could instruct with. A prose guard over the rendered lines
8
+ * is the second layer, not the first — a single-regex guard on exactly this
9
+ * class shipped once and six phrasings walked past it.
10
+ *
11
+ * `hasMigrationNote` is a BOOLEAN for the same reason. A count says "there are
12
+ * instructions somewhere" and leaves the reader to ask for them; the body would
13
+ * say "here are the instructions", which is the sentence an autonomous caller
14
+ * acts on.
15
+ *
16
+ * NOTHING BRANCHES ON A VERSION. Equality decides behind-or-current, and the
17
+ * release identifier is compared for equality only. There is no ordering
18
+ * comparison anywhere in this module: `YYYY.MM.N` is an identifier, and an
19
+ * installed version is evidence rather than authorization.
20
+ */
21
+ import { PLATFORM_PACKAGES, TEMPLATE_COMPONENT } from "./allowlist.js";
22
+ /**
23
+ * Build the notice struct, or `undefined` when there is nothing to say.
24
+ *
25
+ * A component the composed set does not mention is NOT BEHIND and is not
26
+ * reported: on the day this ships every component is absent, which is neither
27
+ * behind nor up to date, and describing it would put a row of nothing in front
28
+ * of every creator. Absent is absent — never inferred, never rendered as a
29
+ * value, never dressed up as a state.
30
+ */
31
+ export function behindFor(installed, current) {
32
+ if (current === undefined)
33
+ return undefined;
34
+ const rows = [];
35
+ // ALLOWLIST ORDER, not object order, so every surface renders the same
36
+ // sequence and two surfaces can never disagree.
37
+ for (const pkg of PLATFORM_PACKAGES) {
38
+ const have = installed.packages[pkg.name];
39
+ const component = current.components[pkg.name];
40
+ if (have === undefined || component === undefined)
41
+ continue;
42
+ if (have === component.version)
43
+ continue;
44
+ rows.push({
45
+ name: pkg.name,
46
+ installed: have,
47
+ current: component.version,
48
+ hasMigrationNote: component.migration !== undefined,
49
+ });
50
+ }
51
+ // The template last, and it is not a package: printed bare, which is also how
52
+ // it distinguishes itself as the one entry that is not a dependency.
53
+ const templateCurrent = current.components[TEMPLATE_COMPONENT];
54
+ if (installed.template !== undefined &&
55
+ templateCurrent !== undefined &&
56
+ installed.template !== templateCurrent.version) {
57
+ rows.push({
58
+ name: TEMPLATE_COMPONENT,
59
+ installed: installed.template,
60
+ current: templateCurrent.version,
61
+ hasMigrationNote: templateCurrent.migration !== undefined,
62
+ });
63
+ }
64
+ if (rows.length === 0)
65
+ return undefined;
66
+ return {
67
+ release: current.release,
68
+ rows,
69
+ noteCount: rows.filter((r) => r.hasMigrationNote).length,
70
+ };
71
+ }
72
+ /**
73
+ * Whether this machine has already announced this release.
74
+ *
75
+ * Equality on the identifier, and the announced value is READ FROM THE CACHE
76
+ * rather than derived. Time governs how often we ask; the fact governs whether
77
+ * we speak — so the second run today is silent, the fortieth is silent, and so
78
+ * is tomorrow's, until the identifier changes.
79
+ */
80
+ export function alreadyAnnounced(announcedRelease, release) {
81
+ return announcedRelease === release;
82
+ }
@@ -0,0 +1,16 @@
1
+ import type { InstalledSet } from "./behind.js";
2
+ export interface OutdatedDeps {
3
+ print: (line: string) => void;
4
+ cwd: string;
5
+ now: () => number;
6
+ /** The API origin, and the base of the address printed to the creator. */
7
+ apiUrl: () => Promise<string>;
8
+ /** `Name · Workspace` when this folder is a project; undefined outside one. */
9
+ projectLine: () => Promise<string | undefined>;
10
+ /** Whether this folder resolves a project binding. */
11
+ inProject: () => Promise<boolean>;
12
+ fetchImpl?: typeof fetch;
13
+ }
14
+ export declare function outdatedCommand(deps: OutdatedDeps): Promise<number>;
15
+ /** Nothing installed and no baseline → the flat "what is current" answer still prints. */
16
+ export declare function hasAnyInstalled(installed: InstalledSet): boolean;