@vincentt-xr/cli 0.15.2 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/args.js +1 -0
- package/dist/cli/index.js +198 -3
- package/dist/cli/usage-text.js +27 -0
- package/dist/cli/verb-surface.d.ts +3 -1
- package/dist/cli/verb-surface.js +12 -0
- package/dist/domain/api.d.ts +51 -0
- package/dist/domain/api.js +145 -0
- package/dist/domain/command.d.ts +75 -0
- package/dist/domain/command.js +467 -0
- package/dist/domain/copy.d.ts +334 -0
- package/dist/domain/copy.js +952 -0
- package/dist/domain/hostname.d.ts +57 -0
- package/dist/domain/hostname.js +116 -0
- package/dist/domain/names.d.ts +78 -0
- package/dist/domain/names.js +113 -0
- package/dist/domain/routedVerbs.d.ts +10 -0
- package/dist/domain/routedVerbs.js +30 -0
- package/dist/domain/wire.d.ts +140 -0
- package/dist/domain/wire.js +174 -0
- package/dist/dx/command.d.ts +13 -4
- package/dist/dx/command.js +26 -5
- package/dist/dx/report.d.ts +39 -7
- package/dist/dx/report.js +71 -20
- package/dist/dx/send.d.ts +22 -4
- package/dist/dx/send.js +29 -8
- package/dist/dx/skeleton.js +19 -0
- package/dist/dx/unbiddenVerbs.d.ts +25 -0
- package/dist/dx/unbiddenVerbs.js +39 -0
- package/dist/lib/initCommand.d.ts +3 -1
- package/dist/lib/initCommand.js +10 -0
- package/dist/lib/projects.d.ts +8 -1
- package/dist/lib/projects.js +12 -2
- package/dist/platform/allowlist.d.ts +36 -0
- package/dist/platform/allowlist.js +34 -0
- package/dist/platform/behind.d.ts +41 -0
- package/dist/platform/behind.js +82 -0
- package/dist/platform/command.d.ts +16 -0
- package/dist/platform/command.js +90 -0
- package/dist/platform/footer.d.ts +60 -0
- package/dist/platform/footer.js +101 -0
- package/dist/platform/notice.d.ts +29 -0
- package/dist/platform/notice.js +66 -0
- package/dist/platform/offSwitch.d.ts +19 -0
- package/dist/platform/offSwitch.js +21 -0
- package/dist/platform/outdated.d.ts +25 -0
- package/dist/platform/outdated.js +246 -0
- package/dist/platform/releaseCache.d.ts +69 -0
- package/dist/platform/releaseCache.js +140 -0
- package/dist/platform/releaseFetch.d.ts +51 -0
- package/dist/platform/releaseFetch.js +150 -0
- package/dist/platform/resolve.d.ts +30 -0
- package/dist/platform/resolve.js +128 -0
- package/dist/platform/templateUpgrade.d.ts +56 -0
- package/dist/platform/templateUpgrade.js +476 -0
- package/dist/platform/upgradeReport.d.ts +132 -0
- package/dist/platform/upgradeReport.js +258 -0
- package/dist/platform/versionSummary.d.ts +82 -0
- package/dist/platform/versionSummary.js +181 -0
- package/dist/platform/zonePath.d.ts +154 -0
- package/dist/platform/zonePath.js +286 -0
- package/dist/platform/zones.d.ts +64 -0
- package/dist/platform/zones.js +79 -0
- package/dist/preview/api.d.ts +19 -0
- package/dist/preview/api.js +29 -0
- package/dist/publish/api.d.ts +32 -5
- package/dist/publish/api.js +19 -5
- package/dist/publish/command.d.ts +10 -0
- package/dist/publish/command.js +30 -2
- package/dist/publish/copy.d.ts +14 -1
- package/dist/publish/copy.js +32 -3
- package/dist/scaffold/index.d.ts +50 -3
- package/dist/scaffold/index.js +61 -1
- package/dist/shared/config.d.ts +16 -0
- package/dist/shared/config.js +9 -1
- package/dist/shared/countLimit.d.ts +109 -0
- package/dist/shared/countLimit.js +169 -0
- package/package.json +1 -1
- package/dist/dx/qa.f7.fixtures.d.ts +0 -73
- package/dist/dx/qa.f7.fixtures.js +0 -58
- package/dist/publish/qa.f4.fixtures.d.ts +0 -25
- package/dist/publish/qa.f4.fixtures.js +0 -140
- package/dist/relay/qa.f12.fixtures.d.ts +0 -154
- package/dist/relay/qa.f12.fixtures.js +0 -358
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The verbs unbidden output may not contain.
|
|
3
|
+
*
|
|
4
|
+
* Unbidden output lands in an agent's transcript, and an agent that reads an
|
|
5
|
+
* instruction will simply obey it. Naming that a command exists is information;
|
|
6
|
+
* telling a caller to transmit — or to grant itself permission to — is not. A
|
|
7
|
+
* REFUSAL may name both, because a refusal is solicited
|
|
8
|
+
* (D-A-refusal-is-solicited-output), so this list constrains only output the
|
|
9
|
+
* caller did not ask for.
|
|
10
|
+
*
|
|
11
|
+
* It is exported rather than restated at each site because every additional
|
|
12
|
+
* consumer is another chance for the lists to drift apart, and a guard that has
|
|
13
|
+
* quietly stopped matching its sibling still passes.
|
|
14
|
+
*
|
|
15
|
+
* `please`, `should` and `must` are not verbs. They are here because they are
|
|
16
|
+
* how an imperative gets smuggled past a verb-only list ("you should enable
|
|
17
|
+
* this"), and the guard is about directing rather than about grammar.
|
|
18
|
+
*/
|
|
19
|
+
export declare const UNBIDDEN_FORBIDDEN_VERBS: readonly ["run", "send", "report", "submit", "share", "try", "use", "execute", "enable", "please", "should", "must"];
|
|
20
|
+
export type UnbiddenForbiddenVerb = (typeof UNBIDDEN_FORBIDDEN_VERBS)[number];
|
|
21
|
+
/**
|
|
22
|
+
* Word-boundary, case-insensitive — the form every consumer needs, built here so
|
|
23
|
+
* a consumer cannot get the boundary or the flags subtly wrong.
|
|
24
|
+
*/
|
|
25
|
+
export declare function unbiddenVerbPattern(verb: string): RegExp;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The verbs unbidden output may not contain.
|
|
3
|
+
*
|
|
4
|
+
* Unbidden output lands in an agent's transcript, and an agent that reads an
|
|
5
|
+
* instruction will simply obey it. Naming that a command exists is information;
|
|
6
|
+
* telling a caller to transmit — or to grant itself permission to — is not. A
|
|
7
|
+
* REFUSAL may name both, because a refusal is solicited
|
|
8
|
+
* (D-A-refusal-is-solicited-output), so this list constrains only output the
|
|
9
|
+
* caller did not ask for.
|
|
10
|
+
*
|
|
11
|
+
* It is exported rather than restated at each site because every additional
|
|
12
|
+
* consumer is another chance for the lists to drift apart, and a guard that has
|
|
13
|
+
* quietly stopped matching its sibling still passes.
|
|
14
|
+
*
|
|
15
|
+
* `please`, `should` and `must` are not verbs. They are here because they are
|
|
16
|
+
* how an imperative gets smuggled past a verb-only list ("you should enable
|
|
17
|
+
* this"), and the guard is about directing rather than about grammar.
|
|
18
|
+
*/
|
|
19
|
+
export const UNBIDDEN_FORBIDDEN_VERBS = Object.freeze([
|
|
20
|
+
"run",
|
|
21
|
+
"send",
|
|
22
|
+
"report",
|
|
23
|
+
"submit",
|
|
24
|
+
"share",
|
|
25
|
+
"try",
|
|
26
|
+
"use",
|
|
27
|
+
"execute",
|
|
28
|
+
"enable",
|
|
29
|
+
"please",
|
|
30
|
+
"should",
|
|
31
|
+
"must",
|
|
32
|
+
]);
|
|
33
|
+
/**
|
|
34
|
+
* Word-boundary, case-insensitive — the form every consumer needs, built here so
|
|
35
|
+
* a consumer cannot get the boundary or the flags subtly wrong.
|
|
36
|
+
*/
|
|
37
|
+
export function unbiddenVerbPattern(verb) {
|
|
38
|
+
return new RegExp(`\\b${verb}\\b`, "i");
|
|
39
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { type ScaffoldOutcome } from "../scaffold/index.js";
|
|
1
2
|
import type { RegisterDeps } from "./initFrom.js";
|
|
2
3
|
import { type SourceRef } from "./sourceRef.js";
|
|
3
4
|
import type { Ask, Confirm } from "../publish/prompt.js";
|
|
4
5
|
export interface InitDeps {
|
|
5
6
|
print: (line: string) => void;
|
|
6
7
|
cwd: string;
|
|
7
|
-
scaffold?: (targetDir: string, opts: ScaffoldCall) => Promise<
|
|
8
|
+
scaffold?: (targetDir: string, opts: ScaffoldCall) => Promise<ScaffoldOutcome>;
|
|
8
9
|
install?: (targetDir: string) => Promise<string>;
|
|
9
10
|
isEmpty?: (targetDir: string) => Promise<boolean>;
|
|
10
11
|
/** Free-text prompt. Absent → not a TTY, so nothing may be asked. */
|
|
@@ -20,6 +21,7 @@ export interface InitDeps {
|
|
|
20
21
|
register?: (deps: Pick<RegisterDeps, "print" | "targetDir" | "dirLabel" | "ask">, opts: {
|
|
21
22
|
name?: string;
|
|
22
23
|
slug?: string;
|
|
24
|
+
templateVersion?: string;
|
|
23
25
|
}) => Promise<number>;
|
|
24
26
|
}
|
|
25
27
|
/**
|
package/dist/lib/initCommand.js
CHANGED
|
@@ -93,6 +93,10 @@ export async function initCommand(deps, opts = {}) {
|
|
|
93
93
|
const scaffold = deps.scaffold ??
|
|
94
94
|
((dir, o) => scaffoldFromTemplate(dir, o));
|
|
95
95
|
try {
|
|
96
|
+
// The result is deliberately discarded here. `init` writes no
|
|
97
|
+
// `.vincentt/project.json` at all on the bare path (that is the whole
|
|
98
|
+
// no-account wedge), and its only registering path is `--from`, whose
|
|
99
|
+
// baseline is refused below for naming a different repository.
|
|
96
100
|
await scaffold(target, opts.from
|
|
97
101
|
? { repo: templateCloneURL(opts.from.owner, opts.from.repo), ref: opts.from.tag }
|
|
98
102
|
: { ref: opts.template });
|
|
@@ -164,6 +168,12 @@ export async function initCommand(deps, opts = {}) {
|
|
|
164
168
|
const register = deps.register;
|
|
165
169
|
if (!register)
|
|
166
170
|
return 0;
|
|
171
|
+
// NO baseline on the `--from` path, and that is not an oversight. A gallery
|
|
172
|
+
// source is a different repository, so a version tag in it names that repo's
|
|
173
|
+
// release and not a v2-template version — recording it would hand a later
|
|
174
|
+
// upgrade a baseline that resolves against the wrong remote entirely, which is
|
|
175
|
+
// worse than the no-baseline path it takes instead. `register` is unreachable
|
|
176
|
+
// unless `--from` was given, so this call never carries one.
|
|
167
177
|
const code = await register({ print: deps.print, targetDir: target, dirLabel: label, ask: deps.ask }, { name: opts.name, slug: opts.slug });
|
|
168
178
|
if (code !== 0)
|
|
169
179
|
return code;
|
package/dist/lib/projects.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type FolderCase } from "./classifyTarget.js";
|
|
2
2
|
import { type OrgSource } from "./initFrom.js";
|
|
3
3
|
import type { ScaffoldCall } from "./initCommand.js";
|
|
4
|
+
import { type ScaffoldOutcome } from "../scaffold/index.js";
|
|
4
5
|
import type { Ask } from "../publish/prompt.js";
|
|
5
6
|
import { type ResolvedConfig } from "../shared/config.js";
|
|
6
7
|
export interface ProjectCommandDeps {
|
|
@@ -33,7 +34,7 @@ export interface CreateCommandDeps extends ProjectCommandDeps {
|
|
|
33
34
|
mkdir?: (dir: string) => Promise<void>;
|
|
34
35
|
/** Resolve a path through its symlinks, for the classify→write re-check. */
|
|
35
36
|
realpath?: (dir: string) => Promise<string>;
|
|
36
|
-
scaffold?: (dir: string, opts: ScaffoldCall) => Promise<
|
|
37
|
+
scaffold?: (dir: string, opts: ScaffoldCall) => Promise<ScaffoldOutcome>;
|
|
37
38
|
install?: (dir: string) => Promise<string>;
|
|
38
39
|
detectPm?: (dir: string) => Promise<string>;
|
|
39
40
|
/** Free-text prompt. Absent → not a TTY, so nothing may be asked. */
|
|
@@ -60,6 +61,12 @@ export interface CreateCommandOptions {
|
|
|
60
61
|
* bounded re-ask). See the throw site.
|
|
61
62
|
*/
|
|
62
63
|
rethrowSlugTaken?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* The concrete template version the scaffold above this call resolved, recorded
|
|
66
|
+
* into the binding as the drift baseline. Absent when nothing was scaffolded
|
|
67
|
+
* (registering a folder that already holds an app) or when resolution failed.
|
|
68
|
+
*/
|
|
69
|
+
templateVersion?: string;
|
|
63
70
|
}
|
|
64
71
|
/**
|
|
65
72
|
* `vincentt create` — a TWO-HALF verb with one seam.
|
package/dist/lib/projects.js
CHANGED
|
@@ -86,6 +86,10 @@ export async function createCommand(deps, opts = {}) {
|
|
|
86
86
|
let installed = true;
|
|
87
87
|
let pm = "npm";
|
|
88
88
|
let actedOn = dirLabel;
|
|
89
|
+
// Case 2 (an app already on disk) leaves this absent: this command did not
|
|
90
|
+
// scaffold that tree, so it knows nothing about which template version it came
|
|
91
|
+
// from, and inventing one is the wrong-baseline failure.
|
|
92
|
+
let templateVersion;
|
|
89
93
|
if (folderCase === "empty") {
|
|
90
94
|
const wrote = await scaffoldInto(deps, target, dirLabel, opts);
|
|
91
95
|
if (!wrote.ok)
|
|
@@ -93,6 +97,7 @@ export async function createCommand(deps, opts = {}) {
|
|
|
93
97
|
scaffolded = true;
|
|
94
98
|
installed = wrote.installed;
|
|
95
99
|
pm = wrote.pm;
|
|
100
|
+
templateVersion = wrote.templateVersion;
|
|
96
101
|
}
|
|
97
102
|
else {
|
|
98
103
|
// Case 2 writes too — a binding and a .gitignore line — so it runs the same
|
|
@@ -157,6 +162,7 @@ export async function createCommand(deps, opts = {}) {
|
|
|
157
162
|
dir: opts.dir,
|
|
158
163
|
name,
|
|
159
164
|
slug,
|
|
165
|
+
templateVersion,
|
|
160
166
|
rethrowSlugTaken: true,
|
|
161
167
|
}),
|
|
162
168
|
},
|
|
@@ -280,8 +286,9 @@ async function scaffoldInto(deps, target, dirLabel, opts) {
|
|
|
280
286
|
// which it could — which is exactly what `--template`/`--from` being refused at
|
|
281
287
|
// the parser makes structurally true, and what the install below rests on.
|
|
282
288
|
const scaffold = deps.scaffold ?? ((dir, o) => scaffoldFromTemplate(dir, o));
|
|
289
|
+
let scaffoldResult;
|
|
283
290
|
try {
|
|
284
|
-
await scaffold(target, {});
|
|
291
|
+
scaffoldResult = await scaffold(target, {});
|
|
285
292
|
}
|
|
286
293
|
catch (err) {
|
|
287
294
|
// No `✓ Created`, so no registration is attempted, and this arm carries
|
|
@@ -307,7 +314,7 @@ async function scaffoldInto(deps, target, dirLabel, opts) {
|
|
|
307
314
|
const pm = await detect(target).catch(() => "npm");
|
|
308
315
|
deps.print("");
|
|
309
316
|
deps.print(`✓ Created ${dirLabel}`);
|
|
310
|
-
return { ok: true, installed, pm };
|
|
317
|
+
return { ok: true, installed, pm, templateVersion: scaffoldResult?.templateVersion };
|
|
311
318
|
}
|
|
312
319
|
/**
|
|
313
320
|
* The registration half — the binding guard's remainder, the slug derivation, the
|
|
@@ -355,6 +362,9 @@ export async function registerProject(deps, opts = {}) {
|
|
|
355
362
|
await writeProjectBinding(target, {
|
|
356
363
|
projectId: created.projectId,
|
|
357
364
|
slug: created.slug,
|
|
365
|
+
// Omitted, never `latest`: absence is the honest no-baseline state, and it is
|
|
366
|
+
// the one that makes a later upgrade report rather than overwrite.
|
|
367
|
+
...(opts.templateVersion ? { template: { version: opts.templateVersion } } : {}),
|
|
358
368
|
});
|
|
359
369
|
deps.print(`✓ Created ${created.name} in ${org.org.name}`);
|
|
360
370
|
deps.print(` Address reserved: ${created.liveUrl}`);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The packages the platform publishes, frozen.
|
|
3
|
+
*
|
|
4
|
+
* This list is what makes `B-F17-1` a structural property rather than a promise:
|
|
5
|
+
* the resolver ITERATES it and reads only the manifests it names, so a
|
|
6
|
+
* third-party package name is never in the CLI's memory — not read, not
|
|
7
|
+
* filtered, not dropped. "Filter the creator's dependency graph" produces the
|
|
8
|
+
* same wire payload today and leaks the whole graph the first time someone
|
|
9
|
+
* spreads the intermediate value into a log line or a report.
|
|
10
|
+
*
|
|
11
|
+
* ORDER IS PART OF THE CONTRACT. Every surface that renders a version set
|
|
12
|
+
* renders it in this order — the notice's rows, `outdated`'s sections, and the
|
|
13
|
+
* report's one-line summary — so two surfaces can never disagree about
|
|
14
|
+
* sequence, and the wire field's key order is stable for the byte-identity
|
|
15
|
+
* assertion across the publish and report payloads.
|
|
16
|
+
*/
|
|
17
|
+
export interface PlatformPackage {
|
|
18
|
+
/** The package identity — the string in the creator's own dependency manifest. */
|
|
19
|
+
name: string;
|
|
20
|
+
/**
|
|
21
|
+
* The short name used only where a person reads three names in one pass (the
|
|
22
|
+
* payload preview's summary line). The WIRE always carries `name`: an agent
|
|
23
|
+
* handed `sdk` cannot find it in a `package.json`.
|
|
24
|
+
*/
|
|
25
|
+
short: string;
|
|
26
|
+
}
|
|
27
|
+
export declare const PLATFORM_PACKAGES: readonly PlatformPackage[];
|
|
28
|
+
/**
|
|
29
|
+
* The template's short name. It is NOT a package: it has no `node_modules`
|
|
30
|
+
* entry, it was copied rather than installed, and its version comes from the
|
|
31
|
+
* project binding. It is named here so the surfaces that render it last are
|
|
32
|
+
* reading one constant rather than each spelling it.
|
|
33
|
+
*/
|
|
34
|
+
export declare const TEMPLATE_COMPONENT = "template";
|
|
35
|
+
export declare function isPlatformPackage(name: string): boolean;
|
|
36
|
+
export declare function shortNameOf(name: string): string;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The packages the platform publishes, frozen.
|
|
3
|
+
*
|
|
4
|
+
* This list is what makes `B-F17-1` a structural property rather than a promise:
|
|
5
|
+
* the resolver ITERATES it and reads only the manifests it names, so a
|
|
6
|
+
* third-party package name is never in the CLI's memory — not read, not
|
|
7
|
+
* filtered, not dropped. "Filter the creator's dependency graph" produces the
|
|
8
|
+
* same wire payload today and leaks the whole graph the first time someone
|
|
9
|
+
* spreads the intermediate value into a log line or a report.
|
|
10
|
+
*
|
|
11
|
+
* ORDER IS PART OF THE CONTRACT. Every surface that renders a version set
|
|
12
|
+
* renders it in this order — the notice's rows, `outdated`'s sections, and the
|
|
13
|
+
* report's one-line summary — so two surfaces can never disagree about
|
|
14
|
+
* sequence, and the wire field's key order is stable for the byte-identity
|
|
15
|
+
* assertion across the publish and report payloads.
|
|
16
|
+
*/
|
|
17
|
+
export const PLATFORM_PACKAGES = Object.freeze([
|
|
18
|
+
Object.freeze({ name: "@vincentt-xr/sdk", short: "sdk" }),
|
|
19
|
+
Object.freeze({ name: "@vincentt-xr/harness", short: "harness" }),
|
|
20
|
+
Object.freeze({ name: "@vincentt-xr/analytics", short: "analytics" }),
|
|
21
|
+
]);
|
|
22
|
+
/**
|
|
23
|
+
* The template's short name. It is NOT a package: it has no `node_modules`
|
|
24
|
+
* entry, it was copied rather than installed, and its version comes from the
|
|
25
|
+
* project binding. It is named here so the surfaces that render it last are
|
|
26
|
+
* reading one constant rather than each spelling it.
|
|
27
|
+
*/
|
|
28
|
+
export const TEMPLATE_COMPONENT = "template";
|
|
29
|
+
export function isPlatformPackage(name) {
|
|
30
|
+
return PLATFORM_PACKAGES.some((p) => p.name === name);
|
|
31
|
+
}
|
|
32
|
+
export function shortNameOf(name) {
|
|
33
|
+
return PLATFORM_PACKAGES.find((p) => p.name === name)?.short ?? name;
|
|
34
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { PlatformRelease } from "./releaseCache.js";
|
|
2
|
+
export interface BehindRow {
|
|
3
|
+
/** The package identity — the string in the creator's own manifest. */
|
|
4
|
+
name: string;
|
|
5
|
+
installed: string;
|
|
6
|
+
current: string;
|
|
7
|
+
/** Whether the platform authored a note. NEVER the note itself. */
|
|
8
|
+
hasMigrationNote: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface BehindNotice {
|
|
11
|
+
/** The release identifier, stated because this is what the notice fires once per. */
|
|
12
|
+
release: string;
|
|
13
|
+
rows: BehindRow[];
|
|
14
|
+
/** How many rows carry an authored note. Counted, never quoted. */
|
|
15
|
+
noteCount: number;
|
|
16
|
+
}
|
|
17
|
+
export interface InstalledSet {
|
|
18
|
+
/** Keyed by package identity. */
|
|
19
|
+
packages: Record<string, string>;
|
|
20
|
+
/** The recorded template baseline. Absent = not recorded. */
|
|
21
|
+
template?: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Build the notice struct, or `undefined` when there is nothing to say.
|
|
25
|
+
*
|
|
26
|
+
* A component the composed set does not mention is NOT BEHIND and is not
|
|
27
|
+
* reported: on the day this ships every component is absent, which is neither
|
|
28
|
+
* behind nor up to date, and describing it would put a row of nothing in front
|
|
29
|
+
* of every creator. Absent is absent — never inferred, never rendered as a
|
|
30
|
+
* value, never dressed up as a state.
|
|
31
|
+
*/
|
|
32
|
+
export declare function behindFor(installed: InstalledSet, current: PlatformRelease | undefined): BehindNotice | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* Whether this machine has already announced this release.
|
|
35
|
+
*
|
|
36
|
+
* Equality on the identifier, and the announced value is READ FROM THE CACHE
|
|
37
|
+
* rather than derived. Time governs how often we ask; the fact governs whether
|
|
38
|
+
* we speak — so the second run today is silent, the fortieth is silent, and so
|
|
39
|
+
* is tomorrow's, until the identifier changes.
|
|
40
|
+
*/
|
|
41
|
+
export declare function alreadyAnnounced(announcedRelease: string | undefined, release: string): boolean;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The comparison: this project's versions against what the platform publishes.
|
|
3
|
+
*
|
|
4
|
+
* THE OUTPUT TYPE CARRIES NO COMMAND STRING, NO URL, AND NO NOTE BODY. That is
|
|
5
|
+
* the primary guard on the unbidden notice and it is structural: an instruction
|
|
6
|
+
* is UNRENDERABLE rather than merely unwritten, because the renderer is never
|
|
7
|
+
* handed anything it could instruct with. A prose guard over the rendered lines
|
|
8
|
+
* is the second layer, not the first — a single-regex guard on exactly this
|
|
9
|
+
* class shipped once and six phrasings walked past it.
|
|
10
|
+
*
|
|
11
|
+
* `hasMigrationNote` is a BOOLEAN for the same reason. A count says "there are
|
|
12
|
+
* instructions somewhere" and leaves the reader to ask for them; the body would
|
|
13
|
+
* say "here are the instructions", which is the sentence an autonomous caller
|
|
14
|
+
* acts on.
|
|
15
|
+
*
|
|
16
|
+
* NOTHING BRANCHES ON A VERSION. Equality decides behind-or-current, and the
|
|
17
|
+
* release identifier is compared for equality only. There is no ordering
|
|
18
|
+
* comparison anywhere in this module: `YYYY.MM.N` is an identifier, and an
|
|
19
|
+
* installed version is evidence rather than authorization.
|
|
20
|
+
*/
|
|
21
|
+
import { PLATFORM_PACKAGES, TEMPLATE_COMPONENT } from "./allowlist.js";
|
|
22
|
+
/**
|
|
23
|
+
* Build the notice struct, or `undefined` when there is nothing to say.
|
|
24
|
+
*
|
|
25
|
+
* A component the composed set does not mention is NOT BEHIND and is not
|
|
26
|
+
* reported: on the day this ships every component is absent, which is neither
|
|
27
|
+
* behind nor up to date, and describing it would put a row of nothing in front
|
|
28
|
+
* of every creator. Absent is absent — never inferred, never rendered as a
|
|
29
|
+
* value, never dressed up as a state.
|
|
30
|
+
*/
|
|
31
|
+
export function behindFor(installed, current) {
|
|
32
|
+
if (current === undefined)
|
|
33
|
+
return undefined;
|
|
34
|
+
const rows = [];
|
|
35
|
+
// ALLOWLIST ORDER, not object order, so every surface renders the same
|
|
36
|
+
// sequence and two surfaces can never disagree.
|
|
37
|
+
for (const pkg of PLATFORM_PACKAGES) {
|
|
38
|
+
const have = installed.packages[pkg.name];
|
|
39
|
+
const component = current.components[pkg.name];
|
|
40
|
+
if (have === undefined || component === undefined)
|
|
41
|
+
continue;
|
|
42
|
+
if (have === component.version)
|
|
43
|
+
continue;
|
|
44
|
+
rows.push({
|
|
45
|
+
name: pkg.name,
|
|
46
|
+
installed: have,
|
|
47
|
+
current: component.version,
|
|
48
|
+
hasMigrationNote: component.migration !== undefined,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
// The template last, and it is not a package: printed bare, which is also how
|
|
52
|
+
// it distinguishes itself as the one entry that is not a dependency.
|
|
53
|
+
const templateCurrent = current.components[TEMPLATE_COMPONENT];
|
|
54
|
+
if (installed.template !== undefined &&
|
|
55
|
+
templateCurrent !== undefined &&
|
|
56
|
+
installed.template !== templateCurrent.version) {
|
|
57
|
+
rows.push({
|
|
58
|
+
name: TEMPLATE_COMPONENT,
|
|
59
|
+
installed: installed.template,
|
|
60
|
+
current: templateCurrent.version,
|
|
61
|
+
hasMigrationNote: templateCurrent.migration !== undefined,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (rows.length === 0)
|
|
65
|
+
return undefined;
|
|
66
|
+
return {
|
|
67
|
+
release: current.release,
|
|
68
|
+
rows,
|
|
69
|
+
noteCount: rows.filter((r) => r.hasMigrationNote).length,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Whether this machine has already announced this release.
|
|
74
|
+
*
|
|
75
|
+
* Equality on the identifier, and the announced value is READ FROM THE CACHE
|
|
76
|
+
* rather than derived. Time governs how often we ask; the fact governs whether
|
|
77
|
+
* we speak — so the second run today is silent, the fortieth is silent, and so
|
|
78
|
+
* is tomorrow's, until the identifier changes.
|
|
79
|
+
*/
|
|
80
|
+
export function alreadyAnnounced(announcedRelease, release) {
|
|
81
|
+
return announcedRelease === release;
|
|
82
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { InstalledSet } from "./behind.js";
|
|
2
|
+
export interface OutdatedDeps {
|
|
3
|
+
print: (line: string) => void;
|
|
4
|
+
cwd: string;
|
|
5
|
+
now: () => number;
|
|
6
|
+
/** The API origin, and the base of the address printed to the creator. */
|
|
7
|
+
apiUrl: () => Promise<string>;
|
|
8
|
+
/** `Name · Workspace` when this folder is a project; undefined outside one. */
|
|
9
|
+
projectLine: () => Promise<string | undefined>;
|
|
10
|
+
/** Whether this folder resolves a project binding. */
|
|
11
|
+
inProject: () => Promise<boolean>;
|
|
12
|
+
fetchImpl?: typeof fetch;
|
|
13
|
+
}
|
|
14
|
+
export declare function outdatedCommand(deps: OutdatedDeps): Promise<number>;
|
|
15
|
+
/** Nothing installed and no baseline → the flat "what is current" answer still prints. */
|
|
16
|
+
export declare function hasAnyInstalled(installed: InstalledSet): boolean;
|
|
@@ -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
|
+
}
|