@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.
Files changed (84) hide show
  1. package/dist/cli/args.js +1 -0
  2. package/dist/cli/index.js +177 -4
  3. package/dist/cli/usage-text.js +27 -0
  4. package/dist/cli/verb-surface.d.ts +3 -1
  5. package/dist/cli/verb-surface.js +12 -0
  6. package/dist/domain/api.d.ts +51 -0
  7. package/dist/domain/api.js +145 -0
  8. package/dist/domain/command.d.ts +75 -0
  9. package/dist/domain/command.js +467 -0
  10. package/dist/domain/copy.d.ts +334 -0
  11. package/dist/domain/copy.js +952 -0
  12. package/dist/domain/hostname.d.ts +57 -0
  13. package/dist/domain/hostname.js +116 -0
  14. package/dist/domain/names.d.ts +78 -0
  15. package/dist/domain/names.js +113 -0
  16. package/dist/domain/routedVerbs.d.ts +10 -0
  17. package/dist/domain/routedVerbs.js +30 -0
  18. package/dist/domain/wire.d.ts +140 -0
  19. package/dist/domain/wire.js +174 -0
  20. package/dist/dx/clusters.js +10 -1
  21. package/dist/dx/command.d.ts +13 -4
  22. package/dist/dx/command.js +26 -5
  23. package/dist/dx/report.d.ts +39 -7
  24. package/dist/dx/report.js +71 -20
  25. package/dist/dx/send.d.ts +22 -4
  26. package/dist/dx/send.js +29 -8
  27. package/dist/dx/skeleton.js +19 -0
  28. package/dist/dx/unbiddenVerbs.d.ts +25 -0
  29. package/dist/dx/unbiddenVerbs.js +39 -0
  30. package/dist/lib/exitCodes.d.ts +24 -0
  31. package/dist/lib/exitCodes.js +28 -0
  32. package/dist/lib/initCommand.d.ts +3 -1
  33. package/dist/lib/initCommand.js +10 -0
  34. package/dist/lib/initFrom.js +12 -2
  35. package/dist/lib/lifecycle.d.ts +5 -1
  36. package/dist/lib/projects.d.ts +8 -1
  37. package/dist/lib/projects.js +21 -4
  38. package/dist/platform/allowlist.d.ts +36 -0
  39. package/dist/platform/allowlist.js +34 -0
  40. package/dist/platform/behind.d.ts +41 -0
  41. package/dist/platform/behind.js +82 -0
  42. package/dist/platform/command.d.ts +16 -0
  43. package/dist/platform/command.js +90 -0
  44. package/dist/platform/footer.d.ts +60 -0
  45. package/dist/platform/footer.js +101 -0
  46. package/dist/platform/notice.d.ts +29 -0
  47. package/dist/platform/notice.js +66 -0
  48. package/dist/platform/offSwitch.d.ts +19 -0
  49. package/dist/platform/offSwitch.js +21 -0
  50. package/dist/platform/outdated.d.ts +25 -0
  51. package/dist/platform/outdated.js +246 -0
  52. package/dist/platform/releaseCache.d.ts +69 -0
  53. package/dist/platform/releaseCache.js +140 -0
  54. package/dist/platform/releaseFetch.d.ts +51 -0
  55. package/dist/platform/releaseFetch.js +150 -0
  56. package/dist/platform/resolve.d.ts +30 -0
  57. package/dist/platform/resolve.js +128 -0
  58. package/dist/platform/templateUpgrade.d.ts +56 -0
  59. package/dist/platform/templateUpgrade.js +476 -0
  60. package/dist/platform/upgradeReport.d.ts +132 -0
  61. package/dist/platform/upgradeReport.js +258 -0
  62. package/dist/platform/versionSummary.d.ts +82 -0
  63. package/dist/platform/versionSummary.js +181 -0
  64. package/dist/platform/zonePath.d.ts +154 -0
  65. package/dist/platform/zonePath.js +286 -0
  66. package/dist/platform/zones.d.ts +64 -0
  67. package/dist/platform/zones.js +79 -0
  68. package/dist/publish/api.d.ts +15 -4
  69. package/dist/publish/api.js +19 -5
  70. package/dist/publish/command.d.ts +10 -0
  71. package/dist/publish/command.js +7 -1
  72. package/dist/publish/copy.d.ts +14 -1
  73. package/dist/publish/copy.js +32 -3
  74. package/dist/scaffold/index.d.ts +50 -3
  75. package/dist/scaffold/index.js +61 -1
  76. package/dist/shared/config.d.ts +16 -0
  77. package/dist/shared/config.js +9 -1
  78. package/package.json +1 -1
  79. package/dist/dx/qa.f7.fixtures.d.ts +0 -73
  80. package/dist/dx/qa.f7.fixtures.js +0 -58
  81. package/dist/publish/qa.f4.fixtures.d.ts +0 -25
  82. package/dist/publish/qa.f4.fixtures.js +0 -140
  83. package/dist/relay/qa.f12.fixtures.d.ts +0 -154
  84. 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[]>;