@vincentt-xr/cli 0.16.0 → 0.18.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 (95) hide show
  1. package/MIGRATION.md +24 -0
  2. package/README.md +94 -1
  3. package/dist/cli/index.js +196 -87
  4. package/dist/cli/usage-text.js +32 -10
  5. package/dist/domain/api.d.ts +10 -8
  6. package/dist/domain/api.js +14 -9
  7. package/dist/domain/command.js +25 -16
  8. package/dist/domain/copy.d.ts +6 -3
  9. package/dist/domain/copy.js +10 -6
  10. package/dist/dx/command.d.ts +24 -4
  11. package/dist/dx/command.js +80 -25
  12. package/dist/dx/consent.js +6 -5
  13. package/dist/dx/journal.d.ts +17 -2
  14. package/dist/dx/journal.js +19 -3
  15. package/dist/dx/report.js +4 -3
  16. package/dist/dx/reportIo.js +6 -5
  17. package/dist/dx/send.js +6 -5
  18. package/dist/lib/backend.d.ts +0 -16
  19. package/dist/lib/backend.js +2 -26
  20. package/dist/lib/detect.d.ts +87 -0
  21. package/dist/lib/detect.js +867 -0
  22. package/dist/lib/diagnostics.js +2 -2
  23. package/dist/lib/feedback.js +1 -1
  24. package/dist/lib/feedbackCopy.js +1 -1
  25. package/dist/lib/initFrom.js +4 -4
  26. package/dist/lib/lifecycle.d.ts +2 -0
  27. package/dist/lib/lifecycle.js +9 -3
  28. package/dist/lib/listProjects.js +2 -1
  29. package/dist/lib/projects.d.ts +37 -2
  30. package/dist/lib/projects.js +86 -21
  31. package/dist/lib/settings.d.ts +113 -0
  32. package/dist/lib/settings.js +284 -0
  33. package/dist/lib/settingsCopy.d.ts +6 -0
  34. package/dist/lib/settingsCopy.js +99 -0
  35. package/dist/lib/settle.d.ts +61 -0
  36. package/dist/lib/settle.js +338 -0
  37. package/dist/lib/template.d.ts +19 -0
  38. package/dist/lib/template.js +73 -0
  39. package/dist/lib/terminalText.d.ts +3 -0
  40. package/dist/lib/terminalText.js +10 -0
  41. package/dist/lib/version.js +5 -11
  42. package/dist/lib/whoami.js +10 -3
  43. package/dist/library/assetsCommand.js +1 -1
  44. package/dist/library/client.js +8 -3
  45. package/dist/library/render.js +8 -6
  46. package/dist/login/authBackend.js +3 -2
  47. package/dist/login/ensure.d.ts +8 -2
  48. package/dist/login/ensure.js +21 -10
  49. package/dist/login/guard.js +5 -4
  50. package/dist/login/loginCommand.d.ts +2 -2
  51. package/dist/login/loginCommand.js +29 -5
  52. package/dist/login/pkce.js +1 -1
  53. package/dist/login/pkceLogin.js +1 -1
  54. package/dist/platform/outdated.js +2 -2
  55. package/dist/platform/upgradeReport.js +16 -12
  56. package/dist/preview/api.d.ts +24 -11
  57. package/dist/preview/api.js +42 -15
  58. package/dist/preview/command.d.ts +29 -0
  59. package/dist/preview/command.js +61 -1
  60. package/dist/preview/copy.d.ts +35 -3
  61. package/dist/preview/copy.js +102 -10
  62. package/dist/preview/proxy.d.ts +38 -1
  63. package/dist/preview/proxy.js +151 -2
  64. package/dist/preview/qrPage.js +1 -1
  65. package/dist/preview/runner.d.ts +25 -13
  66. package/dist/preview/runner.js +127 -26
  67. package/dist/preview/viewers.js +1 -1
  68. package/dist/publish/api.d.ts +39 -6
  69. package/dist/publish/api.js +80 -9
  70. package/dist/publish/command.d.ts +25 -0
  71. package/dist/publish/command.js +230 -32
  72. package/dist/publish/copy.d.ts +78 -5
  73. package/dist/publish/copy.js +271 -37
  74. package/dist/publish/fit.d.ts +15 -0
  75. package/dist/publish/fit.js +103 -0
  76. package/dist/publish/manifest.d.ts +44 -3
  77. package/dist/publish/manifest.js +172 -3
  78. package/dist/publish/prompt.d.ts +4 -0
  79. package/dist/publish/prompt.js +15 -1
  80. package/dist/publish/upload.d.ts +6 -0
  81. package/dist/publish/upload.js +11 -1
  82. package/dist/publish/visibility.d.ts +21 -0
  83. package/dist/publish/visibility.js +68 -0
  84. package/dist/relay/annotations.js +9 -4
  85. package/dist/relay/server.d.ts +21 -0
  86. package/dist/relay/server.js +35 -1
  87. package/dist/shared/authError.d.ts +27 -0
  88. package/dist/shared/authError.js +48 -4
  89. package/dist/shared/config.d.ts +21 -1
  90. package/dist/shared/config.js +39 -8
  91. package/dist/shared/countLimit.d.ts +114 -0
  92. package/dist/shared/countLimit.js +176 -0
  93. package/dist/shared/http.d.ts +23 -0
  94. package/dist/shared/http.js +58 -0
  95. package/package.json +7 -3
