@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,29 @@
1
+ /**
2
+ * The unbidden behind-notice: one fact, printed after a run the creator asked
3
+ * for, on stderr, into a stream that is a coding agent's input.
4
+ *
5
+ * STATES A FACT, NEVER AN INSTRUCTION — the same rule the dx nudge and the CLI
6
+ * update notice already carry, for the same reason, and here with a stronger
7
+ * mechanism: the input struct carries no command string, no URL and no note
8
+ * body, so there is nothing to instruct WITH. An agent reading an imperative
9
+ * here would perform an upgrade nobody asked for, in a creator's repository, in
10
+ * the middle of a preview session.
11
+ *
12
+ * NO URL on any line. A URL in unbidden output is an instruction wearing a
13
+ * fact's clothes; the endpoint's address is printed by `outdated`, which was
14
+ * asked for.
15
+ *
16
+ * Notes are COUNTED, never quoted. The struct holds a boolean per row, so the
17
+ * body cannot appear.
18
+ */
19
+ import type { BehindNotice } from "./behind.js";
20
+ /**
21
+ * The notice's lines, or `[]` when there is nothing to say.
22
+ *
23
+ * The shape is the post-run footer's, borrowed rather than invented: a blank
24
+ * line, ` ! ` and the fact, then indented continuation lines. `!` and not `✓`
25
+ * or `✗` — something the creator should know, and the command proceeded anyway.
26
+ * It sits below the success line because it is a footnote about the world rather
27
+ * than a finding about the run.
28
+ */
29
+ export declare function renderBehindNotice(notice: BehindNotice | undefined): string[];
@@ -0,0 +1,66 @@
1
+ const COUNT_WORDS = [
2
+ "",
3
+ "One",
4
+ "Two",
5
+ "Three",
6
+ "Four",
7
+ "Five",
8
+ "Six",
9
+ "Seven",
10
+ "Eight",
11
+ "Nine",
12
+ "Ten",
13
+ ];
14
+ /**
15
+ * The notice's lines, or `[]` when there is nothing to say.
16
+ *
17
+ * The shape is the post-run footer's, borrowed rather than invented: a blank
18
+ * line, ` ! ` and the fact, then indented continuation lines. `!` and not `✓`
19
+ * or `✗` — something the creator should know, and the command proceeded anyway.
20
+ * It sits below the success line because it is a footnote about the world rather
21
+ * than a finding about the run.
22
+ */
23
+ export function renderBehindNotice(notice) {
24
+ if (notice === undefined || notice.rows.length === 0)
25
+ return [];
26
+ // The comparison is STACKED and column-aligned, never inline: two values a
27
+ // creator has to judge go on their own aligned lines. Padding is computed
28
+ // from the actual names and versions so a long package identity cannot push
29
+ // the `current` column out of alignment on one row only.
30
+ const nameWidth = Math.max(...notice.rows.map((r) => r.name.length));
31
+ const installedWidth = Math.max(...notice.rows.map((r) => r.installed.length));
32
+ const rows = notice.rows.map((r) => {
33
+ const name = r.name.padEnd(nameWidth);
34
+ const installed = r.installed.padEnd(installedWidth);
35
+ return ` ${name} ${installed} · current ${r.current}`;
36
+ });
37
+ return [
38
+ "",
39
+ ` ! This project is behind the current Vincentt release (${notice.release}).`,
40
+ ...rows,
41
+ ` ${noteLine(notice)}`,
42
+ ];
43
+ }
44
+ /**
45
+ * The note line. Singular collapses to its own sentence rather than rendering
46
+ * "1 of 1 carries a migration note", which is a template showing through.
47
+ *
48
+ * "No migration note is attached to this one." is the rendered form of an
49
+ * AUTHORED "no action required" and never of an unauthored one — the platform's
50
+ * write path refuses to publish a moved version carrying no note, so this line
51
+ * always means someone decided nothing is needed.
52
+ */
53
+ function noteLine(notice) {
54
+ const { noteCount, rows } = notice;
55
+ if (rows.length === 1) {
56
+ return noteCount === 1
57
+ ? "This one carries a migration note."
58
+ : "No migration note is attached to this one.";
59
+ }
60
+ if (noteCount === 0)
61
+ return "None of these carries a migration note.";
62
+ return `${countWord(noteCount)} of these carry a migration note.`;
63
+ }
64
+ function countWord(n) {
65
+ return COUNT_WORDS[n] ?? String(n);
66
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `VINCENTT_PLATFORM_CHECK=0` — off means NOT FETCHED, NOT READ, and NOT
3
+ * WRITTEN.
4
+ *
5
+ * The predicate lives in one module and is consulted at THREE sites, all
6
+ * agreeing: the refresh, the notice's call site BEFORE the cache loader is
7
+ * invoked, and `outdated`'s read. Three, not two, and the third is named
8
+ * because f7 shipped its equivalent gate at two of three sites and its own
9
+ * unbidden read path was missed — that case is still failing on purpose.
10
+ *
11
+ * A gate placed AFTER the load still reads the file, which is why the notice
12
+ * gates at its call site rather than relying on the loader's own check: "off"
13
+ * that still opens the file is a promise the filesystem can contradict.
14
+ *
15
+ * Read from `process.env` on every call rather than memoized at import: a
16
+ * memo captures whatever the environment was when the module first loaded,
17
+ * which in a test process is whatever an earlier test left behind.
18
+ */
19
+ export declare function platformCheckEnabled(): boolean;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `VINCENTT_PLATFORM_CHECK=0` — off means NOT FETCHED, NOT READ, and NOT
3
+ * WRITTEN.
4
+ *
5
+ * The predicate lives in one module and is consulted at THREE sites, all
6
+ * agreeing: the refresh, the notice's call site BEFORE the cache loader is
7
+ * invoked, and `outdated`'s read. Three, not two, and the third is named
8
+ * because f7 shipped its equivalent gate at two of three sites and its own
9
+ * unbidden read path was missed — that case is still failing on purpose.
10
+ *
11
+ * A gate placed AFTER the load still reads the file, which is why the notice
12
+ * gates at its call site rather than relying on the loader's own check: "off"
13
+ * that still opens the file is a promise the filesystem can contradict.
14
+ *
15
+ * Read from `process.env` on every call rather than memoized at import: a
16
+ * memo captures whatever the environment was when the module first loaded,
17
+ * which in a test process is whatever an earlier test left behind.
18
+ */
19
+ export function platformCheckEnabled() {
20
+ return process.env.VINCENTT_PLATFORM_CHECK !== "0";
21
+ }
@@ -0,0 +1,25 @@
1
+ import type { PlatformRelease } from "./releaseCache.js";
2
+ import type { InstalledSet } from "./behind.js";
3
+ export interface OutdatedView {
4
+ /** `Name · Workspace`, when this folder is a project. */
5
+ projectLine?: string;
6
+ installed: InstalledSet;
7
+ /** Undefined = there is no current set to compare against; `absentBecause` says why. */
8
+ current?: PlatformRelease;
9
+ /**
10
+ * Why `current` is absent. Two DIFFERENT facts, and one state for both was a
11
+ * defect: with the check switched off the platform was never contacted, so
12
+ * "could not reach" is wrong about the cause and "try again when you have a
13
+ * connection" is advice for a problem the person does not have.
14
+ */
15
+ absentBecause?: "unreachable" | "switched_off";
16
+ /** Human phrase for the release's age, e.g. `3 days ago`. */
17
+ releasedAgo?: string;
18
+ /** True when this folder holds no Vincentt project. */
19
+ outsideProject: boolean;
20
+ /** The endpoint's address, printed labelled public. */
21
+ endpointUrl: string;
22
+ }
23
+ export declare function renderOutdated(view: OutdatedView): string[];
24
+ /** A rough human phrase for a release's age. Never a "days behind" count. */
25
+ export declare function releasedAgo(releasedAt: string | undefined, now: number): string | undefined;
@@ -0,0 +1,246 @@
1
+ /**
2
+ * `vincentt outdated` — the whole neutral door.
3
+ *
4
+ * NOT `update` and NOT `upgrade`. A verb named after the act implies the CLI
5
+ * performs it, which it must not, and an agent reading `vincentt update` in any
6
+ * text will run it. Never name a verb after the act the platform must not
7
+ * perform.
8
+ *
9
+ * SOLICITED output, which is what separates this file from `notice.ts`. It may
10
+ * instruct, it prints the endpoint's address, and it prints a migration note IN
11
+ * FULL — a version number tells an agent something changed and only the note
12
+ * tells it WHAT, which is the load-bearing half of the whole feature.
13
+ *
14
+ * It carries the no-plugin population: a creator with no plugin and no agent
15
+ * reaches every fact this feature produces through this one command. It is also
16
+ * a security control — it ships named in every scaffolded project's agent
17
+ * contract and in `--help`, so reaching for it is ordinary population behavior
18
+ * rather than a signal that a plugin is installed. The copy has to earn that,
19
+ * which means answering "what is my situation" COMPLETELY rather than reading as
20
+ * step one of a procedure.
21
+ *
22
+ * The vocabulary is fixed and deliberate: "behind" and never "outdated",
23
+ * "stale", or "out of date" in the COPY (the verb is `outdated` because a
24
+ * creator types the word they know — two audiences, one deliberate split);
25
+ * "installed" and never "declared"; "this project came from" for the template,
26
+ * because it was copied rather than installed; and the word `zone` appears
27
+ * nowhere, on any surface.
28
+ */
29
+ import { PLATFORM_PACKAGES, TEMPLATE_COMPONENT } from "./allowlist.js";
30
+ /**
31
+ * The template's pinning convention, carried here because `place` must print the
32
+ * string as it appears in the manifest and the two differ ON PURPOSE: the SDK is
33
+ * pinned EXACTLY and the harness carries a CARET. One renderer serves both the
34
+ * plugin path and the bare-shell path, so neither invents a range form.
35
+ */
36
+ const CARET_PINNED = new Set(["@vincentt-xr/harness", "@vincentt-xr/analytics"]);
37
+ export function renderOutdated(view) {
38
+ const lines = [""];
39
+ if (view.projectLine !== undefined)
40
+ lines.push(` ${view.projectLine}`);
41
+ // The platform unreachable with a cold cache. This is THE ONE PLACE a fetch
42
+ // failure is spoken about, and only because it was asked for: the unbidden
43
+ // notice is silent on failure forever, but a command whose entire subject is
44
+ // "am I behind" cannot silently print nothing.
45
+ if (view.current === undefined && view.absentBecause === "switched_off") {
46
+ // Stating the cause, not diagnosing a fault. The person set this; the only
47
+ // useful facts are that nothing was checked and what their own versions are.
48
+ lines.push("", " · The version check is switched off, so nothing was compared.", "", " installed on this machine:", ...installedFlat(view.installed, 6), "", " These are your own versions, read from this folder.", "", " What is current is readable in a browser, from any machine:", ` ${view.endpointUrl}`);
49
+ return lines;
50
+ }
51
+ if (view.current === undefined) {
52
+ lines.push("", " ✗ Could not reach the platform to find out what is current.", "", " installed on this machine:", ...installedFlat(view.installed, 6), "", " Nothing was compared. These are your own versions, read from this folder.", " Run `vincentt outdated` again when you have a connection.", "", " This is also readable in a browser, from any machine:", ` ${view.endpointUrl}`);
53
+ return lines;
54
+ }
55
+ lines.push(` Current Vincentt release: ${view.current.release}${view.releasedAgo === undefined ? "" : ` (released ${view.releasedAgo})`}`);
56
+ // Outside a project the command STILL ANSWERS: "what is current" is a question
57
+ // whose answer needs no project, and refusing here would make the neutral door
58
+ // narrower than the endpoint it fronts. No BEHIND/UP TO DATE split, because
59
+ // there is nothing to be behind — and NO GLYPH, because an absence the command
60
+ // was not asked to fix is not an advisory.
61
+ if (view.outsideProject) {
62
+ lines.push("", ...currentFlat(view.current, 4), "");
63
+ lines.push(" This folder holds no Vincentt project, so there is nothing to compare against.", " The versions above are what the platform currently publishes.");
64
+ lines.push("", ...whereFrom(view.endpointUrl));
65
+ return lines;
66
+ }
67
+ const behind = [];
68
+ const upToDate = [];
69
+ for (const pkg of PLATFORM_PACKAGES) {
70
+ const have = view.installed.packages[pkg.name];
71
+ if (have === undefined)
72
+ continue;
73
+ const component = view.current.components[pkg.name];
74
+ // A component the composed set does not mention is neither behind nor up to
75
+ // date: nothing is known about it, so it is omitted rather than described.
76
+ if (component === undefined)
77
+ continue;
78
+ if (component.version === have)
79
+ upToDate.push({ name: pkg.name, version: have });
80
+ else
81
+ behind.push({ name: pkg.name, installed: have, component });
82
+ }
83
+ // BEHIND is first, because it answers the question that was asked. The heading
84
+ // is ABSENT rather than empty when nothing is behind — the absence IS the
85
+ // answer, and there is no "Everything is current!" line.
86
+ if (behind.length > 0) {
87
+ lines.push("", " BEHIND");
88
+ for (const row of behind) {
89
+ lines.push("", ` ${row.name}`, ` installed ${row.installed}`, ` current ${row.component.version}`, ` place ${placeString(row.name, row.component.version)}`, ...noteLines(row.component));
90
+ }
91
+ }
92
+ // UP TO DATE is PRINTED, not omitted: it is the difference between "the
93
+ // platform checked and this is current" and "the platform did not look", and
94
+ // it is what makes the answer complete rather than a list of problems.
95
+ if (upToDate.length > 0) {
96
+ const width = Math.max(...upToDate.map((r) => r.name.length));
97
+ lines.push("", " UP TO DATE", "");
98
+ for (const row of upToDate) {
99
+ lines.push(` ${row.name.padEnd(width)} ${row.version}`);
100
+ }
101
+ }
102
+ lines.push("", ...templateBlock(view));
103
+ lines.push("", ...whereFrom(view.endpointUrl));
104
+ if (behind.length > 0) {
105
+ // The closing line names the ACT and both actors, and no command. It prints
106
+ // no install command — the creator's package manager is theirs and we do not
107
+ // know which — and it does not print `vincentt update`, because no such verb
108
+ // exists and must not appear to.
109
+ lines.push("", " Editing package.json and installing is the whole dependency upgrade. Ask your", " agent, or do it by hand — the strings to place are above.");
110
+ }
111
+ return lines;
112
+ }
113
+ /** The exact string to put in the manifest, quoted as it appears there. */
114
+ function placeString(name, version) {
115
+ const range = CARET_PINNED.has(name) ? `^${version}` : version;
116
+ return `"${name}": "${range}"`;
117
+ }
118
+ /**
119
+ * The note, in full, in the author's words, indented under the component it
120
+ * belongs to — never in a trailing NOTES section, so the agent editing the SDK
121
+ * line reads the SDK's note without holding a cross-reference.
122
+ *
123
+ * An AUTHORED "no action required" prints as prose in the SAME SLOT. Not
124
+ * omitted, not `—`, not `(none)`: an agent cannot tell blank from forgotten, so
125
+ * the platform says which one it is, every time, in the same place.
126
+ */
127
+ function noteLines(component) {
128
+ const note = component.migration;
129
+ if (note === undefined)
130
+ return [];
131
+ const out = [];
132
+ if (note.summary !== "")
133
+ out.push(...wrap(note.summary, 6));
134
+ for (const n of note.notes)
135
+ out.push(...wrap(n, 6));
136
+ return out;
137
+ }
138
+ /**
139
+ * The template block. It never says `zone`, names three examples from the
140
+ * creator's own file tree, and claims NOTHING about `Scene.tsx` except that it
141
+ * is theirs — no drift count, no diff, no "3 files changed", because this
142
+ * command does not read the creator's source and the copy must not imply it did.
143
+ */
144
+ function templateBlock(view) {
145
+ const current = view.current?.components[TEMPLATE_COMPONENT];
146
+ const lines = [" TEMPLATE", ""];
147
+ const from = view.installed.template;
148
+ if (from === undefined) {
149
+ // "not recorded", printed AS A VALUE. Not blank, not `unknown` (a
150
+ // valid-looking version string), not `latest` (a moving pointer that can be
151
+ // diffed against nothing). And it names BOTH candidate causes, guessing at
152
+ // neither — no fingerprinting, no "probably 1.4.x".
153
+ lines.push(" this project came from not recorded");
154
+ if (current !== undefined)
155
+ lines.push(` current ${current.version}`);
156
+ lines.push(" This project has no recorded template version, so template drift cannot be", " stated. Projects created before this was recorded, and projects whose", " .vincentt/project.json has been replaced, read this way.");
157
+ return lines;
158
+ }
159
+ lines.push(` this project came from ${from}`);
160
+ if (current === undefined) {
161
+ lines.push(" The platform has not published a current template version, so there is", " nothing to compare this against.");
162
+ return lines;
163
+ }
164
+ // Both numbers print EVEN WHEN THEY MATCH: "came from 1.5.0, current 1.5.0" is
165
+ // a stronger statement than silence and costs two lines.
166
+ lines.push(` current ${current.version}`);
167
+ if (from !== current.version) {
168
+ lines.push(" The template is source that was copied into this repository when the project", " was created. Some of it is platform plumbing you never wrote — the app shell,", " the helpers, the build config. `src/Scene.tsx` and everything you added is", " yours, and nothing reports on it but this line.");
169
+ }
170
+ return lines;
171
+ }
172
+ /** The endpoint's address, labelled public — the line that makes the fallback real. */
173
+ function whereFrom(url) {
174
+ return [" Where these facts come from:", ` ${url} (public, no account needed)`];
175
+ }
176
+ function installedFlat(set, indent) {
177
+ const rows = [];
178
+ for (const pkg of PLATFORM_PACKAGES) {
179
+ const v = set.packages[pkg.name];
180
+ if (v !== undefined)
181
+ rows.push([pkg.name, v]);
182
+ }
183
+ if (set.template !== undefined) {
184
+ rows.push([TEMPLATE_COMPONENT, `${set.template} (recorded when this project was created)`]);
185
+ }
186
+ if (rows.length === 0)
187
+ return [];
188
+ const width = Math.max(...rows.map(([n]) => n.length));
189
+ const pad = " ".repeat(indent);
190
+ return rows.map(([n, v]) => `${pad}${n.padEnd(width)} ${v}`);
191
+ }
192
+ function currentFlat(release, indent) {
193
+ const rows = [];
194
+ for (const pkg of PLATFORM_PACKAGES) {
195
+ const c = release.components[pkg.name];
196
+ if (c !== undefined)
197
+ rows.push([pkg.name, c.version]);
198
+ }
199
+ const template = release.components[TEMPLATE_COMPONENT];
200
+ if (template !== undefined)
201
+ rows.push([TEMPLATE_COMPONENT, template.version]);
202
+ if (rows.length === 0)
203
+ return [];
204
+ const width = Math.max(...rows.map(([n]) => n.length));
205
+ const pad = " ".repeat(indent);
206
+ return rows.map(([n, v]) => `${pad}${n.padEnd(width)} ${v}`);
207
+ }
208
+ /** Wrap prose to a terminal-friendly width at the given indent. */
209
+ function wrap(text, indent) {
210
+ const width = 78 - indent;
211
+ const pad = " ".repeat(indent);
212
+ const words = text.split(/\s+/).filter((w) => w !== "");
213
+ const lines = [];
214
+ let current = "";
215
+ for (const word of words) {
216
+ if (current === "")
217
+ current = word;
218
+ else if (`${current} ${word}`.length <= width)
219
+ current += ` ${word}`;
220
+ else {
221
+ lines.push(`${pad}${current}`);
222
+ current = word;
223
+ }
224
+ }
225
+ if (current !== "")
226
+ lines.push(`${pad}${current}`);
227
+ return lines;
228
+ }
229
+ /** A rough human phrase for a release's age. Never a "days behind" count. */
230
+ export function releasedAgo(releasedAt, now) {
231
+ if (releasedAt === undefined)
232
+ return undefined;
233
+ const at = Date.parse(releasedAt);
234
+ if (!Number.isFinite(at))
235
+ return undefined;
236
+ const ms = now - at;
237
+ if (ms < 0)
238
+ return undefined;
239
+ const hours = Math.floor(ms / 3_600_000);
240
+ if (hours < 1)
241
+ return "less than an hour ago";
242
+ if (hours < 24)
243
+ return `${hours} ${hours === 1 ? "hour" : "hours"} ago`;
244
+ const days = Math.floor(hours / 24);
245
+ return `${days} ${days === 1 ? "day" : "days"} ago`;
246
+ }
@@ -0,0 +1,69 @@
1
+ /** One component's current version, as the platform publishes it. */
2
+ export interface ReleaseComponent {
3
+ version: string;
4
+ /**
5
+ * ABSENT means unauthored — a different fact from `{required: false}`, which
6
+ * is the AUTHORED "no action required". Collapsing them silently defeats the
7
+ * argument that a note is accurate or absent and never speculative: an agent
8
+ * cannot tell blank from forgotten, so the platform must say which one it is.
9
+ */
10
+ migration?: ReleaseMigration;
11
+ }
12
+ export interface ReleaseMigration {
13
+ required: boolean;
14
+ summary: string;
15
+ notes: string[];
16
+ }
17
+ export interface PlatformRelease {
18
+ /**
19
+ * `"2026.09.3"` — an IDENTIFIER, never semver, never version-compared. It is
20
+ * compared for EQUALITY only, which is what makes the notice fire once per
21
+ * changed release rather than once per interval.
22
+ */
23
+ release: string;
24
+ releasedAt?: string;
25
+ /** Keyed by package identity (`@vincentt-xr/sdk`), never repo shorthand. */
26
+ components: Record<string, ReleaseComponent>;
27
+ }
28
+ export interface ReleaseCache {
29
+ release: PlatformRelease;
30
+ /** When it was fetched (epoch ms). */
31
+ at: number;
32
+ /** The release identifier this machine has already announced, if any. */
33
+ announcedRelease?: string;
34
+ etag?: string;
35
+ }
36
+ /**
37
+ * Freshness, with a clock that moved backwards treated as STALE rather than as
38
+ * fresh forever. Same rule and same TTL as the CLI update check — reused rather
39
+ * than re-chosen, because two "how often do we ask" constants drift and the
40
+ * second one is always the one nobody knows about.
41
+ */
42
+ export declare function isReleaseFresh(cache: ReleaseCache | undefined, now: number): boolean;
43
+ /**
44
+ * Parse defensively and STRICTLY. A hand-edited or half-written file must
45
+ * degrade to "no cache" rather than to a bogus comparison — a partially-parsed
46
+ * release would produce a notice about versions nobody publishes.
47
+ */
48
+ export declare function readReleaseCache(raw: unknown): ReleaseCache | undefined;
49
+ /**
50
+ * Parse the endpoint's body. A component whose version is not a non-empty string
51
+ * is DROPPED rather than failing the whole document: the platform composes the
52
+ * set from one row per component, so a single malformed component is not a
53
+ * reason to know nothing about the others. A MALFORMED MIGRATION NOTE IS TREATED
54
+ * AS ABSENT and never partially parsed — half a note is worse than none, because
55
+ * the half that survives reads as complete.
56
+ */
57
+ export declare function readPlatformRelease(raw: unknown): PlatformRelease | undefined;
58
+ export declare function releaseCachePath(): string;
59
+ /**
60
+ * Read the cache. Any fault reads as "no cache".
61
+ *
62
+ * OFF-SWITCH SITE: with the check off this returns undefined WITHOUT touching
63
+ * the file. Callers also gate before calling (see `notice.ts`), because a gate
64
+ * placed only here still means the loader was invoked — and the requirement is
65
+ * that off means not read at all.
66
+ */
67
+ export declare function loadReleaseCache(): ReleaseCache | undefined;
68
+ /** Write the cache. Best-effort: a cache that cannot be written asks again. */
69
+ export declare function saveReleaseCache(cache: ReleaseCache): void;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * `~/.vincentt/platform-release.json` — the machine-wide answer to "what does
3
+ * the platform currently publish", plus the record of what this machine has
4
+ * already said out loud.
5
+ *
6
+ * `announcedRelease` is STORED rather than derived, and that is the whole reason
7
+ * this file holds more than the fetched payload. Derived, "we already told them"
8
+ * is unfalsifiable: a creator who says they were never told about the new SDK
9
+ * cannot be answered, and the platform cannot know either. Stored, it is a fact
10
+ * on disk with the creator's cooperation.
11
+ *
12
+ * Nothing here is a secret and nothing here is state a creator would miss, so
13
+ * the write is best-effort and non-atomic, unlike the machine config. A torn
14
+ * write costs one extra fetch.
15
+ */
16
+ import fs from "node:fs";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+ import { CHECK_TTL_MS } from "../dx/updateCheck.js";
20
+ import { platformCheckEnabled } from "./offSwitch.js";
21
+ const CACHE_FILE = "platform-release.json";
22
+ /**
23
+ * Freshness, with a clock that moved backwards treated as STALE rather than as
24
+ * fresh forever. Same rule and same TTL as the CLI update check — reused rather
25
+ * than re-chosen, because two "how often do we ask" constants drift and the
26
+ * second one is always the one nobody knows about.
27
+ */
28
+ export function isReleaseFresh(cache, now) {
29
+ if (cache === undefined)
30
+ return false;
31
+ if (!Number.isFinite(cache.at))
32
+ return false;
33
+ const age = now - cache.at;
34
+ return age >= 0 && age < CHECK_TTL_MS;
35
+ }
36
+ /**
37
+ * Parse defensively and STRICTLY. A hand-edited or half-written file must
38
+ * degrade to "no cache" rather than to a bogus comparison — a partially-parsed
39
+ * release would produce a notice about versions nobody publishes.
40
+ */
41
+ export function readReleaseCache(raw) {
42
+ if (typeof raw !== "object" || raw === null)
43
+ return undefined;
44
+ const { release, at, announcedRelease, etag } = raw;
45
+ const parsed = readPlatformRelease(release);
46
+ if (parsed === undefined)
47
+ return undefined;
48
+ if (typeof at !== "number" || !Number.isFinite(at))
49
+ return undefined;
50
+ const cache = { release: parsed, at };
51
+ if (typeof announcedRelease === "string" && announcedRelease !== "") {
52
+ cache.announcedRelease = announcedRelease;
53
+ }
54
+ if (typeof etag === "string" && etag !== "")
55
+ cache.etag = etag;
56
+ return cache;
57
+ }
58
+ /**
59
+ * Parse the endpoint's body. A component whose version is not a non-empty string
60
+ * is DROPPED rather than failing the whole document: the platform composes the
61
+ * set from one row per component, so a single malformed component is not a
62
+ * reason to know nothing about the others. A MALFORMED MIGRATION NOTE IS TREATED
63
+ * AS ABSENT and never partially parsed — half a note is worse than none, because
64
+ * the half that survives reads as complete.
65
+ */
66
+ export function readPlatformRelease(raw) {
67
+ if (typeof raw !== "object" || raw === null)
68
+ return undefined;
69
+ const { release, releasedAt, components } = raw;
70
+ if (typeof release !== "string" || release === "")
71
+ return undefined;
72
+ if (typeof components !== "object" || components === null)
73
+ return undefined;
74
+ const out = {};
75
+ for (const [name, value] of Object.entries(components)) {
76
+ if (typeof value !== "object" || value === null)
77
+ continue;
78
+ const { version, migration } = value;
79
+ if (typeof version !== "string" || version === "")
80
+ continue;
81
+ const component = { version };
82
+ const note = readMigration(migration);
83
+ if (note !== undefined)
84
+ component.migration = note;
85
+ out[name] = component;
86
+ }
87
+ const parsed = { release, components: out };
88
+ if (typeof releasedAt === "string" && releasedAt !== "")
89
+ parsed.releasedAt = releasedAt;
90
+ return parsed;
91
+ }
92
+ function readMigration(raw) {
93
+ if (typeof raw !== "object" || raw === null)
94
+ return undefined;
95
+ const { required, summary, notes } = raw;
96
+ // `required` is MANDATORY and boolean. Absent or non-boolean is an unauthored
97
+ // note, not a defaulted one: defaulting it to false would manufacture the
98
+ // authored "no action required" out of a parse failure.
99
+ if (typeof required !== "boolean")
100
+ return undefined;
101
+ if (typeof summary !== "string")
102
+ return undefined;
103
+ const list = Array.isArray(notes)
104
+ ? notes.filter((n) => typeof n === "string")
105
+ : [];
106
+ return { required, summary, notes: list };
107
+ }
108
+ export function releaseCachePath() {
109
+ return path.join(os.homedir(), ".vincentt", CACHE_FILE);
110
+ }
111
+ /**
112
+ * Read the cache. Any fault reads as "no cache".
113
+ *
114
+ * OFF-SWITCH SITE: with the check off this returns undefined WITHOUT touching
115
+ * the file. Callers also gate before calling (see `notice.ts`), because a gate
116
+ * placed only here still means the loader was invoked — and the requirement is
117
+ * that off means not read at all.
118
+ */
119
+ export function loadReleaseCache() {
120
+ if (!platformCheckEnabled())
121
+ return undefined;
122
+ try {
123
+ return readReleaseCache(JSON.parse(fs.readFileSync(releaseCachePath(), "utf8")));
124
+ }
125
+ catch {
126
+ return undefined;
127
+ }
128
+ }
129
+ /** Write the cache. Best-effort: a cache that cannot be written asks again. */
130
+ export function saveReleaseCache(cache) {
131
+ if (!platformCheckEnabled())
132
+ return;
133
+ try {
134
+ fs.mkdirSync(path.dirname(releaseCachePath()), { recursive: true });
135
+ fs.writeFileSync(releaseCachePath(), JSON.stringify(cache) + "\n", "utf8");
136
+ }
137
+ catch {
138
+ // Nothing here is worth failing a verb over.
139
+ }
140
+ }
@@ -0,0 +1,51 @@
1
+ import { type PlatformRelease } from "./releaseCache.js";
2
+ export declare const VERSIONS_PATH = "/platform/versions";
3
+ export interface FetchReleaseResult {
4
+ release: PlatformRelease;
5
+ etag?: string;
6
+ }
7
+ export interface FetchReleaseOptions {
8
+ /** The API origin. */
9
+ apiUrl: string;
10
+ /** Sent as `If-None-Match`. The only request header that changes the response. */
11
+ etag?: string;
12
+ signal?: AbortSignal;
13
+ /** Test seam. */
14
+ fetchImpl?: typeof fetch;
15
+ }
16
+ /**
17
+ * Fetch the current set. `undefined` means nothing to say, including on a `304`
18
+ * — the caller already holds that answer in its cache and a 304 is not new
19
+ * information.
20
+ *
21
+ * OFF-SWITCH SITE (the refresh): with the check off no request is made at all.
22
+ */
23
+ export declare function fetchRelease(opts: FetchReleaseOptions): Promise<FetchReleaseResult | undefined>;
24
+ /**
25
+ * The same read, on an UNREF'D SOCKET, for the unbidden path only.
26
+ *
27
+ * WHY THIS EXISTS AND IS NOT `fetch`. The design's performance guarantee is
28
+ * structural: `preview`'s time-to-URL must not move, and the mechanism named for
29
+ * it is an `unref`'d refresh. Measured, `unref`'ing the TIMERS is not sufficient
30
+ * — a pending `fetch` keeps its SOCKET as an active handle, and the platform
31
+ * exposes no way to unref it, so a verb run against an endpoint that accepts the
32
+ * connection and never answers waits the full budget before exiting. Against a
33
+ * healthy endpoint nobody notices; against a hung one every command appears to
34
+ * hang, and the delay lands on `preview`, where someone is waiting to open a URL
35
+ * on a phone. That is the exact failure the guarantee was written to exclude.
36
+ *
37
+ * `node:http` with `socket.unref()` exits immediately (measured: 2ms against the
38
+ * same hung endpoint), so the unbidden path uses it and the guarantee holds as a
39
+ * property rather than as an aspiration. `fetchRelease` above keeps `fetch` and
40
+ * its injectable seam for `outdated`, which was ASKED for and may legitimately
41
+ * wait.
42
+ *
43
+ * Best-effort throughout, and `undefined` on every fault — including a body that
44
+ * arrives after the process has already exited, which is the normal case here
45
+ * and is why this writes through a callback rather than returning a promise the
46
+ * caller could await.
47
+ */
48
+ export declare function fetchReleaseDetached(opts: {
49
+ apiUrl: string;
50
+ etag?: string;
51
+ }, onResult: (result: FetchReleaseResult) => void): void;