@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.
- package/MIGRATION.md +24 -0
- package/README.md +94 -1
- package/dist/cli/index.js +196 -87
- package/dist/cli/usage-text.js +32 -10
- package/dist/domain/api.d.ts +10 -8
- package/dist/domain/api.js +14 -9
- package/dist/domain/command.js +25 -16
- package/dist/domain/copy.d.ts +6 -3
- package/dist/domain/copy.js +10 -6
- package/dist/dx/command.d.ts +24 -4
- package/dist/dx/command.js +80 -25
- package/dist/dx/consent.js +6 -5
- package/dist/dx/journal.d.ts +17 -2
- package/dist/dx/journal.js +19 -3
- package/dist/dx/report.js +4 -3
- package/dist/dx/reportIo.js +6 -5
- package/dist/dx/send.js +6 -5
- package/dist/lib/backend.d.ts +0 -16
- package/dist/lib/backend.js +2 -26
- package/dist/lib/detect.d.ts +87 -0
- package/dist/lib/detect.js +867 -0
- package/dist/lib/diagnostics.js +2 -2
- package/dist/lib/feedback.js +1 -1
- package/dist/lib/feedbackCopy.js +1 -1
- package/dist/lib/initFrom.js +4 -4
- package/dist/lib/lifecycle.d.ts +2 -0
- package/dist/lib/lifecycle.js +9 -3
- package/dist/lib/listProjects.js +2 -1
- package/dist/lib/projects.d.ts +37 -2
- package/dist/lib/projects.js +86 -21
- package/dist/lib/settings.d.ts +113 -0
- package/dist/lib/settings.js +284 -0
- package/dist/lib/settingsCopy.d.ts +6 -0
- package/dist/lib/settingsCopy.js +99 -0
- package/dist/lib/settle.d.ts +61 -0
- package/dist/lib/settle.js +338 -0
- package/dist/lib/template.d.ts +19 -0
- package/dist/lib/template.js +73 -0
- package/dist/lib/terminalText.d.ts +3 -0
- package/dist/lib/terminalText.js +10 -0
- package/dist/lib/version.js +5 -11
- package/dist/lib/whoami.js +10 -3
- package/dist/library/assetsCommand.js +1 -1
- package/dist/library/client.js +8 -3
- package/dist/library/render.js +8 -6
- package/dist/login/authBackend.js +3 -2
- package/dist/login/ensure.d.ts +8 -2
- package/dist/login/ensure.js +21 -10
- package/dist/login/guard.js +5 -4
- package/dist/login/loginCommand.d.ts +2 -2
- package/dist/login/loginCommand.js +29 -5
- package/dist/login/pkce.js +1 -1
- package/dist/login/pkceLogin.js +1 -1
- package/dist/platform/outdated.js +2 -2
- package/dist/platform/upgradeReport.js +16 -12
- package/dist/preview/api.d.ts +24 -11
- package/dist/preview/api.js +42 -15
- package/dist/preview/command.d.ts +29 -0
- package/dist/preview/command.js +61 -1
- package/dist/preview/copy.d.ts +35 -3
- package/dist/preview/copy.js +102 -10
- package/dist/preview/proxy.d.ts +38 -1
- package/dist/preview/proxy.js +151 -2
- package/dist/preview/qrPage.js +1 -1
- package/dist/preview/runner.d.ts +25 -13
- package/dist/preview/runner.js +127 -26
- package/dist/preview/viewers.js +1 -1
- package/dist/publish/api.d.ts +39 -6
- package/dist/publish/api.js +80 -9
- package/dist/publish/command.d.ts +25 -0
- package/dist/publish/command.js +230 -32
- package/dist/publish/copy.d.ts +78 -5
- package/dist/publish/copy.js +271 -37
- package/dist/publish/fit.d.ts +15 -0
- package/dist/publish/fit.js +103 -0
- package/dist/publish/manifest.d.ts +44 -3
- package/dist/publish/manifest.js +172 -3
- package/dist/publish/prompt.d.ts +4 -0
- package/dist/publish/prompt.js +15 -1
- package/dist/publish/upload.d.ts +6 -0
- package/dist/publish/upload.js +11 -1
- package/dist/publish/visibility.d.ts +21 -0
- package/dist/publish/visibility.js +68 -0
- package/dist/relay/annotations.js +9 -4
- package/dist/relay/server.d.ts +21 -0
- package/dist/relay/server.js +35 -1
- package/dist/shared/authError.d.ts +27 -0
- package/dist/shared/authError.js +48 -4
- package/dist/shared/config.d.ts +21 -1
- package/dist/shared/config.js +39 -8
- package/dist/shared/countLimit.d.ts +114 -0
- package/dist/shared/countLimit.js +176 -0
- package/dist/shared/http.d.ts +23 -0
- package/dist/shared/http.js +58 -0
- package/package.json +7 -3
package/dist/preview/runner.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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) =>
|
|
102
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
221
|
-
*
|
|
222
|
-
*
|
|
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
|
-
*
|
|
247
|
-
*
|
|
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
|
package/dist/preview/viewers.js
CHANGED
|
@@ -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
|
|
174
|
+
` it is ${label} now. Labels last one connection. Testers survive a refresh.`,
|
|
175
175
|
];
|
|
176
176
|
}
|
|
177
177
|
disconnect(label, now) {
|
package/dist/publish/api.d.ts
CHANGED
|
@@ -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:
|
|
19
|
+
publishState: PublishState | "unknown";
|
|
10
20
|
firstPublishedAt: string | null;
|
|
11
21
|
liveVersion: number | null;
|
|
12
22
|
highestVersion: number | null;
|
|
@@ -31,17 +41,32 @@ 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
|
-
|
|
41
|
-
|
|
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
|
+
/**
|
|
62
|
+
* ⚠ `limit` CARRIES TWO DIFFERENT TYPES on two different codes, and the api
|
|
63
|
+
* owns both. On `publish_too_large` and f16's domain ceiling it is a
|
|
64
|
+
* MAGNITUDE (bytes, a count); on the three count refusals it is the AXIS NAME
|
|
65
|
+
* ("seats" | "published" | "previews" | "storage"). The two never appear on
|
|
66
|
+
* the same body, so the union is read at the arm that knows which code it is
|
|
67
|
+
* handling and never generically.
|
|
68
|
+
*/
|
|
69
|
+
limit?: number | string;
|
|
45
70
|
observed?: number;
|
|
46
71
|
missing?: number;
|
|
47
72
|
mismatched?: number;
|
|
@@ -67,6 +92,14 @@ export interface ApiErrorDetail {
|
|
|
67
92
|
*/
|
|
68
93
|
orgName?: string;
|
|
69
94
|
orgSlug?: string;
|
|
95
|
+
/**
|
|
96
|
+
* The count refusals' two figures — the org's current occupancy on the axis,
|
|
97
|
+
* and the plan's ceiling for it. Both are required for the refusal to
|
|
98
|
+
* discharge B-F10-2: a refusal naming neither is a refusal with no notice in
|
|
99
|
+
* it.
|
|
100
|
+
*/
|
|
101
|
+
count?: number;
|
|
102
|
+
ceiling?: number;
|
|
70
103
|
}
|
|
71
104
|
export declare class PublishApiError extends Error {
|
|
72
105
|
readonly status: number;
|
package/dist/publish/api.js
CHANGED
|
@@ -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
|
|
3
|
-
// once
|
|
4
|
-
//
|
|
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 =
|
|
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")
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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>;
|