@@ -1,13 +1,24 @@
1
- // First-run login guard. An auth-requiring verb (create / preview / publish) calls
2
- // this before doing any work: if there's no token yet, it runs the login flow inline
3
- // and continues, instead of failing with "not signed in". Login stays a human browser
4
- // approval — this only removes the separate `vincentt login` step a user had to know
5
- // to run first. Deps are injected so the branch is unit-testable without a real
6
- // browser round-trip or a real config file.
7
- /** No-op when a token is already present; otherwise log-and-login before returning. */
1
+ // First-run login guard. An auth-requiring verb calls this before doing any work:
2
+ // if there's no token yet, it runs the login flow inline and continues, instead of
3
+ // failing with "not signed in". Login stays a human browser approval, so it is
4
+ // offered only at a terminal. Without one the browser wait would outlast the
5
+ // caller's shell timeout, so the verb refuses at once with the login remedy.
6
+ import { NOT_SIGNED_IN_LINES } from "../shared/authError.js";
7
+ /**
8
+ * True when a token is present, or a login at the terminal produced one. False
9
+ * after printing the not-signed-in lines: the caller exits EXIT.NOT_AUTHENTICATED.
10
+ * A login that throws (cancelled, timed out) propagates.
11
+ */
8
12
  export async function ensureLoggedIn(deps) {
9
13
  if (await deps.currentToken())
10
- return;
11
- deps.notify("You're not signed in yet — opening the browser to authorize this device…");
12
- await deps.runLogin();
14
+ return true;
15
+ if (deps.interactive) {
16
+ deps.notify("You're not signed in yet. Opening the browser to authorize this device…");
17
+ await deps.runLogin();
18
+ if (await deps.currentToken())
19
+ return true;
20
+ }
21
+ for (const l of NOT_SIGNED_IN_LINES)
22
+ deps.notify(l);
23
+ return false;
13
24
  }
@@ -1,7 +1,8 @@
1
1
  // The not-signed-in voice (Flow L3). f1 defines this voice; any gated command (f2+) uses
2
2
  // it via `requireSignedIn`. When no token is present it prints the exact two-line message
3
- // and reports "not signed in" so the caller can abort with a non-zero exit — no stack trace,
4
- // no auto-browser-launch (that's `ensureLoggedIn`'s convenience path; the gate is explicit).
3
+ // and reports "not signed in" so the caller can abort with EXIT.NOT_AUTHENTICATED: no stack
4
+ // trace, no auto-browser-launch (that's `ensureLoggedIn`'s convenience path; the gate is explicit).
5
+ import { NOT_SIGNED_IN_LINES } from "../shared/authError.js";
5
6
  /**
6
7
  * True when a token is present. When absent, prints the L3 not-signed-in message and returns
7
8
  * false. The exact copy is locked by the f1 design:
@@ -11,7 +12,7 @@
11
12
  export async function requireSignedIn(deps) {
12
13
  if (await deps.currentToken())
13
14
  return true;
14
- deps.print("✗ You're not signed in.");
15
- deps.print(" Run `vincentt login` to connect this machine to your Vincentt account.");
15
+ for (const l of NOT_SIGNED_IN_LINES)
16
+ deps.print(l);
16
17
  return false;
17
18
  }
@@ -14,8 +14,8 @@ export interface LoginCommandDeps {
14
14
  print: (line: string) => void;
15
15
  /** The token currently on this machine, if any (env override or config file). */
16
16
  currentToken: () => Promise<string | undefined>;
17
- /** Confirm who a present token belongs to (GET /me). Undefined if it can't be resolved. */
18
- whoAmI: () => Promise<Identity | undefined>;
17
+ /** Confirm who the present token belongs to (GET /me). Throws the client's error unchanged. */
18
+ whoAmI: () => Promise<Identity>;
19
19
  /** Run the loopback + PKCE flow; resolves identity + the saved config path. */
