@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,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
+ }
@@ -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;
@@ -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.
@@ -86,6 +86,10 @@ export async function createCommand(deps, opts = {}) {
86
86
  let installed = true;
87
87
  let pm = "npm";
88
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;
89
93
  if (folderCase === "empty") {
90
94
  const wrote = await scaffoldInto(deps, target, dirLabel, opts);
91
95
  if (!wrote.ok)
@@ -93,6 +97,7 @@ export async function createCommand(deps, opts = {}) {
93
97
  scaffolded = true;
94
98
  installed = wrote.installed;
95
99
  pm = wrote.pm;
100
+ templateVersion = wrote.templateVersion;
96
101
  }
97
102
  else {
98
103
  // Case 2 writes too — a binding and a .gitignore line — so it runs the same
@@ -157,6 +162,7 @@ export async function createCommand(deps, opts = {}) {
157
162
  dir: opts.dir,
158
163
  name,
159
164
  slug,
165
+ templateVersion,
160
166
  rethrowSlugTaken: true,
161
167
  }),
162
168
  },
@@ -280,8 +286,9 @@ async function scaffoldInto(deps, target, dirLabel, opts) {
280
286
  // which it could — which is exactly what `--template`/`--from` being refused at
281
287
  // the parser makes structurally true, and what the install below rests on.
282
288
  const scaffold = deps.scaffold ?? ((dir, o) => scaffoldFromTemplate(dir, o));
289
+ let scaffoldResult;
283
290
  try {
284
- await scaffold(target, {});
291
+ scaffoldResult = await scaffold(target, {});
285
292
  }
286
293
  catch (err) {
287
294
  // No `✓ Created`, so no registration is attempted, and this arm carries
@@ -307,7 +314,7 @@ async function scaffoldInto(deps, target, dirLabel, opts) {
307
314
  const pm = await detect(target).catch(() => "npm");
308
315
  deps.print("");
309
316
  deps.print(`✓ Created ${dirLabel}`);
310
- return { ok: true, installed, pm };
317
+ return { ok: true, installed, pm, templateVersion: scaffoldResult?.templateVersion };
311
318
  }
312
319
  /**
313
320
  * The registration half — the binding guard's remainder, the slug derivation, the
@@ -355,6 +362,9 @@ export async function registerProject(deps, opts = {}) {
355
362
  await writeProjectBinding(target, {
356
363
  projectId: created.projectId,
357
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 } } : {}),
358
368
  });
359
369
  deps.print(`✓ Created ${created.name} in ${org.org.name}`);
360
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;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * `vincentt outdated`'s I/O half: resolve what this folder has, read or fetch
3
+ * what the platform publishes, and hand both to the renderer.
4
+ *
5
+ * This is the THIRD off-switch site. With `VINCENTT_PLATFORM_CHECK=0` it
6
+ * performs no fetch AND no cache read — off means not fetched, not read, and not
7
+ * written. The refresh is the first site and the notice's call site is the
8
+ * second; f7 shipped its equivalent gate at two of three and its unbidden read
9
+ * path was missed, which is why the third is named explicitly rather than
10
+ * assumed to follow.
11
+ *
12
+ * Unlike the notice, this verb MAY await the network: it was asked for, and a
13
+ * command whose whole subject is "am I behind" cannot answer from a cold cache
14
+ * by printing nothing.
15
+ */
16
+ import { PLATFORM_PACKAGES } from "./allowlist.js";
17
+ import { resolveBuiltWith } from "./resolve.js";
18
+ import { isReleaseFresh, loadReleaseCache, saveReleaseCache, } from "./releaseCache.js";
19
+ import { fetchRelease } from "./releaseFetch.js";
20
+ import { renderOutdated, releasedAgo } from "./outdated.js";
21
+ import { platformCheckEnabled } from "./offSwitch.js";
22
+ export async function outdatedCommand(deps) {
23
+ const apiUrl = await deps.apiUrl();
24
+ const inProject = await deps.inProject();
25
+ const builtWith = await resolveBuiltWith(deps.cwd);
26
+ const installed = {
27
+ packages: builtWith?.packages ?? {},
28
+ template: builtWith?.template,
29
+ };
30
+ // Read the switch HERE as well as inside currentRelease, so the view can say
31
+ // WHY there is nothing to compare. "Could not reach" is wrong when the person
32
+ // switched the check off, and the retry advice is advice for a problem they do
33
+ // not have.
34
+ const switchedOff = !platformCheckEnabled();
35
+ const current = await currentRelease(deps, apiUrl);
36
+ const view = {
37
+ projectLine: inProject ? await deps.projectLine() : undefined,
38
+ installed,
39
+ current,
40
+ absentBecause: current !== undefined ? undefined : switchedOff ? "switched_off" : "unreachable",
41
+ releasedAgo: releasedAgo(current?.releasedAt, deps.now()),
42
+ outsideProject: !inProject,
43
+ endpointUrl: `${apiUrl.replace(/\/$/, "")}/platform/versions`,
44
+ };
45
+ for (const line of renderOutdated(view))
46
+ deps.print(line);
47
+ // Exit 0 on every path INCLUDING the unreachable one: nothing the creator
48
+ // asked for failed, and a listing verb that exits non-zero because the network
49
+ // was down teaches an agent to retry a read that will not change.
50
+ return 0;
51
+ }
52
+ /**
53
+ * The current set: a fresh cache, else a fetch, else the stale cache, else
54
+ * nothing.
55
+ *
56
+ * A STALE cache is still used when the fetch fails, because an older true
57
+ * statement beats no statement on a surface that was asked for — and the
58
+ * alternative is telling a creator on a train that nothing is known.
59
+ */
60
+ async function currentRelease(deps, apiUrl) {
61
+ // OFF-SWITCH SITE 3, before any read.
62
+ if (!platformCheckEnabled())
63
+ return undefined;
64
+ const cached = loadReleaseCache();
65
+ if (isReleaseFresh(cached, deps.now()))
66
+ return cached?.release;
67
+ const fetched = await fetchRelease({
68
+ apiUrl,
69
+ etag: cached?.etag,
70
+ fetchImpl: deps.fetchImpl,
71
+ });
72
+ if (fetched !== undefined) {
73
+ saveReleaseCache({
74
+ release: fetched.release,
75
+ at: deps.now(),
76
+ // The announcement record is PRESERVED across a refresh: this verb is
77
+ // solicited and says nothing about whether the unbidden notice has
78
+ // spoken, so overwriting it here would silently re-arm the notice.
79
+ announcedRelease: cached?.announcedRelease,
80
+ etag: fetched.etag,
81
+ });
82
+ return fetched.release;
83
+ }
84
+ return cached?.release;
85
+ }
86
+ /** Nothing installed and no baseline → the flat "what is current" answer still prints. */
87
+ export function hasAnyInstalled(installed) {
88
+ return (PLATFORM_PACKAGES.some((p) => installed.packages[p.name] !== undefined) ||
89
+ installed.template !== undefined);
90
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The unbidden notice's call site, and the `unref`'d refresh behind it.
3
+ *
4
+ * DISCOVERY RIDES ON CREATOR-INITIATED COMMANDS ONLY. There is no timer, no
5
+ * daemon, no background poll, and no scheduled check anywhere in this feature —
6
+ * a "just check daily" implementation is the most likely way to break that, and
7
+ * it would be a transmission the creator did not initiate. What is time-bounded
8
+ * is how often we ASK; whether we SPEAK is governed by the fact changing.
9
+ *
10
+ * The line printed always comes from the PREVIOUS run's cache and the fetch is
11
+ * never awaited, so no verb pays for this. The consequence is accepted: a fresh
12
+ * install is quiet on its first run, and a creator who just installed is current
13
+ * by definition.
14
+ *
15
+ * OFF-SWITCH SITE 2 is `behindFooterLines`, and the gate is BEFORE
16
+ * `loadReleaseCache()` rather than inside it. A gate placed after the load still
17
+ * reads the file, and the requirement is that off means not read.
18
+ */
19
+ import { type InstalledSet } from "./behind.js";
20
+ import { type ReleaseCache } from "./releaseCache.js";
21
+ export interface FooterDeps {
22
+ installed: InstalledSet;
23
+ now: () => number;
24
+ /** Injected so the notice's decision is testable without a home directory. */
25
+ loadCache?: () => ReleaseCache | undefined;
26
+ saveCache?: (cache: ReleaseCache) => void;
27
+ }
28
+ /**
29
+ * The notice's lines, and the announcement recorded when it fires.
30
+ *
31
+ * Firing WRITES `announcedRelease`, which is what closes the nag by mechanism
32
+ * rather than by tone: the second run on the same release finds them equal and
33
+ * the renderer is never called. The silence after the first announcement is
34
+ * byte-identical to the silence of a caller with nothing to say, so the line
35
+ * cannot degrade into a quieter nag.
36
+ */
37
+ export declare function behindFooterLines(deps: FooterDeps): string[];
38
+ /** Whether the cache is due a refresh. Separate so the caller can gate first. */
39
+ export declare function needsRefresh(cache: ReleaseCache | undefined, now: number): boolean;
40
+ /**
41
+ * Refresh the cached release without holding the process open.
42
+ *
43
+ * `unref` is the load-bearing call, and it is the reason `preview`'s
44
+ * time-to-URL does not move: without it a 2s request keeps the event loop alive
45
+ * after the verb is done, so every command appears to hang — and the delay would
46
+ * land on `preview`, where someone is waiting to open a URL on a phone.
47
+ *
48
+ * OFF-SWITCH SITE 1 is `fetchRelease` itself, plus the gate here so no timer is
49
+ * even created.
50
+ */
51
+ export declare function refreshReleaseCache(opts: {
52
+ apiUrl: string;
53
+ now: () => number;
54
+ /**
55
+ * Test seam. When given, the awaitable `fetch`-based reader is used so a case
56
+ * can inject a hanging or failing implementation; production takes the
57
+ * detached path, whose whole purpose is not being awaitable.
58
+ */
59
+ fetchImpl?: typeof fetch;
60
+ }): void;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The unbidden notice's call site, and the `unref`'d refresh behind it.
3
+ *
4
+ * DISCOVERY RIDES ON CREATOR-INITIATED COMMANDS ONLY. There is no timer, no
5
+ * daemon, no background poll, and no scheduled check anywhere in this feature —
6
+ * a "just check daily" implementation is the most likely way to break that, and
7
+ * it would be a transmission the creator did not initiate. What is time-bounded
8
+ * is how often we ASK; whether we SPEAK is governed by the fact changing.
9
+ *
10
+ * The line printed always comes from the PREVIOUS run's cache and the fetch is
11
+ * never awaited, so no verb pays for this. The consequence is accepted: a fresh
12
+ * install is quiet on its first run, and a creator who just installed is current
13
+ * by definition.
14
+ *
15
+ * OFF-SWITCH SITE 2 is `behindFooterLines`, and the gate is BEFORE
16
+ * `loadReleaseCache()` rather than inside it. A gate placed after the load still
17
+ * reads the file, and the requirement is that off means not read.
18
+ */
19
+ import { alreadyAnnounced, behindFor } from "./behind.js";
20
+ import { renderBehindNotice } from "./notice.js";
21
+ import { FETCH_TIMEOUT_MS } from "../dx/updateCheck.js";
22
+ import { isReleaseFresh, loadReleaseCache, saveReleaseCache, } from "./releaseCache.js";
23
+ import { fetchRelease, fetchReleaseDetached } from "./releaseFetch.js";
24
+ import { platformCheckEnabled } from "./offSwitch.js";
25
+ /**
26
+ * The notice's lines, and the announcement recorded when it fires.
27
+ *
28
+ * Firing WRITES `announcedRelease`, which is what closes the nag by mechanism
29
+ * rather than by tone: the second run on the same release finds them equal and
30
+ * the renderer is never called. The silence after the first announcement is
31
+ * byte-identical to the silence of a caller with nothing to say, so the line
32
+ * cannot degrade into a quieter nag.
33
+ */
34
+ export function behindFooterLines(deps) {
35
+ // OFF-SWITCH SITE 2 — before the loader is invoked at all.
36
+ if (!platformCheckEnabled())
37
+ return [];
38
+ const load = deps.loadCache ?? loadReleaseCache;
39
+ const cached = load();
40
+ if (cached === undefined)
41
+ return [];
42
+ const notice = behindFor(deps.installed, cached.release);
43
+ if (notice === undefined)
44
+ return [];
45
+ if (alreadyAnnounced(cached.announcedRelease, notice.release))
46
+ return [];
47
+ const lines = renderBehindNotice(notice);
48
+ if (lines.length === 0)
49
+ return [];
50
+ const save = deps.saveCache ?? saveReleaseCache;
51
+ save({ ...cached, announcedRelease: notice.release });
52
+ return lines;
53
+ }
54
+ /** Whether the cache is due a refresh. Separate so the caller can gate first. */
55
+ export function needsRefresh(cache, now) {
56
+ return !isReleaseFresh(cache, now);
57
+ }
58
+ /**
59
+ * Refresh the cached release without holding the process open.
60
+ *
61
+ * `unref` is the load-bearing call, and it is the reason `preview`'s
62
+ * time-to-URL does not move: without it a 2s request keeps the event loop alive
63
+ * after the verb is done, so every command appears to hang — and the delay would
64
+ * land on `preview`, where someone is waiting to open a URL on a phone.
65
+ *
66
+ * OFF-SWITCH SITE 1 is `fetchRelease` itself, plus the gate here so no timer is
67
+ * even created.
68
+ */
69
+ export function refreshReleaseCache(opts) {
70
+ if (!platformCheckEnabled())
71
+ return;
72
+ const previous = loadReleaseCache();
73
+ const store = (result) => {
74
+ saveReleaseCache({
75
+ release: result.release,
76
+ at: opts.now(),
77
+ // PRESERVED across a refresh. Dropping it would re-announce a release
78
+ // this machine has already spoken about, which is the nag the stored
79
+ // value exists to prevent.
80
+ announcedRelease: previous?.announcedRelease,
81
+ etag: result.etag,
82
+ });
83
+ };
84
+ if (opts.fetchImpl !== undefined) {
85
+ const timer = setTimeout(() => { }, FETCH_TIMEOUT_MS + 500);
86
+ timer.unref?.();
87
+ void fetchRelease({
88
+ apiUrl: opts.apiUrl,
89
+ etag: previous?.etag,
90
+ fetchImpl: opts.fetchImpl,
91
+ })
92
+ .then((result) => {
93
+ if (result !== undefined)
94
+ store(result);
95
+ })
96
+ .catch(() => { })
97
+ .finally(() => clearTimeout(timer));
98
+ return;
99
+ }
100
+ fetchReleaseDetached({ apiUrl: opts.apiUrl, etag: previous?.etag }, store);
101
+ }