@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.
- package/dist/cli/args.js +1 -0
- package/dist/cli/index.js +198 -3
- package/dist/cli/usage-text.js +27 -0
- package/dist/cli/verb-surface.d.ts +3 -1
- package/dist/cli/verb-surface.js +12 -0
- package/dist/domain/api.d.ts +51 -0
- package/dist/domain/api.js +145 -0
- package/dist/domain/command.d.ts +75 -0
- package/dist/domain/command.js +467 -0
- package/dist/domain/copy.d.ts +334 -0
- package/dist/domain/copy.js +952 -0
- package/dist/domain/hostname.d.ts +57 -0
- package/dist/domain/hostname.js +116 -0
- package/dist/domain/names.d.ts +78 -0
- package/dist/domain/names.js +113 -0
- package/dist/domain/routedVerbs.d.ts +10 -0
- package/dist/domain/routedVerbs.js +30 -0
- package/dist/domain/wire.d.ts +140 -0
- package/dist/domain/wire.js +174 -0
- package/dist/dx/command.d.ts +13 -4
- package/dist/dx/command.js +26 -5
- package/dist/dx/report.d.ts +39 -7
- package/dist/dx/report.js +71 -20
- package/dist/dx/send.d.ts +22 -4
- package/dist/dx/send.js +29 -8
- package/dist/dx/skeleton.js +19 -0
- package/dist/dx/unbiddenVerbs.d.ts +25 -0
- package/dist/dx/unbiddenVerbs.js +39 -0
- package/dist/lib/initCommand.d.ts +3 -1
- package/dist/lib/initCommand.js +10 -0
- package/dist/lib/projects.d.ts +8 -1
- package/dist/lib/projects.js +12 -2
- package/dist/platform/allowlist.d.ts +36 -0
- package/dist/platform/allowlist.js +34 -0
- package/dist/platform/behind.d.ts +41 -0
- package/dist/platform/behind.js +82 -0
- package/dist/platform/command.d.ts +16 -0
- package/dist/platform/command.js +90 -0
- package/dist/platform/footer.d.ts +60 -0
- package/dist/platform/footer.js +101 -0
- package/dist/platform/notice.d.ts +29 -0
- package/dist/platform/notice.js +66 -0
- package/dist/platform/offSwitch.d.ts +19 -0
- package/dist/platform/offSwitch.js +21 -0
- package/dist/platform/outdated.d.ts +25 -0
- package/dist/platform/outdated.js +246 -0
- package/dist/platform/releaseCache.d.ts +69 -0
- package/dist/platform/releaseCache.js +140 -0
- package/dist/platform/releaseFetch.d.ts +51 -0
- package/dist/platform/releaseFetch.js +150 -0
- package/dist/platform/resolve.d.ts +30 -0
- package/dist/platform/resolve.js +128 -0
- package/dist/platform/templateUpgrade.d.ts +56 -0
- package/dist/platform/templateUpgrade.js +476 -0
- package/dist/platform/upgradeReport.d.ts +132 -0
- package/dist/platform/upgradeReport.js +258 -0
- package/dist/platform/versionSummary.d.ts +82 -0
- package/dist/platform/versionSummary.js +181 -0
- package/dist/platform/zonePath.d.ts +154 -0
- package/dist/platform/zonePath.js +286 -0
- package/dist/platform/zones.d.ts +64 -0
- package/dist/platform/zones.js +79 -0
- package/dist/preview/api.d.ts +19 -0
- package/dist/preview/api.js +29 -0
- package/dist/publish/api.d.ts +32 -5
- package/dist/publish/api.js +19 -5
- package/dist/publish/command.d.ts +10 -0
- package/dist/publish/command.js +30 -2
- package/dist/publish/copy.d.ts +14 -1
- package/dist/publish/copy.js +32 -3
- package/dist/scaffold/index.d.ts +50 -3
- package/dist/scaffold/index.js +61 -1
- package/dist/shared/config.d.ts +16 -0
- package/dist/shared/config.js +9 -1
- package/dist/shared/countLimit.d.ts +109 -0
- package/dist/shared/countLimit.js +169 -0
- package/package.json +1 -1
- package/dist/dx/qa.f7.fixtures.d.ts +0 -73
- package/dist/dx/qa.f7.fixtures.js +0 -58
- package/dist/publish/qa.f4.fixtures.d.ts +0 -25
- package/dist/publish/qa.f4.fixtures.js +0 -140
- package/dist/relay/qa.f12.fixtures.d.ts +0 -154
- 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;
|