20
20
  runLogin: () => Promise<{
21
21
  identity: Identity;
@@ -5,6 +5,8 @@
5
5
  //
6
6
  // The IO boundary (print, current identity, run the PKCE flow, clock) is injected so every
7
7
  // L1–L4 branch is unit-testable with no real browser, socket, or config file.
8
+ import { isAuthRefused } from "../shared/authError.js";
9
+ import { EXIT } from "../lib/exitCodes.js";
8
10
  /** Collapse the home dir to `~` for display. `~/.vincentt/config.json` in every transcript. */
9
11
  export function displayConfigPath(absPath, home) {
10
12
  if (home && absPath.startsWith(home))
@@ -42,16 +44,38 @@ export async function loginCommand(deps, opts = {}) {
42
44
  await deps.clearToken();
43
45
  }
44
46
  else {
45
- // L2 — already signed in: no browser, no re-mint.
47
+ // L2: already signed in, but only once the api confirms the token.
46
48
  const existing = await deps.currentToken();
47
49
  if (existing) {
48
- const who = await deps.whoAmI().catch(() => undefined);
49
- const email = who?.email ?? "your account";
50
- deps.print(`✓ Already signed in as ${email} (token in ${displayPath})`);
50
+ let who;
51
+ try {
52
+ who = await deps.whoAmI();
53
+ }
54
+ catch (err) {
55
+ if (!isTokenRejected(err)) {
56
+ // A down api or a dead network says nothing about the token, so it stays.
57
+ deps.print("✗ Could not check the account for the token on this machine.");
58
+ deps.print(` The token in ${displayPath} was kept.`);
59
+ deps.print(" Check your connection, then run `vincentt login` again.");
60
+ return EXIT.FAILED;
61
+ }
62
+ deps.print("→ The stored token is no longer valid. Signing in again…");
63
+ await deps.clearToken();
64
+ return runLoginFlow(deps);
65
+ }
66
+ deps.print(`✓ Already signed in as ${who.email} (token in ${displayPath})`);
51
67
  deps.print(" Re-run with `vincentt login --force` to sign in as someone else.");
52
- return 0;
68
+ return EXIT.OK;
53
69
  }
54
70
  }
71
+ return runLoginFlow(deps);
72
+ }
73
+ /** 403 counts too: a token the api knows but will not honor is as unusable as an unknown one. */
74
+ function isTokenRejected(err) {
75
+ return (isAuthRefused(err) ||
76
+ (err instanceof Error && err.status === 403));
77
+ }
78
+ async function runLoginFlow(deps) {
55
79
  // L1 / L4 — run the loopback + PKCE flow.
56
80
  try {
57
81
  const { identity, configPath } = await deps.runLogin();
@@ -81,7 +81,7 @@ export function parseCallback(params, expectedState) {
81
81
  return {
82
82
  ok: false,
83
83
  reason: "state_mismatch",
84
- message: "State mismatch — ignoring an unexpected callback.",
84
+ message: "State mismatch. Ignoring an unexpected callback.",
85
85
  };
86
86
  }
87
87
  const error = params.get("error");
@@ -130,7 +130,7 @@ export async function runPkceLogin(opts) {
130
130
  value: result.code,
131
131
  page: {
132
132
  status: 200,
133
- html: browserPage(true, "You're all set", "Return to your terminal — you can close this tab."),
133
+ html: browserPage(true, "You're all set", "Return to your terminal. You can close this tab."),
134
134
  },
135
135
  };
136
136
  }
@@ -106,7 +106,7 @@ export function renderOutdated(view) {
106
106
  // no install command — the creator's package manager is theirs and we do not
107
107
  // know which — and it does not print `vincentt update`, because no such verb
108
108
  // exists and must not appear to.
109
- lines.push("", " Editing package.json and installing is the whole dependency upgrade. Ask your", " agent, or do it by hand — the strings to place are above.");
109
+ lines.push("", " Editing package.json and installing is the whole dependency upgrade. Ask your", " agent, or do it by hand; the strings to place are above.");
110
110
  }
111
111
  return lines;
112
112
  }
@@ -165,7 +165,7 @@ function templateBlock(view) {
165
165
  // a stronger statement than silence and costs two lines.
166
166
  lines.push(` current ${current.version}`);
167
167
  if (from !== current.version) {
168
- lines.push(" The template is source that was copied into this repository when the project", " was created. Some of it is platform plumbing you never wrote — the app shell,", " the helpers, the build config. `src/Scene.tsx` and everything you added is", " yours, and nothing reports on it but this line.");
168
+ lines.push(" The template is source that was copied into this repository when the project", " was created. Some of it is platform plumbing you never wrote: the app shell,", " the helpers, the build config. `src/Scene.tsx` and everything you added is", " yours, and nothing reports on it but this line.");
169
169
  }
170
170
  return lines;
171
171
  }
@@ -48,7 +48,7 @@ export function renderUpgradeReport(r) {
48
48
  ` Compared against template ${r.comparedAgainst ?? "unknown"}, as recorded in this project.`,
49
49
  ];
50
50
  if (r.changed.length > 0) {
51
- lines.push("", " REPLACED — AND YOU HAD CHANGED THESE", "");
51
+ lines.push("", " REPLACED, AND YOU HAD CHANGED THESE", "");
52
52
  // NEVER COMPRESSED, at any length: one file per line, always.
53
53
  const width = Math.max(...r.changed.map((f) => f.path.length));
54
54
  for (const f of r.changed) {
@@ -57,22 +57,22 @@ export function renderUpgradeReport(r) {
57
57
  lines.push(...unrecoverableBlock(r.changed));
58
58
  }
59
59
  if (r.unchanged.length > 0 || r.unchangedTestCount > 0) {
60
- lines.push("", ` REPLACED — UNCHANGED BY YOU, compared against ${r.comparedAgainst ?? "the recorded baseline"}`, "", ...middotList(r.unchanged, r.unchangedTestCount));
60
+ lines.push("", ` REPLACED, UNCHANGED BY YOU, compared against ${r.comparedAgainst ?? "the recorded baseline"}`, "", ...middotList(r.unchanged, r.unchangedTestCount));
61
61
  }
62
- lines.push("", " LEFT ALONE — YOURS", "",
62
+ lines.push("", " LEFT ALONE: YOURS", "",
63
63
  // Three claims, not one. Not-written is the weak claim; NOT-READ is the
64
64
  // strong one, and it is true — the procedure never has the creator's scene
65
65
  // content in its context.
66
66
  " src/Scene.tsx, and every file you added. None of them was read, compared,", " or written by this upgrade.");
67
67
  if (r.deletions.length > 0) {
68
- lines.push("", ` NOT PART OF ${r.to} — STILL IN YOUR PROJECT`, "");
68
+ lines.push("", ` NOT PART OF ${r.to}: STILL IN YOUR PROJECT`, "");
69
69
  const width = Math.max(...r.deletions.map((p) => p.length));
70
70
  for (const p of r.deletions) {
71
71
  lines.push(` ${p.padEnd(width)} ${r.to} does not carry this file. It has not been`, ` ${" ".repeat(width)} removed. Nothing in ${r.to} imports it.`);
72
72
  }
73
73
  }
74
74
  if (r.notPermitted.length > 0) {
75
- lines.push("", " NOT UPGRADED — NOT PERMITTED TO BE", "", ` ${r.notPermitted.join(" · ")}`, " These run on your machine, so they are never replaced by this procedure.", ` ${r.to}'s versions differ from the ones in this project.`);
75
+ lines.push("", " NOT UPGRADED: NOT PERMITTED TO BE", "", ` ${r.notPermitted.join(" · ")}`, " These run on your machine, so they are never replaced by this procedure.", ` ${r.to}'s versions differ from the ones in this project.`);
76
76
  }
77
77
  lines.push("", ...NOT_VERIFIED);
78
78
  return lines;
@@ -86,7 +86,9 @@ export function renderUpgradeReport(r) {
86
86
  * operationalise.
87
87
  */
88
88
  function unrecoverableBlock(changed) {
89
- const lost = changed.filter((f) => f.tracked === "untracked" || f.tracked === "ignored" || f.tracked === "no_repository");
89
+ const lost = changed.filter((f) => f.tracked === "untracked" ||
90
+ f.tracked === "ignored" ||
91
+ f.tracked === "no_repository");
90
92
  if (lost.length === 0) {
91
93
  return [
92
94
  "",
@@ -136,7 +138,7 @@ function trackedLabel(state) {
136
138
  */
137
139
  export function renderBlockedReport(r) {
138
140
  const lines = [...dependencyBlock(r.dependencies)];
139
- lines.push("", " TEMPLATE — NOT ATTEMPTED", "");
141
+ lines.push("", " TEMPLATE: NOT ATTEMPTED", "");
140
142
  if (r.reason === "refused") {
141
143
  lines.push(` Template ${r.to ?? r.firstManifestVersion} states which parts of the template are platform`, " plumbing and which are yours, and that statement did not pass the checks this", " procedure applies to it before reading anything. Nothing can say which files", " would be safe to replace, so no template file was read or written.", "", " This is not something this project can resolve. It is a fault in the template", " version itself, and a later template version is what changes it.", "", " NOT VERIFIED", "", " The build has not been run. Whether this project still builds after the", " dependency changes is not something this procedure established.");
142
144
  return lines;
@@ -167,15 +169,15 @@ export function renderBlockedReport(r) {
167
169
  */
168
170
  export function renderDegradedReport(r) {
169
171
  const lines = [...dependencyBlock(r.dependencies)];
170
- lines.push("", " TEMPLATE — NOT UPGRADED", "", " This project has no recorded template version, so there is nothing to", " compare your files against. No file was replaced.");
172
+ lines.push("", " TEMPLATE: NOT UPGRADED", "", " This project has no recorded template version, so there is nothing to", " compare your files against. No file was replaced.");
171
173
  if (r.matching.length > 0 || r.matchingTestCount > 0) {
172
174
  lines.push("", ` ALREADY MATCHING ${r.to}, byte for byte`, "", ...middotList(r.matching, r.matchingTestCount));
173
175
  }
174
176
  if (r.differing.length > 0) {
175
- lines.push("", ` DIFFERS FROM ${r.to} — LEFT ALONE`, "", ...middotList(r.differing, 0), "", " Whether these differ because you changed them, or because this project came", " from an older template, cannot be established without a recorded template", " version. This procedure will not guess, and it did not replace them.");
177
+ lines.push("", ` DIFFERS FROM ${r.to}: LEFT ALONE`, "", ...middotList(r.differing, 0), "", " Whether these differ because you changed them, or because this project came", " from an older template, cannot be established without a recorded template", " version. This procedure will not guess, and it did not replace them.");
176
178
  }
177
179
  if (r.deletions.length > 0) {
178
- lines.push("", ` NOT PART OF ${r.to} — STILL IN YOUR PROJECT`, "");
180
+ lines.push("", ` NOT PART OF ${r.to}: STILL IN YOUR PROJECT`, "");
179
181
  for (const p of r.deletions) {
180
182
  lines.push(` ${p} ${r.to} does not carry this file.`);
181
183
  }
@@ -193,7 +195,9 @@ export function renderDegradedReport(r) {
193
195
  lines.push("", matched
194
196
  ? ` This project's template version is now recorded as ${r.to} for the files that`
195
197
  : ` This project's template version is now recorded as ${r.to}.`, ...(matched
196
- ? [" already match. A future upgrade from here can tell your changes apart from the"]
198
+ ? [
199
+ " already match. A future upgrade from here can tell your changes apart from the",
200
+ ]
197
201
  : [" A future upgrade from here can tell your changes apart from the"]), " platform's, and will be able to report what it replaced.");
198
202
  }
199
203
  lines.push("", " NOT VERIFIED", "", " The build has not been run.");
@@ -214,7 +218,7 @@ export function renderDependencyIncompleteReport(r) {
214
218
  lines.push(...dependencyBlock(r.completed));
215
219
  lines.push("");
216
220
  }
217
- lines.push(" TEMPLATE — NOT ATTEMPTED", "", " The dependency changes are not all in place yet, and the template's platform", " plumbing expects the versions this upgrade names. Replacing that plumbing over", " a project still on the old versions would leave a project matching neither, so", " no template file was read or written.", "", " Still outstanding:", "", ...r.outstanding.map((name) => ` ${name}`), "", " A later run of this procedure, once those are in place, performs the template", " half.", "", " NOT VERIFIED", "", " The build has not been run.");
221
+ lines.push(" TEMPLATE: NOT ATTEMPTED", "", " The dependency changes are not all in place yet, and the template's platform", " plumbing expects the versions this upgrade names. Replacing that plumbing over", " a project still on the old versions would leave a project matching neither, so", " no template file was read or written.", "", " Still outstanding:", "", ...r.outstanding.map((name) => ` ${name}`), "", " A later run of this procedure, once those are in place, performs the template", " half.", "", " NOT VERIFIED", "", " The build has not been run.");
218
222
  return lines;
219
223
  }
220
224
  function dependencyBlock(deps) {
@@ -1,4 +1,5 @@
1
1
  import type { ResolvedConfig } from "../shared/config.js";
2
+ import type { CountLimitDetail } from "../shared/countLimit.js";
2
3
  import { BackendError } from "../lib/backend.js";
3
4
  /** The mint response. Its key set is the cross-repo pin (contract table 2.11). */
4
5
  export interface MintedPreview {
@@ -28,19 +29,31 @@ export declare class PreviewConflictError extends BackendError {
28
29
  constructor(message: string);
29
30
  }
30
31
  /**
31
- * The session is gone — `preview_session_ended`, the CLI's terminal branch.
32
+ * The org is at its concurrent-live-preview ceiling.
33
+ *
34
+ * IT IS A SECOND 409 ON THE MINT ROUTE and it is told from the first by its
35
+ * CODE, never by its status. The two mean opposite things to a creator: a
36
+ * conflict says a preview for this folder is already running and to go watch it;
37
+ * the ceiling says no preview started and the organization is full. Collapsing
38
+ * them would tell a creator at their ceiling to watch a preview that does not
39
+ * exist.
32
40
  *
33
- * `endReason` is the DISCRIMINATOR the five ending screens need, and it is
34
- * `undefined` against the API as built: every terminal cause answers this route
35
- * with a byte-identical body (`{"code":"preview_session_ended","message":"this
36
- * preview has ended"}`), verified against the running stack with an `abandoned`
37
- * row and an `expired` row. QA-F3-09 names that exact condition — "if the wire
38
- * carried one undifferentiated 'ended', the designer's spec would be unbuildable
39
- * and that is a fork, not a test failure".
41
+ * It carries the whole detail because the refusal IS the notice: the figures and
42
+ * the org name are the only things that make it actionable, and the CLI cannot
43
+ * obtain either anywhere else.
44
+ */
45
+ export declare class PreviewLimitError extends BackendError {
46
+ readonly detail: CountLimitDetail;
47
+ constructor(message: string, detail: CountLimitDetail);
48
+ }
49
+ /**
50
+ * The session is gone — `preview_session_ended`, the CLI's terminal branch.
40
51
  *
41
- * It is read here anyway so the client half is complete and the seam is one
42
- * field: the day the API adds `endReason` to the 410 body, the abandoned and
43
- * hard-max screens separate with no further client change.
52
+ * `endReason` is the DISCRIMINATOR the ending screens need. The API sends it
53
+ * inside the error's `detail` (`{"code":"preview_session_ended","message":...,
54
+ * "detail":{"endReason":"abandoned"}}`), never at the top of the body. It is
55
+ * `undefined` only for a 410 that carries no reason, which renders the
56
+ * cause-free ending rather than a guessed one.
44
57
  */
45
58
  export declare class PreviewEndedError extends BackendError {
46
59
  readonly endReason?: string | undefined;
@@ -7,6 +7,8 @@
7
7
  import { createHash } from "node:crypto";
8
8
  import { realpath } from "node:fs/promises";
9
9
  import { BackendError, ProjectUnreachableError } from "../lib/backend.js";
10
+ import { rejectAuthExpired } from "../shared/authError.js";
11
+ import { apiFetch } from "../shared/http.js";
10
12
  /** The mint rate limit (§2.16). `retryAfterSec` comes from the header, never the body. */
11
13
  export class PreviewRateLimitedError extends BackendError {
12
14
  retryAfterSec;
@@ -24,19 +26,35 @@ export class PreviewConflictError extends BackendError {
24
26
  }
25
27
  }
26
28
  /**
27
- * The session is gone — `preview_session_ended`, the CLI's terminal branch.
29
+ * The org is at its concurrent-live-preview ceiling.
30
+ *
31
+ * IT IS A SECOND 409 ON THE MINT ROUTE and it is told from the first by its
32
+ * CODE, never by its status. The two mean opposite things to a creator: a
33
+ * conflict says a preview for this folder is already running and to go watch it;
34
+ * the ceiling says no preview started and the organization is full. Collapsing
35
+ * them would tell a creator at their ceiling to watch a preview that does not
36
+ * exist.
28
37
  *
29
- * `endReason` is the DISCRIMINATOR the five ending screens need, and it is
30
- * `undefined` against the API as built: every terminal cause answers this route
31
- * with a byte-identical body (`{"code":"preview_session_ended","message":"this
32
- * preview has ended"}`), verified against the running stack with an `abandoned`
33
- * row and an `expired` row. QA-F3-09 names that exact condition — "if the wire
34
- * carried one undifferentiated 'ended', the designer's spec would be unbuildable
35
- * and that is a fork, not a test failure".
38
+ * It carries the whole detail because the refusal IS the notice: the figures and
39
+ * the org name are the only things that make it actionable, and the CLI cannot
40
+ * obtain either anywhere else.
41
+ */
42
+ export class PreviewLimitError extends BackendError {
43
+ detail;
44
+ constructor(message, detail) {
45
+ super(message, 409);
46
+ this.detail = detail;
47
+ this.name = "PreviewLimitError";
48
+ }
49
+ }
50
+ /**
51
+ * The session is gone — `preview_session_ended`, the CLI's terminal branch.
36
52
  *
37
- * It is read here anyway so the client half is complete and the seam is one
38
- * field: the day the API adds `endReason` to the 410 body, the abandoned and
39
- * hard-max screens separate with no further client change.
53
+ * `endReason` is the DISCRIMINATOR the ending screens need. The API sends it
54
+ * inside the error's `detail` (`{"code":"preview_session_ended","message":...,
55
+ * "detail":{"endReason":"abandoned"}}`), never at the top of the body. It is
56
+ * `undefined` only for a 410 that carries no reason, which renders the
57
+ * cause-free ending rather than a guessed one.
40
58
  */
41
59
  export class PreviewEndedError extends BackendError {
42
60
  endReason;
@@ -89,7 +107,7 @@ export function parseRetryAfter(raw) {
89
107
  return Number.isFinite(n) && n > 0 ? Math.ceil(n) : 60;
90
108
  }
91
109
  /** POST /projects/{id}/preview-sessions — mint. */
92
- export async function mintPreviewSession(cfg, projectId, key, fetchImpl = fetch) {
110
+ export async function mintPreviewSession(cfg, projectId, key, fetchImpl = apiFetch) {
93
111
  const res = await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}/preview-sessions`, {
94
112
  method: "POST",
95
113
  headers: authHeaders(cfg),
@@ -99,11 +117,19 @@ export async function mintPreviewSession(cfg, projectId, key, fetchImpl = fetch)
99
117
  throw new PreviewRateLimitedError(parseRetryAfter(res.headers.get("retry-after")));
100
118
  }
101
119
  if (!res.ok) {
120
+ rejectAuthExpired(res);
102
121
  const body = await errorBody(res);
103
122
  if (res.status === 404) {
104
123
  throw new ProjectUnreachableError(body.message ?? "This project is not available to you.");
105
124
  }
106
125
  if (res.status === 409) {
126
+ // TWO 409s NOW LAND HERE and the CODE is what separates them. Reading the
127
+ // status alone would render the ceiling refusal as "a preview is already
128
+ // running for this folder" — a sentence that is false, that names a
129
+ // preview the creator does not have, and that sends them to watch it.
130
+ if (body.code === "preview_limit") {
131
+ throw new PreviewLimitError(body.message ?? "this organization is at its preview limit", body.detail ?? {});
132
+ }
107
133
  throw new PreviewConflictError(body.message ?? "a preview is already running for this folder");
108
134
  }
109
135
  throw new BackendError(body.message ?? `could not start a preview (${res.status})`, res.status);
@@ -136,12 +162,13 @@ export function parseMint(raw) {
136
162
  return { sessionId, url, edgeUrl, grant, heartbeatSec, expiresAt };
137
163
  }
138
164
  /** POST /preview-sessions/{id}/heartbeat — keepalive AND re-authorization. */
139
- export async function heartbeatPreviewSession(cfg, sessionId, fetchImpl = fetch) {
165
+ export async function heartbeatPreviewSession(cfg, sessionId, fetchImpl = apiFetch) {
140
166
  const res = await fetchImpl(`${cfg.apiUrl}/preview-sessions/${encodeURIComponent(sessionId)}/heartbeat`, { method: "POST", headers: authHeaders(cfg) });
141
167
  if (!res.ok) {
168
+ rejectAuthExpired(res);
142
169
  const body = await errorBody(res);
143
170
  if (res.status === 410) {
144
- throw new PreviewEndedError(body.message ?? "this preview has ended", typeof body.endReason === "string" ? body.endReason : undefined);
171
+ throw new PreviewEndedError(body.message ?? "this preview has ended", typeof body.detail?.endReason === "string" ? body.detail.endReason : undefined);
145
172
  }
146
173
  if (res.status === 404 || res.status === 403) {
147
174
  throw new ProjectUnreachableError(body.message ?? "This project is not available to you.");
@@ -161,7 +188,7 @@ export async function heartbeatPreviewSession(cfg, sessionId, fetchImpl = fetch)
161
188
  * Surfacing a teardown failure would turn a successful stop into an error the
162
189
  * creator cannot act on.
163
190
  */
164
- export async function endPreviewSession(cfg, sessionId, fetchImpl = fetch) {
191
+ export async function endPreviewSession(cfg, sessionId, fetchImpl = apiFetch) {
165
192
  try {
166
193
  await fetchImpl(`${cfg.apiUrl}/preview-sessions/${encodeURIComponent(sessionId)}`, {
167
194
  method: "DELETE",
@@ -12,7 +12,21 @@ export interface PreviewUiDeps {
12
12
  filterTester?: string;
13
13
  /** Filter the stream to one connection (`--viewer`). */
14
14
  filterViewer?: string;
15
+ /** The resolved dev command, named by the origin-down advisory. */
16
+ devCommand?: string;
17
+ /** Where the forwarded port came from, named by the origin-down advisory. */
18
+ portSource?: "flag" | "vincentt.json" | "default";
19
+ /**
20
+ * Whether package.json lists the harness. `false` means the static
21
+ * no-diagnostics line was shown, so arrivals are printed per device class;
22
+ * `true` arms the runtime line. Undefined prints neither.
23
+ */
24
+ harnessListed?: boolean;
25
+ /** Injected for tests. Returns a cancel. */
26
+ schedule?: (fn: () => void, ms: number) => () => void;
15
27
  }
28
+ /** How long a viewer may be present with no harness hello before the runtime line. */
29
+ export declare const NO_DIAGNOSTICS_WINDOW_MS = 10000;
16
30
  /**
17
31
  * PreviewUi owns every line after the mint. It is a pure sink — it takes events
18
32
  * and prints strings — so the whole transcript is assertable without a terminal,
@@ -32,6 +46,13 @@ export declare class PreviewUi {
32
46
  private hardMaxWarned;
33
47
  private tunnelState;
34
48
  private ended;
49
+ /** Device classes already announced by the arrival line. */
50
+ private readonly arrivedClasses;
51
+ /** Any connection sent a harness hello in this preview. */
52
+ private helloSeen;
53
+ private runtimeArmed;
54
+ private cancelRuntime;
55
+ private hiddenAdvised;
35
56
  constructor(deps: PreviewUiDeps);
36
57
  get connectedCount(): number;
37
58
  get totalConnections(): number;
@@ -47,6 +68,14 @@ export declare class PreviewUi {
47
68
  * forty assets from announcing itself forty times.
48
69
  */
49
70
  viewerSeen(label: string, uaClass: UAClass): void;
71
+ /**
72
+ * The two no-diagnostics signals, never both in one preview. Keyed on the
73
+ * tunnel's own viewer signal, which exists with or without a harness; a hello
74
+ * is what names a tester, so neither line can name one.
75
+ */
76
+ private noDiagnosticsSignal;
77
+ /** The front proxy refused a hidden path. One advisory per preview, naming the first. */
78
+ hiddenPath(requestPath: string): void;
50
79
  viewerGone(label: string): void;
51
80
  /**
52
81
  * The harness introduced itself on a connection. This is what prints the
@@ -15,6 +15,13 @@ import * as copy from "./copy.js";
15
15
  import { PerfThrottle, palette, renderAnnotation, renderEvent, uaLabel, } from "./stream.js";
16
16
  import { ViewerRoster } from "./viewers.js";
17
17
  import { MAX_TESTERS } from "../relay/testers.js";
18
+ /** How long a viewer may be present with no harness hello before the runtime line. */
19
+ export const NO_DIAGNOSTICS_WINDOW_MS = 10_000;
20
+ function defaultSchedule(fn, ms) {
21
+ const t = setTimeout(fn, ms);
22
+ t.unref?.();
23
+ return () => clearTimeout(t);
24
+ }
18
25
  /**
19
26
  * PreviewUi owns every line after the mint. It is a pure sink — it takes events
20
27
  * and prints strings — so the whole transcript is assertable without a terminal,
@@ -34,6 +41,13 @@ export class PreviewUi {
34
41
  hardMaxWarned = false;
35
42
  tunnelState = "connecting";
36
43
  ended = false;
44
+ /** Device classes already announced by the arrival line. */
45
+ arrivedClasses = new Set();
46
+ /** Any connection sent a harness hello in this preview. */
47
+ helloSeen = false;
48
+ runtimeArmed = false;
49
+ cancelRuntime;
50
+ hiddenAdvised = false;
37
51
  constructor(deps) {
38
52
  this.deps = deps;
39
53
  this.p = palette(deps.color);
@@ -65,6 +79,7 @@ export class PreviewUi {
65
79
  */
66
80
  viewerSeen(label, uaClass) {
67
81
  const out = this.roster.connect(label, uaClass, this.deps.now());
82
+ this.noDiagnosticsSignal(uaClass);
68
83
  if (out.connect === undefined)
69
84
  return;
70
85
  this.line("");
@@ -73,6 +88,43 @@ export class PreviewUi {
73
88
  this.lines(out.advisory.map((l) => ` ${l}`));
74
89
  this.line("");
75
90
  }
91
+ /**
92
+ * The two no-diagnostics signals, never both in one preview. Keyed on the
93
+ * tunnel's own viewer signal, which exists with or without a harness; a hello
94
+ * is what names a tester, so neither line can name one.
95
+ */
96
+ noDiagnosticsSignal(uaClass) {
97
+ if (this.deps.harnessListed === false) {
98
+ if (this.arrivedClasses.has(uaClass))
99
+ return;
100
+ this.arrivedClasses.add(uaClass);
101
+ this.line("");
102
+ this.line(` ${copy.deviceArrived(uaLabel(uaClass))}`);
103
+ this.line("");
104
+ return;
105
+ }
106
+ if (this.deps.harnessListed !== true || this.runtimeArmed || this.helloSeen)
107
+ return;
108
+ this.runtimeArmed = true;
109
+ const descriptor = uaLabel(uaClass);
110
+ const schedule = this.deps.schedule ?? defaultSchedule;
111
+ this.cancelRuntime = schedule(() => {
112
+ if (this.helloSeen || this.ended)
113
+ return;
114
+ this.line("");
115
+ this.lines(copy.noDiagnosticsRuntime(descriptor).map((l) => ` ${l}`));
116
+ this.line("");
117
+ }, NO_DIAGNOSTICS_WINDOW_MS);
118
+ }
119
+ /** The front proxy refused a hidden path. One advisory per preview, naming the first. */
120
+ hiddenPath(requestPath) {
121
+ if (this.hiddenAdvised)
122
+ return;
123
+ this.hiddenAdvised = true;
124
+ this.line("");
125
+ this.line(` ${copy.hiddenPathRefused(requestPath)}`);
126
+ this.line("");
127
+ }
76
128
  viewerGone(label) {
77
129
  const out = this.roster.disconnect(label, this.deps.now());
78
130
  this.perf.forget(label);
@@ -89,6 +141,9 @@ export class PreviewUi {
89
141
  * fixes.
90
142
  */
91
143
  testerSeen(label, tester) {
144
+ this.helloSeen = true;
145
+ this.cancelRuntime?.();
146
+ this.cancelRuntime = undefined;
92
147
  const out = this.roster.attribute(label, tester);
93
148
  if (out.connect === undefined)
94
149
  return;
@@ -179,7 +234,10 @@ export class PreviewUi {
179
234
  return;
180
235
  this.originDown = true;
181
236
  this.line("");
182
- this.lines(copy.originDown(this.deps.targetUrl).map((l) => ` ${l}`));
237
+ const settings = this.deps.devCommand !== undefined && this.deps.portSource !== undefined
238
+ ? { devCommand: this.deps.devCommand, portSource: this.deps.portSource }
239
+ : undefined;
240
+ this.lines(copy.originDown(this.deps.targetUrl, settings).map((l) => ` ${l}`));
183
241
  }
184
242
  originRecovered() {
185
243
  this.originDown = false;
@@ -275,6 +333,8 @@ export function endingLines(reason, orgName) {
275
333
  return copy.endedUnreachable(orgName);
276
334
  case "authz_unconfirmed":
277
335
  return copy.endedUnconfirmed();
336
+ case "auth_expired":
337
+ return copy.endedAuthExpired();
278
338
  case "egress_cap":
279
339
  return copy.egressHard();
280
340
  case "stopped":