@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,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;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one network read: `GET /platform/versions`.
|
|
3
|
+
*
|
|
4
|
+
* Public and unauthenticated, so no PAT is attached and no binding is consulted
|
|
5
|
+
* for anything but the host. `undefined` ON ANY FAULT — a refusal, a timeout, a
|
|
6
|
+
* 503 from a box with an empty corpus, a body of the wrong shape, a connection
|
|
7
|
+
* reset. Every one of them is "nothing to say", and the unbidden caller's
|
|
8
|
+
* silence on all of them must be byte-identical to the silence of a project with
|
|
9
|
+
* nothing to report. `preview` must not acquire a new way to look broken.
|
|
10
|
+
*
|
|
11
|
+
* The 2s budget is f7's `FETCH_TIMEOUT_MS`, reused rather than re-chosen. A
|
|
12
|
+
* second timeout constant is a second thing to tune and the one nobody finds.
|
|
13
|
+
*/
|
|
14
|
+
import http from "node:http";
|
|
15
|
+
import https from "node:https";
|
|
16
|
+
import { CLIENT_HEADER, clientHeaderValue } from "../shared/clientHeader.js";
|
|
17
|
+
import { FETCH_TIMEOUT_MS } from "../dx/updateCheck.js";
|
|
18
|
+
import { readPlatformRelease } from "./releaseCache.js";
|
|
19
|
+
import { platformCheckEnabled } from "./offSwitch.js";
|
|
20
|
+
export const VERSIONS_PATH = "/platform/versions";
|
|
21
|
+
/**
|
|
22
|
+
* Fetch the current set. `undefined` means nothing to say, including on a `304`
|
|
23
|
+
* — the caller already holds that answer in its cache and a 304 is not new
|
|
24
|
+
* information.
|
|
25
|
+
*
|
|
26
|
+
* OFF-SWITCH SITE (the refresh): with the check off no request is made at all.
|
|
27
|
+
*/
|
|
28
|
+
export async function fetchRelease(opts) {
|
|
29
|
+
if (!platformCheckEnabled())
|
|
30
|
+
return undefined;
|
|
31
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
34
|
+
timer.unref?.();
|
|
35
|
+
const onAbort = () => controller.abort();
|
|
36
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
37
|
+
try {
|
|
38
|
+
const res = await fetchImpl(`${opts.apiUrl.replace(/\/$/, "")}${VERSIONS_PATH}`, {
|
|
39
|
+
signal: controller.signal,
|
|
40
|
+
headers: {
|
|
41
|
+
// The client tag rides along because the existing middleware LOGS it,
|
|
42
|
+
// and logging is not branching: the handler reads no header but
|
|
43
|
+
// `If-None-Match`, so this cannot change the answer. Omitting it would
|
|
44
|
+
// make this one route's requests distinguishable from every other
|
|
45
|
+
// request the CLI makes, which is the opposite of what is wanted.
|
|
46
|
+
[CLIENT_HEADER]: clientHeaderValue(),
|
|
47
|
+
...(opts.etag ? { "if-none-match": opts.etag } : {}),
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
if (!res.ok)
|
|
51
|
+
return undefined;
|
|
52
|
+
const release = readPlatformRelease(await res.json());
|
|
53
|
+
if (release === undefined)
|
|
54
|
+
return undefined;
|
|
55
|
+
const etag = res.headers.get("etag") ?? undefined;
|
|
56
|
+
return etag ? { release, etag } : { release };
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The same read, on an UNREF'D SOCKET, for the unbidden path only.
|
|
68
|
+
*
|
|
69
|
+
* WHY THIS EXISTS AND IS NOT `fetch`. The design's performance guarantee is
|
|
70
|
+
* structural: `preview`'s time-to-URL must not move, and the mechanism named for
|
|
71
|
+
* it is an `unref`'d refresh. Measured, `unref`'ing the TIMERS is not sufficient
|
|
72
|
+
* — a pending `fetch` keeps its SOCKET as an active handle, and the platform
|
|
73
|
+
* exposes no way to unref it, so a verb run against an endpoint that accepts the
|
|
74
|
+
* connection and never answers waits the full budget before exiting. Against a
|
|
75
|
+
* healthy endpoint nobody notices; against a hung one every command appears to
|
|
76
|
+
* hang, and the delay lands on `preview`, where someone is waiting to open a URL
|
|
77
|
+
* on a phone. That is the exact failure the guarantee was written to exclude.
|
|
78
|
+
*
|
|
79
|
+
* `node:http` with `socket.unref()` exits immediately (measured: 2ms against the
|
|
80
|
+
* same hung endpoint), so the unbidden path uses it and the guarantee holds as a
|
|
81
|
+
* property rather than as an aspiration. `fetchRelease` above keeps `fetch` and
|
|
82
|
+
* its injectable seam for `outdated`, which was ASKED for and may legitimately
|
|
83
|
+
* wait.
|
|
84
|
+
*
|
|
85
|
+
* Best-effort throughout, and `undefined` on every fault — including a body that
|
|
86
|
+
* arrives after the process has already exited, which is the normal case here
|
|
87
|
+
* and is why this writes through a callback rather than returning a promise the
|
|
88
|
+
* caller could await.
|
|
89
|
+
*/
|
|
90
|
+
export function fetchReleaseDetached(opts, onResult) {
|
|
91
|
+
if (!platformCheckEnabled())
|
|
92
|
+
return;
|
|
93
|
+
let url;
|
|
94
|
+
try {
|
|
95
|
+
url = new URL(`${opts.apiUrl.replace(/\/$/, "")}${VERSIONS_PATH}`);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const transport = url.protocol === "https:" ? https : http;
|
|
101
|
+
try {
|
|
102
|
+
const req = transport.request({
|
|
103
|
+
protocol: url.protocol,
|
|
104
|
+
hostname: url.hostname,
|
|
105
|
+
port: url.port,
|
|
106
|
+
path: `${url.pathname}${url.search}`,
|
|
107
|
+
method: "GET",
|
|
108
|
+
headers: {
|
|
109
|
+
[CLIENT_HEADER]: clientHeaderValue(),
|
|
110
|
+
...(opts.etag ? { "if-none-match": opts.etag } : {}),
|
|
111
|
+
},
|
|
112
|
+
}, (res) => {
|
|
113
|
+
if (res.statusCode !== 200) {
|
|
114
|
+
res.resume();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
let body = "";
|
|
118
|
+
res.setEncoding("utf8");
|
|
119
|
+
res.on("data", (chunk) => {
|
|
120
|
+
body += chunk;
|
|
121
|
+
// Bounded: this is parsed on a creator's machine off a public route,
|
|
122
|
+
// and an endless body must not grow without limit.
|
|
123
|
+
if (body.length > 256_000)
|
|
124
|
+
req.destroy();
|
|
125
|
+
});
|
|
126
|
+
res.on("end", () => {
|
|
127
|
+
try {
|
|
128
|
+
const release = readPlatformRelease(JSON.parse(body));
|
|
129
|
+
if (release === undefined)
|
|
130
|
+
return;
|
|
131
|
+
const etag = res.headers.etag;
|
|
132
|
+
onResult(typeof etag === "string" ? { release, etag } : { release });
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// A malformed body is nothing to say.
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
res.on("error", () => { });
|
|
139
|
+
});
|
|
140
|
+
// THE LOAD-BEARING CALL. Without it a pending request is an active handle
|
|
141
|
+
// and the process waits on it.
|
|
142
|
+
req.on("socket", (socket) => socket.unref());
|
|
143
|
+
req.on("error", () => { });
|
|
144
|
+
req.setTimeout(FETCH_TIMEOUT_MS, () => req.destroy());
|
|
145
|
+
req.end();
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// A refresh that cannot even be started is nothing to say.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire shape. `packages` keys are package identities; `template` is the
|
|
3
|
+
* recorded baseline, and ABSENT means unknown.
|
|
4
|
+
*
|
|
5
|
+
* The field names and casing are the contract — the same name and shape on the
|
|
6
|
+
* publish route and the report route.
|
|
7
|
+
*/
|
|
8
|
+
export interface BuiltWith {
|
|
9
|
+
packages: Record<string, string>;
|
|
10
|
+
template?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface ResolveBuiltWithOptions {
|
|
13
|
+
/**
|
|
14
|
+
* The recorded template baseline, injected so the resolver does not have to
|
|
15
|
+
* decide where a binding lives. Omitted → read from the project binding.
|
|
16
|
+
*/
|
|
17
|
+
templateVersion?: string;
|
|
18
|
+
/** Test seam: skip the binding read entirely. */
|
|
19
|
+
readBinding?: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the version set for `projectDir`, or `undefined` when there is
|
|
23
|
+
* nothing to say.
|
|
24
|
+
*
|
|
25
|
+
* ITERATES THE ALLOWLIST — it never enumerates the creator's dependencies and
|
|
26
|
+
* filters. Only allowlisted paths are opened, so a third-party name is never
|
|
27
|
+
* read into memory in the first place. This is `B-F17-1` as control flow, and it
|
|
28
|
+
* is why the creator-facing disclosure can say "and nothing else" truthfully.
|
|
29
|
+
*/
|
|
30
|
+
export declare function resolveBuiltWith(projectDir: string, opts?: ResolveBuiltWithOptions): Promise<BuiltWith | undefined>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `builtWith` — what this project resolved on THIS machine, right now.
|
|
3
|
+
*
|
|
4
|
+
* ONE resolver, two callers (the publish body and the dx report body). That is
|
|
5
|
+
* not tidiness: the two payloads carrying byte-identical values for the same
|
|
6
|
+
* project is the mechanical proof there is one resolver and not two that drifted
|
|
7
|
+
* — a second resolver is how one surface starts reporting declared ranges while
|
|
8
|
+
* the other reports installed versions, and neither looks wrong alone.
|
|
9
|
+
*
|
|
10
|
+
* INSTALLED, NEVER DECLARED. The version read is the one in
|
|
11
|
+
* `node_modules/<name>/package.json`, because `^1.2.0` cannot answer "did the
|
|
12
|
+
* fix reach anyone" and `1.4.0` can. A creator whose lockfile resolved 1.4.0
|
|
13
|
+
* under a `^1.2.0` declaration HAS the fix, and recording the range would report
|
|
14
|
+
* them behind while current. (The UPGRADE edits the declared range, because that
|
|
15
|
+
* is what an install resolves from — two different reads of two different
|
|
16
|
+
* things, and a dev unifying them breaks one of the two.)
|
|
17
|
+
*
|
|
18
|
+
* ABSENT vs EMPTY are different facts and both are first-class. `undefined`
|
|
19
|
+
* means resolution did not run or found nothing to say; an object with an empty
|
|
20
|
+
* `packages` map means it ran and found none installed. Nothing is ever
|
|
21
|
+
* backfilled and there is no `"unknown"` sentinel — a valid-looking version
|
|
22
|
+
* string is how a surface ends up rendering `sdk@unknown` as a version and how a
|
|
23
|
+
* comparison ends up running against it.
|
|
24
|
+
*/
|
|
25
|
+
import { promises as fs } from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { PLATFORM_PACKAGES } from "./allowlist.js";
|
|
28
|
+
import { loadProjectBinding } from "../shared/config.js";
|
|
29
|
+
/** §9's bounds. Over-bound entries are DROPPED, never an error. */
|
|
30
|
+
const MAX_PACKAGES = 16;
|
|
31
|
+
const MAX_NAME_BYTES = 128;
|
|
32
|
+
const MAX_VERSION_BYTES = 64;
|
|
33
|
+
/**
|
|
34
|
+
* The character class a version string must satisfy WHOLE, matching the api's
|
|
35
|
+
* own `dxCLIVersionRe`. It admits every registry semver — `2.0.0-alpha.3+build.1`,
|
|
36
|
+
* `1.0.0-rc.1+meta.20260908`, `1.0.0-rc-16` — and nothing that can carry a
|
|
37
|
+
* sentence.
|
|
38
|
+
*
|
|
39
|
+
* Printable ASCII was not enough, and the gap was demonstrated rather than
|
|
40
|
+
* argued: control bytes were already dropped, so ANSI and OSC-8 were closed, but
|
|
41
|
+
* punctuation-only injection was wide open. A `version` field in a
|
|
42
|
+
* `node_modules/*/package.json` is writable by any postinstall script or
|
|
43
|
+
* transitive dependency, this string lands INSIDE the payload block on a consent
|
|
44
|
+
* screen, the CLI's stdout is a coding agent's input, and the sentence below it
|
|
45
|
+
* asserts "and nothing else" — so the attacker's text lands where an agent parses
|
|
46
|
+
* loosely with the platform's own copy vouching for it.
|
|
47
|
+
*/
|
|
48
|
+
const VERSION_CLASS = /^[0-9A-Za-z.\-+]+$/;
|
|
49
|
+
/**
|
|
50
|
+
* A version string, or `undefined` when there is nothing safe to say.
|
|
51
|
+
*
|
|
52
|
+
* THE CLASS CHECK RUNS FIRST, on the value AS RECEIVED, and an out-of-class
|
|
53
|
+
* value is dropped WHOLE. Order is the decision: applied after truncation it
|
|
54
|
+
* would store a PREFIX of a hostile value, and `1.4` standing in for `1.4.7` is
|
|
55
|
+
* a wrong fact that looks entirely plausible — a wrong answer to "did the fix
|
|
56
|
+
* reach anyone", and worse than absence. Truncation therefore only ever sees a
|
|
57
|
+
* value already known to be in class, which also makes mid-rune truncation moot.
|
|
58
|
+
*
|
|
59
|
+
* DROPPED, NEVER REFUSED. An out-of-class value omits the entry; the send still
|
|
60
|
+
* succeeds. `file:` and `git+ssh://` installs fall out of class and become
|
|
61
|
+
* ABSENT, which is a first-class state every surface renders honestly, and it is
|
|
62
|
+
* the accepted cost of closing an injection channel on a consent surface.
|
|
63
|
+
*/
|
|
64
|
+
function sanitizeVersion(raw) {
|
|
65
|
+
if (!VERSION_CLASS.test(raw))
|
|
66
|
+
return undefined;
|
|
67
|
+
if (Buffer.byteLength(raw, "utf8") > MAX_VERSION_BYTES)
|
|
68
|
+
return undefined;
|
|
69
|
+
return raw;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Read one allowlisted package's installed version.
|
|
73
|
+
*
|
|
74
|
+
* A missing package, an unreadable manifest, a manifest with no `version` and a
|
|
75
|
+
* manifest that is not JSON are all the same answer: this package contributes
|
|
76
|
+
* nothing. None of them is an error, because a project legitimately does not
|
|
77
|
+
* install every platform package.
|
|
78
|
+
*/
|
|
79
|
+
async function installedVersion(projectDir, name) {
|
|
80
|
+
try {
|
|
81
|
+
const manifestPath = path.join(projectDir, "node_modules", ...name.split("/"), "package.json");
|
|
82
|
+
const raw = JSON.parse(await fs.readFile(manifestPath, "utf8"));
|
|
83
|
+
if (typeof raw.version !== "string")
|
|
84
|
+
return undefined;
|
|
85
|
+
return sanitizeVersion(raw.version);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Resolve the version set for `projectDir`, or `undefined` when there is
|
|
93
|
+
* nothing to say.
|
|
94
|
+
*
|
|
95
|
+
* ITERATES THE ALLOWLIST — it never enumerates the creator's dependencies and
|
|
96
|
+
* filters. Only allowlisted paths are opened, so a third-party name is never
|
|
97
|
+
* read into memory in the first place. This is `B-F17-1` as control flow, and it
|
|
98
|
+
* is why the creator-facing disclosure can say "and nothing else" truthfully.
|
|
99
|
+
*/
|
|
100
|
+
export async function resolveBuiltWith(projectDir, opts = {}) {
|
|
101
|
+
const packages = {};
|
|
102
|
+
for (const pkg of PLATFORM_PACKAGES) {
|
|
103
|
+
if (Object.keys(packages).length >= MAX_PACKAGES)
|
|
104
|
+
break;
|
|
105
|
+
if (Buffer.byteLength(pkg.name, "utf8") > MAX_NAME_BYTES)
|
|
106
|
+
continue;
|
|
107
|
+
const version = await installedVersion(projectDir, pkg.name);
|
|
108
|
+
if (version !== undefined)
|
|
109
|
+
packages[pkg.name] = version;
|
|
110
|
+
}
|
|
111
|
+
let template = opts.templateVersion;
|
|
112
|
+
if (template === undefined && opts.readBinding !== false) {
|
|
113
|
+
try {
|
|
114
|
+
template = (await loadProjectBinding(projectDir))?.template?.version;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
template = undefined;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const cleanTemplate = template === undefined ? undefined : sanitizeVersion(template);
|
|
121
|
+
// Nothing resolved at all → undefined, so the caller OMITS the field rather
|
|
122
|
+
// than sending `{}`. A `{}` says "resolution ran and found nothing", which is
|
|
123
|
+
// a different and specific fact, and a surface that renders the two the same
|
|
124
|
+
// way collapses two of the three states this design exists to distinguish.
|
|
125
|
+
if (Object.keys(packages).length === 0 && cleanTemplate === undefined)
|
|
126
|
+
return undefined;
|
|
127
|
+
return cleanTemplate === undefined ? { packages } : { packages, template: cleanTemplate };
|
|
128
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type DependencyChange, type TrackedState } from "./upgradeReport.js";
|
|
2
|
+
/** The manifest file, read-only: it arrives with the TO tree and is never in its own overwrite set. */
|
|
3
|
+
export declare const MANIFEST_FILE = ".vincentt-template.json";
|
|
4
|
+
/** The first template version carrying a manifest, for the blocked report's copy. */
|
|
5
|
+
export declare const FIRST_MANIFEST_VERSION = "1.5.0";
|
|
6
|
+
export type GitRunner = (args: string[]) => Promise<string | void>;
|
|
7
|
+
/**
|
|
8
|
+
* Validate a recorded FROM ref BEFORE it reaches git.
|
|
9
|
+
*
|
|
10
|
+
* Anything not exactly `MAJOR.MINOR.PATCH` is NO BASELINE — not an error, and
|
|
11
|
+
* not a bare attempt. `../../etc`, `refs/heads/1.4.3`, `1.4.3; rm -rf` and
|
|
12
|
+
* `latest` all resolve to "no baseline" without git being invoked at all.
|
|
13
|
+
*/
|
|
14
|
+
export declare function validateFromRef(raw: string | undefined): string | undefined;
|
|
15
|
+
export interface UpgradeDeps {
|
|
16
|
+
projectDir: string;
|
|
17
|
+
/** The TO version — a concrete tag. */
|
|
18
|
+
to: string;
|
|
19
|
+
/**
|
|
20
|
+
* What the dependency half reported. The template half is attempted only when
|
|
21
|
+
* every platform package the project has is accounted for here.
|
|
22
|
+
*/
|
|
23
|
+
dependencyOutcome: DependencyOutcome;
|
|
24
|
+
runGit?: GitRunner;
|
|
25
|
+
/** Test seam: substitute the clone with a local directory per ref. */
|
|
26
|
+
fetchTag?: (ref: string) => Promise<string | undefined>;
|
|
27
|
+
/** Test seam for tracked-ness, so the git plumbing is injectable. */
|
|
28
|
+
trackedStateOf?: (projectDir: string, rel: string) => Promise<TrackedState>;
|
|
29
|
+
}
|
|
30
|
+
export interface DependencyOutcome {
|
|
31
|
+
/** Packages the dependency half completed. */
|
|
32
|
+
completed: DependencyChange[];
|
|
33
|
+
/**
|
|
34
|
+
* Packages it did NOT complete. Non-empty means the template half is not
|
|
35
|
+
* attempted — the ordering precondition, expressed as data the report reads
|
|
36
|
+
* rather than as an exit code the procedure may not read.
|
|
37
|
+
*/
|
|
38
|
+
outstanding: string[];
|
|
39
|
+
}
|
|
40
|
+
export interface UpgradeOutcome {
|
|
41
|
+
/** The rendered report. The procedure reads THIS, never an exit code. */
|
|
42
|
+
report: string[];
|
|
43
|
+
/** Whether the template half wrote any file. */
|
|
44
|
+
templateAttempted: boolean;
|
|
45
|
+
/** The baseline recorded, if any. Written LAST. */
|
|
46
|
+
baselineWritten?: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Run the template half.
|
|
50
|
+
*
|
|
51
|
+
* Every early return renders a report and writes nothing. There is no path
|
|
52
|
+
* through this function that overwrites a file without the report naming it.
|
|
53
|
+
*/
|
|
54
|
+
export declare function upgradeTemplate(deps: UpgradeDeps): Promise<UpgradeOutcome>;
|
|
55
|
+
/** Which platform packages this project has installed, for the ordering precondition. */
|
|
56
|
+
export declare function installedPlatformPackages(projectDir: string): Promise<string[]>;
|