@vincentt-xr/cli 0.15.1 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/args.js +1 -0
- package/dist/cli/index.js +177 -4
- 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/clusters.js +10 -1
- 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/exitCodes.d.ts +24 -0
- package/dist/lib/exitCodes.js +28 -0
- package/dist/lib/initCommand.d.ts +3 -1
- package/dist/lib/initCommand.js +10 -0
- package/dist/lib/initFrom.js +12 -2
- package/dist/lib/lifecycle.d.ts +5 -1
- package/dist/lib/projects.d.ts +8 -1
- package/dist/lib/projects.js +21 -4
- 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/publish/api.d.ts +15 -4
- package/dist/publish/api.js +19 -5
- package/dist/publish/command.d.ts +10 -0
- package/dist/publish/command.js +7 -1
- 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/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,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
|
+
}
|
|
@@ -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
|
+
}
|