@vincentt-xr/cli 0.10.0 → 0.12.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/index.js CHANGED
@@ -45,6 +45,8 @@ import { DX_UNATTENDED_SUNSET, loadEnablement } from "../dx/enablement.js";
45
45
  import { journalEnabled, journalPath, readJournal } from "../dx/journal.js";
46
46
  import { postReport } from "../dx/send.js";
47
47
  import { nudgeFor } from "../dx/nudge.js";
48
+ import { FETCH_TIMEOUT_MS, isFresh, updateNotice } from "../dx/updateCheck.js";
49
+ import { fetchLatest, loadCache, saveCache } from "../dx/updateCheckIo.js";
48
50
  import { verbOf } from "../dx/skeleton.js";
49
51
  import { createSourceFlagRefusal, fromFlagConflict, isHelpInvocation, isVersionInvocation, positionalArgs, refusedFlag, } from "./args.js";
50
52
  import { parseSourceRef } from "../lib/sourceRef.js";
@@ -540,7 +542,7 @@ async function preview() {
540
542
  orgName: await activeOrgName(),
541
543
  targetUrl: `http://localhost:${running.appPort}`,
542
544
  url: running.url,
543
- consoleUrl: await projectConsoleUrl(cwd),
545
+ consoleUrl: await projectConsoleUrl(cwd, running.sessionId),
544
546
  qr,
545
547
  expiresInHours: 12,
546
548
  })) {
@@ -657,14 +659,26 @@ async function projectDisplayName(cwd) {
657
659
  * `consoleVerbCommand` deep-links to. The console redirects it to the org-scoped
658
660
  * address, so the CLI does not need the org slug and cannot print a stale one — a
659
661
  * folder can outlive the org binding the machine config happens to hold.
662
+ *
663
+ * `sessionId` deep-links PAST the project's default tab to the running preview
664
+ * itself (`?tab=preview&session=<id>`), which is the surface the creator is being
665
+ * handed this link to watch. Omitted when there is no session — the bare project
666
+ * page is right for `link`/`create`, and a session parameter naming nothing would
667
+ * land on an empty workspace.
660
668
  */
661
- async function projectConsoleUrl(cwd) {
669
+ async function projectConsoleUrl(cwd, sessionId) {
662
670
  const located = await locateProjectBinding(cwd);
663
671
  if (!located)
664
672
  return undefined;
665
673
  const cfg = await loadMachineConfig();
666
674
  const base = (cfg.consoleUrl ?? DEFAULT_CONSOLE_URL).replace(/\/$/, "");
667
- return `${base}/projects/${located.binding.projectId}`;
675
+ const url = `${base}/projects/${located.binding.projectId}`;
676
+ if (!sessionId)
677
+ return url;
678
+ // encodeURIComponent on BOTH: the ids are server-minted and hex today, and a
679
+ // future id shape carrying a reserved character must not become a second
680
+ // parameter.
681
+ return `${url}?tab=preview&session=${encodeURIComponent(sessionId)}`;
668
682
  }
669
683
  /**
670
684
  * Map a mint failure to its screen. Each cause has a DISTINCT first line so the
@@ -1161,6 +1175,41 @@ function journal(exitCode, error) {
1161
1175
  catch {
1162
1176
  // A hint that can break a publish is not worth having.
1163
1177
  }
1178
+ // The update notice prints from the LAST run's cached answer and never waits
1179
+ // on the registry, so no verb pays for this. The consequence is that a fresh
1180
+ // install stays quiet on its first run and speaks on the next one — which is
1181
+ // the right trade: a creator who just installed is by definition current, and
1182
+ // a notice is worth nothing if it costs a second on every publish.
1183
+ try {
1184
+ const cached = loadCache();
1185
+ for (const l of updateNotice(cliVersion(), cached?.latest))
1186
+ console.error(l);
1187
+ if (!isFresh(cached, Date.now()))
1188
+ refreshUpdateCache();
1189
+ }
1190
+ catch {
1191
+ // Same rule as the nudge: never break a run over a footnote.
1192
+ }
1193
+ }
1194
+ /**
1195
+ * Refresh the cached `latest` without holding the process open.
1196
+ *
1197
+ * `unref` is the load-bearing call. Without it a 2s registry request keeps the
1198
+ * event loop alive after the verb is done, so every command appears to hang for
1199
+ * up to two seconds — a CLI that feels slower in exchange for an occasional
1200
+ * notice is a bad trade, and the delay would land on `preview`, where someone is
1201
+ * waiting to open a URL on a phone.
1202
+ */
1203
+ function refreshUpdateCache() {
1204
+ const timer = setTimeout(() => { }, FETCH_TIMEOUT_MS + 500);
1205
+ timer.unref?.();
1206
+ void fetchLatest()
1207
+ .then((latest) => {
1208
+ if (latest !== undefined)
1209
+ saveCache({ latest, at: Date.now() });
1210
+ })
1211
+ .catch(() => { })
1212
+ .finally(() => clearTimeout(timer));
1164
1213
  }
1165
1214
  main()
1166
1215
  .then(() => {
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The update notice: a one-line fact, printed after a run, saying a newer CLI
3
+ * exists.
4
+ *
5
+ * The CLI is installed globally (`npm install -g @vincentt-xr/cli`), so there is
6
+ * one copy per machine and nothing about a project ever updates it. The install
7
+ * instruction in the scaffolded contract fires only when the binary is MISSING,
8
+ * which makes it a first-install path and never an upgrade path: a creator who
9
+ * installed in July keeps running July's CLI indefinitely, and the failures that
10
+ * follow a wire change read as platform bugs.
11
+ *
12
+ * STATES A FACT, NEVER AN INSTRUCTION — the same rule as the dx nudge, for the
13
+ * same reason. This line lands in an agent's transcript, where the CLI's stdout
14
+ * is the agent's input, and `npm install -g ...@latest` is a directive an
15
+ * autonomous caller will simply obey. An agent that updates the binary mid-run
16
+ * has swapped the tool underneath itself and may need a sudo the human never
17
+ * approved. Naming that a newer version exists is information the caller can act
18
+ * on; telling it to install is a decision that is not the CLI's to make.
19
+ */
20
+ /** How long a registry answer is trusted. One notice a day, not one per run. */
21
+ export declare const CHECK_TTL_MS: number;
22
+ /** Registry request budget. A slow npm must never slow a verb down. */
23
+ export declare const FETCH_TIMEOUT_MS = 2000;
24
+ export interface UpdateCache {
25
+ /** The `latest` dist-tag as last seen. */
26
+ latest: string;
27
+ /** When it was fetched (epoch ms). */
28
+ at: number;
29
+ }
30
+ /**
31
+ * True when `latest` is strictly newer than `current`.
32
+ *
33
+ * Prerelease suffixes are deliberately ignored beyond the numeric core: the
34
+ * comparison only decides whether to print one line, and a creator running
35
+ * `0.11.0-rc.1` against a `0.11.0` latest is not someone to nag.
36
+ */
37
+ export declare function isNewer(current: string, latest: string): boolean;
38
+ /**
39
+ * Decide whether a cached answer is still usable. A cache written in the future
40
+ * (a clock that moved) is treated as stale rather than trusted forever.
41
+ */
42
+ export declare function isFresh(cache: UpdateCache | undefined, now: number): boolean;
43
+ /**
44
+ * Read a cache object defensively. STRICT about types for the same reason the
45
+ * enablement reader is: a hand-edited or half-written file must degrade to "no
46
+ * cache" rather than to a bogus comparison.
47
+ */
48
+ export declare function readCache(raw: unknown): UpdateCache | undefined;
49
+ /**
50
+ * The notice. Empty when there is nothing to say.
51
+ *
52
+ * Pure, so the transcript is testable without a network, a home directory, or a
53
+ * published package.
54
+ */
55
+ export declare function updateNotice(current: string, latest: string | undefined): string[];
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The update notice: a one-line fact, printed after a run, saying a newer CLI
3
+ * exists.
4
+ *
5
+ * The CLI is installed globally (`npm install -g @vincentt-xr/cli`), so there is
6
+ * one copy per machine and nothing about a project ever updates it. The install
7
+ * instruction in the scaffolded contract fires only when the binary is MISSING,
8
+ * which makes it a first-install path and never an upgrade path: a creator who
9
+ * installed in July keeps running July's CLI indefinitely, and the failures that
10
+ * follow a wire change read as platform bugs.
11
+ *
12
+ * STATES A FACT, NEVER AN INSTRUCTION — the same rule as the dx nudge, for the
13
+ * same reason. This line lands in an agent's transcript, where the CLI's stdout
14
+ * is the agent's input, and `npm install -g ...@latest` is a directive an
15
+ * autonomous caller will simply obey. An agent that updates the binary mid-run
16
+ * has swapped the tool underneath itself and may need a sudo the human never
17
+ * approved. Naming that a newer version exists is information the caller can act
18
+ * on; telling it to install is a decision that is not the CLI's to make.
19
+ */
20
+ /** How long a registry answer is trusted. One notice a day, not one per run. */
21
+ export const CHECK_TTL_MS = 24 * 60 * 60 * 1000;
22
+ /** Registry request budget. A slow npm must never slow a verb down. */
23
+ export const FETCH_TIMEOUT_MS = 2000;
24
+ /**
25
+ * Parse a semver-ish version into comparable parts. Returns undefined for
26
+ * anything unrecognized, which callers treat as "cannot compare" rather than as
27
+ * zero — a version that failed to parse must not read as older than everything.
28
+ */
29
+ function parts(v) {
30
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
31
+ if (m === null)
32
+ return undefined;
33
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
34
+ }
35
+ /**
36
+ * True when `latest` is strictly newer than `current`.
37
+ *
38
+ * Prerelease suffixes are deliberately ignored beyond the numeric core: the
39
+ * comparison only decides whether to print one line, and a creator running
40
+ * `0.11.0-rc.1` against a `0.11.0` latest is not someone to nag.
41
+ */
42
+ export function isNewer(current, latest) {
43
+ const c = parts(current);
44
+ const l = parts(latest);
45
+ if (c === undefined || l === undefined)
46
+ return false;
47
+ for (let i = 0; i < 3; i++) {
48
+ if (l[i] > c[i])
49
+ return true;
50
+ if (l[i] < c[i])
51
+ return false;
52
+ }
53
+ return false;
54
+ }
55
+ /**
56
+ * Decide whether a cached answer is still usable. A cache written in the future
57
+ * (a clock that moved) is treated as stale rather than trusted forever.
58
+ */
59
+ export function isFresh(cache, now) {
60
+ if (cache === undefined)
61
+ return false;
62
+ if (!Number.isFinite(cache.at))
63
+ return false;
64
+ const age = now - cache.at;
65
+ return age >= 0 && age < CHECK_TTL_MS;
66
+ }
67
+ /**
68
+ * Read a cache object defensively. STRICT about types for the same reason the
69
+ * enablement reader is: a hand-edited or half-written file must degrade to "no
70
+ * cache" rather than to a bogus comparison.
71
+ */
72
+ export function readCache(raw) {
73
+ if (typeof raw !== "object" || raw === null)
74
+ return undefined;
75
+ const { latest, at } = raw;
76
+ if (typeof latest !== "string" || latest === "")
77
+ return undefined;
78
+ if (typeof at !== "number" || !Number.isFinite(at))
79
+ return undefined;
80
+ return { latest, at };
81
+ }
82
+ /**
83
+ * The notice. Empty when there is nothing to say.
84
+ *
85
+ * Pure, so the transcript is testable without a network, a home directory, or a
86
+ * published package.
87
+ */
88
+ export function updateNotice(current, latest) {
89
+ if (latest === undefined)
90
+ return [];
91
+ if (!isNewer(current, latest))
92
+ return [];
93
+ return [
94
+ "",
95
+ ` ! A newer vincentt is available (${current} → ${latest}).`,
96
+ " Installed globally as @vincentt-xr/cli.",
97
+ ];
98
+ }
@@ -0,0 +1,18 @@
1
+ import { type UpdateCache } from "./updateCheck.js";
2
+ export declare function cachePath(): string;
3
+ /** Read the cached answer. Any fault reads as "no cache". */
4
+ export declare function loadCache(): UpdateCache | undefined;
5
+ /**
6
+ * Write the cached answer. Best-effort and non-atomic, unlike the machine
7
+ * config: this file holds no secret and no state a creator would miss, so a
8
+ * torn write costs one extra registry call rather than a lost token.
9
+ */
10
+ export declare function saveCache(cache: UpdateCache): void;
11
+ /**
12
+ * Ask the registry for the `latest` dist-tag. Undefined on any fault.
13
+ *
14
+ * The Accept header requests npm's abbreviated document, which is a fraction of
15
+ * the full package metadata — the full one for a package with many versions is
16
+ * megabytes, and this runs on someone's laptop after every verb.
17
+ */
18
+ export declare function fetchLatest(signal?: AbortSignal): Promise<string | undefined>;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The I/O half of the update notice: the cache file and the registry fetch.
3
+ *
4
+ * Split from the decision logic in updateCheck.ts so the transcript and the
5
+ * comparison stay testable without a network or a home directory.
6
+ *
7
+ * Every operation here is best-effort. A creator's `publish` must not fail, slow
8
+ * down, or print a stack trace because the npm registry was unreachable or
9
+ * ~/.vincentt was read-only — the worst outcome of this whole feature going dark
10
+ * is that nobody is told about an update, which is exactly where they were
11
+ * before it existed.
12
+ */
13
+ import fs from "node:fs";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { FETCH_TIMEOUT_MS, readCache } from "./updateCheck.js";
17
+ const CACHE_FILE = "update-check.json";
18
+ /** The npm registry's package document, filtered to the dist-tags it serves. */
19
+ const REGISTRY_URL = "https://registry.npmjs.org/@vincentt-xr%2Fcli";
20
+ export function cachePath() {
21
+ return path.join(os.homedir(), ".vincentt", CACHE_FILE);
22
+ }
23
+ /** Read the cached answer. Any fault reads as "no cache". */
24
+ export function loadCache() {
25
+ try {
26
+ return readCache(JSON.parse(fs.readFileSync(cachePath(), "utf8")));
27
+ }
28
+ catch {
29
+ return undefined;
30
+ }
31
+ }
32
+ /**
33
+ * Write the cached answer. Best-effort and non-atomic, unlike the machine
34
+ * config: this file holds no secret and no state a creator would miss, so a
35
+ * torn write costs one extra registry call rather than a lost token.
36
+ */
37
+ export function saveCache(cache) {
38
+ try {
39
+ fs.mkdirSync(path.dirname(cachePath()), { recursive: true });
40
+ fs.writeFileSync(cachePath(), JSON.stringify(cache) + "\n", "utf8");
41
+ }
42
+ catch {
43
+ // A cache that cannot be written just means the next run asks again.
44
+ }
45
+ }
46
+ /**
47
+ * Ask the registry for the `latest` dist-tag. Undefined on any fault.
48
+ *
49
+ * The Accept header requests npm's abbreviated document, which is a fraction of
50
+ * the full package metadata — the full one for a package with many versions is
51
+ * megabytes, and this runs on someone's laptop after every verb.
52
+ */
53
+ export async function fetchLatest(signal) {
54
+ const controller = new AbortController();
55
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
56
+ const onAbort = () => controller.abort();
57
+ signal?.addEventListener("abort", onAbort, { once: true });
58
+ try {
59
+ const res = await fetch(REGISTRY_URL, {
60
+ signal: controller.signal,
61
+ headers: {
62
+ Accept: "application/vnd.npm.install-v1+json",
63
+ },
64
+ });
65
+ if (!res.ok)
66
+ return undefined;
67
+ const body = (await res.json());
68
+ const latest = body["dist-tags"]?.latest;
69
+ return typeof latest === "string" && latest !== "" ? latest : undefined;
70
+ }
71
+ catch {
72
+ return undefined;
73
+ }
74
+ finally {
75
+ clearTimeout(timer);
76
+ signal?.removeEventListener("abort", onAbort);
77
+ }
78
+ }
@@ -99,9 +99,18 @@ export declare function stopped(ranMs: number, totalConnections: number): string
99
99
  *
100
100
  * The claim is exactly what the wire supports: `stopped` is produced by one
101
101
  * route, authorized by the compound key {_id, userId}, so the actor is ALWAYS
102
- * the same account as the reader. Never a teammate, never an admin. It does not
103
- * say WHICH window, because another terminal, another machine and a future
104
- * console control are all the same 410.
102
+ * the same account as the reader. Never a teammate, never an admin.
103
+ *
104
+ * IT NAMES NO SURFACE, and that is the whole design of this screen. `endReason`
105
+ * carries ONE value for every actor on that route — this CLI's own stop, another
106
+ * terminal, another machine, the console's Stop control, and any surface added
107
+ * later. The API does not learn which one called and must not: deriving it would
108
+ * mean guessing a surface from a credential kind (a PAT-driven script is not "a
109
+ * terminal") and reporting the guess to the creator as fact. That is the
110
+ * over-claim class that produced the abandoned→hard_max defect.
111
+ *
112
+ * So the headline states only the event, and the second line describes the actor
113
+ * exactly as far as the compound key supports: the same account, not this window.
105
114
  *
106
115
  * `✓`, not `✗`: someone with the authority to stop it stopped it.
107
116
  */
@@ -205,16 +205,25 @@ export function stopped(ranMs, totalConnections) {
205
205
  *
206
206
  * The claim is exactly what the wire supports: `stopped` is produced by one
207
207
  * route, authorized by the compound key {_id, userId}, so the actor is ALWAYS
208
- * the same account as the reader. Never a teammate, never an admin. It does not
209
- * say WHICH window, because another terminal, another machine and a future
210
- * console control are all the same 410.
208
+ * the same account as the reader. Never a teammate, never an admin.
209
+ *
210
+ * IT NAMES NO SURFACE, and that is the whole design of this screen. `endReason`
211
+ * carries ONE value for every actor on that route — this CLI's own stop, another
212
+ * terminal, another machine, the console's Stop control, and any surface added
213
+ * later. The API does not learn which one called and must not: deriving it would
214
+ * mean guessing a surface from a credential kind (a PAT-driven script is not "a
215
+ * terminal") and reporting the guess to the creator as fact. That is the
216
+ * over-claim class that produced the abandoned→hard_max defect.
217
+ *
218
+ * So the headline states only the event, and the second line describes the actor
219
+ * exactly as far as the compound key supports: the same account, not this window.
211
220
  *
212
221
  * `✓`, not `✗`: someone with the authority to stop it stopped it.
213
222
  */
214
223
  export function stoppedElsewhere() {
215
224
  return [
216
225
  "",
217
- "✓ Preview stopped from another terminal. The link no longer works.",
226
+ "✓ Preview stopped. The link no longer works.",
218
227
  "",
219
228
  " It was stopped on your account, not by this window. Nothing is wrong here.",
220
229
  "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentt-xr/cli",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Vincentt CLI — the vincentt command-line dev loop (login, create, publish, preview, diagnostics, feedback, relay)",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",