@vincentt-xr/cli 0.17.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 +170 -84
  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 +5 -11
  57. package/dist/preview/api.js +13 -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 +22 -5
  69. package/dist/publish/api.js +80 -9
  70. package/dist/publish/command.d.ts +25 -0
  71. package/dist/publish/command.js +211 -35
  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 +7 -2
  92. package/dist/shared/countLimit.js +12 -5
  93. package/dist/shared/http.d.ts +23 -0
  94. package/dist/shared/http.js +58 -0
  95. package/package.json +7 -3
@@ -13,21 +13,33 @@
13
13
  // Returns the public URL plus a `stop()` that reaps everything. A failure at any
14
14
  // step tears down what was already started, so the caller never leaks a child
15
15
  // process or a live session.
16
- import { spawn } from "node:child_process";
16
+ import { spawn, spawnSync } from "node:child_process";
17
+ import { realpath } from "node:fs/promises";
17
18
  import { createServer } from "node:http";
18
19
  import { TunnelClient, resolveForwardTarget, } from "@vincentt-xr/harness/tunnel";
19
20
  import { startRelay } from "../relay/server.js";
20
- import { proxyWeb, proxyWs } from "./proxy.js";
21
+ import { isHiddenAppPath, proxyWeb, proxyWs, relayRoute } from "./proxy.js";
21
22
  import { getFreePort, isPortListening, waitForApp } from "./net.js";
22
23
  import { endPreviewSession, heartbeatPreviewSession, mintPreviewSession, workingTreeKey, PreviewEndedError, } from "./api.js";
24
+ import { withoutCliCredentials } from "../shared/config.js";
23
25
  import { ProjectUnreachableError } from "../lib/backend.js";
26
+ import { AuthExpiredError } from "../shared/authError.js";
27
+ import { apiFetch } from "../shared/http.js";
28
+ /** The path a 404 advisory names: the request's own path, without its query. */
29
+ export function requestPathOf(url) {
30
+ const raw = (url ?? "/").split("?")[0] ?? "/";
31
+ // Control characters never reach the terminal from a request line.
32
+ return raw.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
33
+ }
24
34
  export async function startPreview(opts) {
25
- const { projectCwd, projectRoot = projectCwd, config, projectId, harnessPath = "/__harness", devCommand = ["npm", "run", "dev"], appStdio = "ignore", appReadyTimeoutMs = 8_000, onLog = () => undefined, fetchImpl = fetch, } = opts;
35
+ const { projectCwd, projectRoot = projectCwd, config, projectId, harnessPath = "/__harness", devCommand = ["npm", "run", "dev"], appStdio = "ignore", appReadyTimeoutMs = 8_000, onLog = () => undefined, fetchImpl = apiFetch, } = opts;
26
36
  const appPort = opts.appPort ?? 5173;
27
37
  const relayPort = opts.relayPort ?? (await getFreePort());
28
38
  const frontPort = opts.frontPort ?? (await getFreePort());
29
39
  const reuseApp = await isPortListening(appPort);
40
+ const hiddenRoot = await realpath(projectRoot).catch(() => projectRoot);
30
41
  let app;
42
+ let killApp;
31
43
  let relay;
32
44
  let front;
33
45
  let tunnel;
@@ -41,7 +53,13 @@ export async function startPreview(opts) {
41
53
  if (heartbeat !== undefined)
42
54
  clearInterval(heartbeat);
43
55
  tunnel?.close();
44
- app?.kill();
56
+ if (killApp) {
57
+ process.removeListener("exit", killApp);
58
+ killApp();
59
+ }
60
+ else {
61
+ app?.kill();
62
+ }
45
63
  front?.close();
46
64
  if (relay)
47
65
  await relay.close();
@@ -64,13 +82,59 @@ export async function startPreview(opts) {
64
82
  onLog(`reusing the dev server already on :${appPort}`);
65
83
  }
66
84
  else {
67
- app = spawn(devCommand[0], devCommand.slice(1), {
68
- cwd: projectCwd,
69
- env: { ...process.env, PORT: String(appPort) },
70
- stdio: appStdio,
71
- // Windows: npm/pnpm are .cmd shims — spawn needs a shell to resolve them.
72
- shell: process.platform === "win32",
73
- });
85
+ // The dev command runs where the binding lives, and its environment carries
86
+ // no CLI credential: the command is repo-controlled and arrives by pull
87
+ // request. The machine config file stays readable to it (a recorded limit).
88
+ const env = { ...withoutCliCredentials(process.env), PORT: String(appPort) };
89
+ // A shell command such as `a && npm run serve` puts the dev server one or
90
+ // more processes below the shell, so signalling the shell alone orphans it.
91
+ // On POSIX the shell leads its own process group and stop() signals the
92
+ // group. Its stdin is never the terminal: a background group reading the
93
+ // terminal would be stopped by SIGTTIN.
94
+ const grouped = typeof devCommand === "string" && process.platform !== "win32";
95
+ app =
96
+ typeof devCommand === "string"
97
+ ? spawn(devCommand, {
98
+ cwd: projectRoot,
99
+ env,
100
+ stdio: appStdio === "inherit" ? ["ignore", "inherit", "inherit"] : "ignore",
101
+ shell: true,
102
+ detached: grouped,
103
+ })
104
+ : spawn(devCommand[0], devCommand.slice(1), {
105
+ cwd: projectRoot,
106
+ env,
107
+ stdio: appStdio,
108
+ // Windows: npm/pnpm are .cmd shims — spawn needs a shell to resolve them.
109
+ shell: process.platform === "win32",
110
+ });
111
+ if (grouped && app.pid !== undefined) {
112
+ const pid = app.pid;
113
+ killApp = () => {
114
+ try {
115
+ process.kill(-pid, "SIGTERM");
116
+ }
117
+ catch {
118
+ app?.kill();
119
+ }
120
+ };
121
+ // Best effort for an exit that skips stop(); a SIGKILL of this process
122
+ // still orphans the group, as it always orphaned the dev server.
123
+ process.once("exit", killApp);
124
+ }
125
+ else if (process.platform === "win32" && app.pid !== undefined) {
126
+ // On Windows the dev server runs under a cmd.exe shim, and killing the
127
+ // shim leaves the server running. taskkill /T ends the whole tree.
128
+ const pid = app.pid;
129
+ killApp = () => {
130
+ const r = spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], {
131
+ stdio: "ignore",
132
+ });
133
+ if (r.status !== 0)
134
+ app?.kill();
135
+ };
136
+ process.once("exit", killApp);
137
+ }
74
138
  }
75
139
  relay = startRelay({
76
140
  port: relayPort,
@@ -85,8 +149,22 @@ export async function startPreview(opts) {
85
149
  onCapReached: opts.onCapReached,
86
150
  onAnnotation: opts.onAnnotation,
87
151
  });
152
+ // Fixed here, from the server-computed URL, and never from a request.
153
+ const previewOrigin = new URL(minted.url).origin;
88
154
  front = createServer((req, res) => {
89
- const toRelay = req.url?.startsWith(harnessPath) === true;
155
+ const route = relayRoute(req.url ?? "/", harnessPath, "http");
156
+ if (route === "refuse") {
157
+ res.writeHead(404, { "content-type": "text/plain", "cache-control": "no-store" });
158
+ res.end("Not found");
159
+ return;
160
+ }
161
+ const toRelay = route === "relay";
162
+ if (!toRelay && isHiddenAppPath(req.url ?? "/", hiddenRoot)) {
163
+ opts.onHiddenPath?.(requestPathOf(req.url));
164
+ res.writeHead(404, { "content-type": "text/plain", "cache-control": "no-store" });
165
+ res.end("Not found");
166
+ return;
167
+ }
90
168
  // Recovery is signalled by normal output resuming, so the re-arm has to
91
169
  // happen on a SUCCESSFUL response rather than on a timer. A relay request
92
170
  // is not the creator's app and must not clear an app failure.
@@ -98,8 +176,22 @@ export async function startPreview(opts) {
98
176
  }
99
177
  proxyWeb(req, res, toRelay ? relayPort : appPort, () => opts.onOriginError?.("", "origin_unreachable"));
100
178
  });
101
- front.on("upgrade", (req, socket, head) => proxyWs(req, socket, head, req.url?.startsWith(harnessPath) ? relayPort : appPort));
102
- await new Promise((resolve) => front.listen(frontPort, resolve));
179
+ front.on("upgrade", (req, socket, head) => {
180
+ const route = relayRoute(req.url ?? "/", harnessPath, "upgrade");
181
+ if (route === "relay") {
182
+ proxyWs(req, socket, head, relayPort);
183
+ return;
184
+ }
185
+ if (route === "refuse" || isHiddenAppPath(req.url ?? "/", hiddenRoot)) {
186
+ if (route === "app")
187
+ opts.onHiddenPath?.(requestPathOf(req.url));
188
+ socket.end("HTTP/1.1 404 Not Found\r\nconnection: close\r\ncontent-length: 0\r\n\r\n");
189
+ return;
190
+ }
191
+ proxyWs(req, socket, head, appPort, { rewriteOriginFrom: previewOrigin });
192
+ });
193
+ // Loopback only: the tunnel client on this machine is the one intended caller.
194
+ await new Promise((resolve) => front.listen(frontPort, "127.0.0.1", resolve));
103
195
  // ONE configured target, captured HERE from the creator's own config and
104
196
  // never from a request (2.15.4 rule 1). The loopback assertion runs at
105
197
  // start and is re-checked at every connect.
@@ -138,8 +230,15 @@ export async function startPreview(opts) {
138
230
  // RequireMembership on the API side, so a project transferred out from
139
231
  // under a live preview, or a member removed from the org, ends it on the
140
232
  // next beat rather than at the 12-hour ceiling.
233
+ // One beat in flight at a time: a slow api must not stack up requests.
234
+ let beating = false;
141
235
  heartbeat = setInterval(() => {
142
- void runHeartbeat(config, minted.sessionId, fetchImpl, opts.onEnded);
236
+ if (beating)
237
+ return;
238
+ beating = true;
239
+ void runHeartbeat(config, minted.sessionId, fetchImpl, opts.onEnded).finally(() => {
240
+ beating = false;
241
+ });
143
242
  }, minted.heartbeatSec * 1000);
144
243
  // Node must not be held alive by the heartbeat alone — the tunnel and the
145
244
  // relay are what keep the process up, and a stray interval would outlive a
@@ -147,7 +246,7 @@ export async function startPreview(opts) {
147
246
  heartbeat.unref?.();
148
247
  const appReady = reuseApp ? true : await waitForApp(appPort, appReadyTimeoutMs);
149
248
  if (!appReady) {
150
- onLog(`app on :${appPort} isn't responding yet — the URL may be blank until it builds`);
249
+ onLog(`app on :${appPort} isn't responding yet; the URL may be blank until it builds`);
151
250
  }
152
251
  return {
153
252
  url: minted.url,
@@ -205,7 +304,8 @@ export function refusalToReason(status) {
205
304
  /**
206
305
  * One heartbeat. A 410 is the session ending on the server's terms; a 404/403
207
306
  * is authorization lost. They are DELIBERATELY NOT MERGED, because the remedies
208
- * differ: one is "find out who you are", the other is "try again".
307
+ * differ: one is "find out who you are", the other is "try again". A 401 is the
308
+ * token itself refused, and its remedy is signing in again.
209
309
  *
210
310
  * A transport failure is neither — the API being briefly unreachable must not
211
311
  * end a healthy preview, because the edge already fails closed on its own
@@ -217,17 +317,19 @@ export function refusalToReason(status) {
217
317
  * superseded, or stopped elsewhere, all answer 410, and the hard-max screen is a
218
318
  * `✓` telling the creator the preview did exactly what it promised over 12 hours.
219
319
  *
220
- * The API's 410 body carries ONE opaque code (`preview_session_ended`) for every
221
- * terminal reason — verified against the running stack: an `abandoned` row and an
222
- * `expired` row return byte-identical bodies — so the CLI CANNOT tell them apart
223
- * and must not guess. `session_ended` is the honest, cause-free rendering until
224
- * the wire carries the discriminator. See the note on PreviewEndedError.
320
+ * The API's 410 carries the terminal reason under `detail.endReason`, and each
321
+ * reason maps to its own screen. A 410 with no reason gets the cause-free
322
+ * ending. See the note on PreviewEndedError.
225
323
  */
226
324
  export async function runHeartbeat(config, sessionId, fetchImpl, onEnded) {
227
325
  try {
228
326
  await heartbeatPreviewSession(config, sessionId, fetchImpl);
229
327
  }
230
328
  catch (err) {
329
+ if (err instanceof AuthExpiredError) {
330
+ onEnded?.("auth_expired");
331
+ return;
332
+ }
231
333
  if (err instanceof ProjectUnreachableError) {
232
334
  onEnded?.("authz_revoked");
233
335
  return;
@@ -242,10 +344,9 @@ export async function runHeartbeat(config, sessionId, fetchImpl, onEnded) {
242
344
  /**
243
345
  * The API's `endReason` → the CLI's ending screen.
244
346
  *
245
- * `hard_max` is returned ONLY for an explicit `expired`. An UNLABELLED 410 —
246
- * which is every 410 the API sends today — maps to `grace_exhausted`, the one
247
- * ending whose copy states only what is actually known: the preview has ended
248
- * and the link no longer works. It asserts no cause.
347
+ * `hard_max` is returned ONLY for an explicit `expired`. An UNLABELLED 410 maps
348
+ * to `grace_exhausted`, the one ending whose copy states only what is actually
349
+ * known: the preview has ended and the link no longer works. It asserts no cause.
249
350
  *
250
351
  * That default is the conservative choice and it is deliberate. `hard_max` is a
251
352
  * `✓` claiming the preview ran its full 12 hours; rendering it for a session the
@@ -171,7 +171,7 @@ export class ViewerRoster {
171
171
  return undefined;
172
172
  return [
173
173
  `! A connection that reconnects gets a new label. If ${gone.label} came back,`,
174
- ` it is ${label} now — labels last one connection. Testers survive a refresh.`,
174
+ ` it is ${label} now. Labels last one connection. Testers survive a refresh.`,
175
175
  ];
176
176
  }
177
177
  disconnect(label, now) {
@@ -1,12 +1,22 @@
1
1
  import type { ResolvedConfig } from "../shared/config.js";
2
2
  import type { ManifestEntry } from "./manifest.js";
3
3
  import type { BuiltWith } from "../platform/resolve.js";
4
+ /**
5
+ * The api's publish states, `domain.PublishState*`. apiPublishStates.test.ts
6
+ * checks this list against the committed api capture, so a state the api adds
7
+ * fails the suite on the next capture refresh instead of reaching a checklist
8
+ * that has no arm for it.
9
+ */
10
+ export declare const PUBLISH_STATES: readonly ["never", "live", "unpublished", "not_serving", "held_by_platform"];
11
+ export type PublishState = (typeof PUBLISH_STATES)[number];
12
+ /** A state outside PUBLISH_STATES reads as `unknown`, never as a guess at a known one. */
13
+ export declare function parsePublishState(raw: string | null): PublishState | "unknown";
4
14
  export interface PublishProject {
5
15
  id: string;
6
16
  slug: string;
7
17
  name: string;
8
18
  liveUrl: string;
9
- publishState: "never" | "live" | "unpublished" | "not_serving";
19
+ publishState: PublishState | "unknown";
10
20
  firstPublishedAt: string | null;
11
21
  liveVersion: number | null;
12
22
  highestVersion: number | null;
@@ -31,14 +41,21 @@ export interface OpenedUpload {
31
41
  uploads: UploadTarget[];
32
42
  expiresAt: string;
33
43
  }
44
+ /** The api's `commitResponse`. apiPublishStates.test.ts checks these keys against the capture. */
34
45
  export interface CommitResult {
35
46
  version: number;
36
- liveVersion: number;
37
- highestVersion: number;
47
+ liveVersion: number | null;
48
+ highestVersion: number | null;
38
49
  liveUrl: string;
39
50
  publishedAt: string;
40
- /** Response-side name of the request's `note`. The asymmetry is deliberate. */
41
- releaseNote?: string;
51
+ }
52
+ /**
53
+ * The api answered 2xx to a commit but the body is not a commit response. The
54
+ * version may well be live, so this is never reported as "unchanged".
55
+ */
56
+ export declare class MalformedCommitResponse extends Error {
57
+ readonly reason: string;
58
+ constructor(reason: string);
42
59
  }
43
60
  export interface ApiErrorDetail {
44
61
  /**
@@ -1,11 +1,73 @@
1
1
  // The three-call publish transaction against the Go API, built against
2
- // implementation.md §2.7's contract table — every wire field named canonically
3
- // once, and the deliberate asymmetry (request `note`, response `releaseNote`)
4
- // carried as written.
2
+ // implementation.md §2.7's contract table, every wire field named canonically
3
+ // once. The request's `note` comes back as `releaseNote` on the version list,
4
+ // never on the commit response.
5
5
  //
6
6
  // The bytes do NOT transit the API. Open returns one presigned PUT per file,
7
7
  // each signed for one exact key; the CLI PUTs direct to S3 and then commits.
8
8
  import { clientHeaders } from "../shared/clientHeader.js";
9
+ import { rejectAuthExpired } from "../shared/authError.js";
10
+ import { apiFetch } from "../shared/http.js";
11
+ /**
12
+ * The api's publish states, `domain.PublishState*`. apiPublishStates.test.ts
13
+ * checks this list against the committed api capture, so a state the api adds
14
+ * fails the suite on the next capture refresh instead of reaching a checklist
15
+ * that has no arm for it.
16
+ */
17
+ export const PUBLISH_STATES = [
18
+ "never",
19
+ "live",
20
+ "unpublished",
21
+ "not_serving",
22
+ "held_by_platform",
23
+ ];
24
+ /** A state outside PUBLISH_STATES reads as `unknown`, never as a guess at a known one. */
25
+ export function parsePublishState(raw) {
26
+ if (raw === null)
27
+ return "never";
28
+ return PUBLISH_STATES.includes(raw)
29
+ ? raw
30
+ : "unknown";
31
+ }
32
+ /**
33
+ * The api answered 2xx to a commit but the body is not a commit response. The
34
+ * version may well be live, so this is never reported as "unchanged".
35
+ */
36
+ export class MalformedCommitResponse extends Error {
37
+ reason;
38
+ constructor(reason) {
39
+ super(`commit: ${reason}`);
40
+ this.reason = reason;
41
+ this.name = "MalformedCommitResponse";
42
+ }
43
+ }
44
+ function parseCommitResult(raw) {
45
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
46
+ throw new MalformedCommitResponse("response is not an object");
47
+ }
48
+ const r = raw;
49
+ const intOrNull = (k) => {
50
+ const v = r[k];
51
+ if (v === null || v === undefined)
52
+ return null;
53
+ if (typeof v === "number" && Number.isInteger(v))
54
+ return v;
55
+ throw new MalformedCommitResponse(`${k} is not an integer`);
56
+ };
57
+ if (typeof r.version !== "number" || !Number.isInteger(r.version)) {
58
+ throw new MalformedCommitResponse("version is not an integer");
59
+ }
60
+ if (typeof r.liveUrl !== "string" || r.liveUrl === "") {
61
+ throw new MalformedCommitResponse("liveUrl is missing");
62
+ }
63
+ return {
64
+ version: r.version,
65
+ liveVersion: intOrNull("liveVersion"),
66
+ highestVersion: intOrNull("highestVersion"),
67
+ liveUrl: r.liveUrl,
68
+ publishedAt: typeof r.publishedAt === "string" ? r.publishedAt : "",
69
+ };
70
+ }
9
71
  export class PublishApiError extends Error {
10
72
  status;
11
73
  code;
@@ -20,7 +82,9 @@ export class PublishApiError extends Error {
20
82
  this.name = "PublishApiError";
21
83
  }
22
84
  }
85
+ /** Rejects with AuthExpiredError on a 401; every other status becomes a PublishApiError. */
23
86
  async function readError(res) {
87
+ rejectAuthExpired(res);
24
88
  let body = {};
25
89
  try {
26
90
  body = (await res.json());
@@ -35,7 +99,7 @@ function authHeaders(cfg) {
35
99
  return { ...clientHeaders(), authorization: `Bearer ${cfg.pat}` };
36
100
  }
37
101
  /** The ONE call before the checklist. liveVersion + livePublishedAt ride it, by design. */
38
- export async function readProject(cfg, projectId, fetchImpl = fetch) {
102
+ export async function readProject(cfg, projectId, fetchImpl = apiFetch) {
39
103
  const res = await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}`, {
40
104
  headers: authHeaders(cfg),
41
105
  });
@@ -49,7 +113,7 @@ export async function readProject(cfg, projectId, fetchImpl = fetch) {
49
113
  slug: String(raw.slug ?? ""),
50
114
  name: typeof raw.name === "string" ? raw.name : String(raw.slug ?? ""),
51
115
  liveUrl: str("liveUrl") ?? "",
52
- publishState: (str("publishState") ?? "never"),
116
+ publishState: parsePublishState(str("publishState")),
53
117
  firstPublishedAt: str("firstPublishedAt"),
54
118
  liveVersion: num("liveVersion"),
55
119
  highestVersion: num("highestVersion"),
@@ -72,7 +136,7 @@ export async function readProject(cfg, projectId, fetchImpl = fetch) {
72
136
  * an empty object. `{}` means "resolution ran and found nothing", a different
73
137
  * and specific fact.
74
138
  */
75
- export async function openUpload(cfg, projectId, files, note, fetchImpl = fetch, builtWith) {
139
+ export async function openUpload(cfg, projectId, files, note, fetchImpl = apiFetch, builtWith) {
76
140
  const res = await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}/publishes`, {
77
141
  method: "POST",
78
142
  headers: { ...authHeaders(cfg), "content-type": "application/json" },
@@ -94,14 +158,21 @@ export async function openUpload(cfg, projectId, files, note, fetchImpl = fetch,
94
158
  expiresAt: body.expiresAt ?? "",
95
159
  };
96
160
  }
97
- export async function commitUpload(cfg, projectId, uploadId, fetchImpl = fetch) {
161
+ export async function commitUpload(cfg, projectId, uploadId, fetchImpl = apiFetch) {
98
162
  const res = await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}/publishes/${encodeURIComponent(uploadId)}/commit`, { method: "POST", headers: authHeaders(cfg) });
99
163
  if (!res.ok)
100
164
  throw await readError(res);
101
- return (await res.json());
165
+ let raw;
166
+ try {
167
+ raw = await res.json();
168
+ }
169
+ catch {
170
+ throw new MalformedCommitResponse("response is not JSON");
171
+ }
172
+ return parseCommitResult(raw);
102
173
  }
103
174
  /** Abandon an open upload. Idempotent server-side; a failure here must never mask the real error. */
104
- export async function abandonUpload(cfg, projectId, uploadId, fetchImpl = fetch) {
175
+ export async function abandonUpload(cfg, projectId, uploadId, fetchImpl = apiFetch) {
105
176
  try {
106
177
  await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}/publishes/${encodeURIComponent(uploadId)}`, { method: "DELETE", headers: authHeaders(cfg) });
107
178
  }
@@ -1,5 +1,6 @@
1
1
  import type { ResolvedConfig } from "../shared/config.js";
2
2
  import { type BuiltWith } from "../platform/resolve.js";
3
+ import { type VincenttJson } from "../lib/settings.js";
3
4
  export interface PublishDeps {
4
5
  print: (line: string) => void;
5
6
  /** Raw write, for the redrawn progress line only. */
@@ -8,10 +9,14 @@ export interface PublishDeps {
8
9
  confirm: (prompt: string, defaultYes: boolean) => Promise<boolean>;
9
10
  cwd: string;
10
11
  config: () => Promise<ResolvedConfig>;
12
+ /** `dir` is the directory holding the binding; absent, the working directory stands in. */
11
13
  binding: () => Promise<{
12
14
  projectId: string;
13
15
  slug: string;
16
+ dir?: string;
14
17
  } | undefined>;
18
+ /** Injected for tests. Defaults to reading `<root>/vincentt.json`. */
19
+ readSettings?: (root: string) => Promise<VincenttJson | undefined>;
15
20
  /** Absent means not signed in — the gate is LOCAL and nothing is attempted. */
16
21
  token: () => Promise<string | undefined>;
17
22
  isTTY: boolean;
@@ -31,10 +36,30 @@ export interface PublishDeps {
31
36
  * mechanical proof there is one resolver and not two that drifted.
32
37
  */
33
38
  builtWith?: () => Promise<BuiltWith | undefined>;
39
+ /** Fetch for the post-commit probe of the public address. Defaults to fetchImpl, then the global. */
40
+ probeFetch?: typeof fetch;
41
+ /** Where the --json document goes (stdout). Defaults to print. */
42
+ emit?: (line: string) => void;
34
43
  }
35
44
  export interface PublishOptions {
36
45
  distDir?: string;
37
46
  note?: string;
38
47
  yes?: boolean;
48
+ /** Print one JSON document at the end; the caller routes `print` elsewhere. */
49
+ json?: boolean;
39
50
  }
51
+ /**
52
+ * The --json document. `live` means the address served the new index.html;
53
+ * `committed_not_visible` means the commit succeeded but the wait ran out;
54
+ * `failed` covers every other ending, with the human copy on stderr.
55
+ */
56
+ export type PublishReport = {
57
+ state: "live" | "committed_not_visible";
58
+ version: number;
59
+ liveUrl: string;
60
+ waitedMs: number;
61
+ } | {
62
+ state: "failed";
63
+ exitCode: number;
64
+ };
40
65
  export declare function publishCommand(deps: PublishDeps, opts?: PublishOptions): Promise<number>;