@vincentt-xr/cli 0.17.0 → 0.18.1
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 +31 -0
- package/README.md +94 -1
- package/dist/cli/index.js +170 -84
- 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 +5 -11
- package/dist/preview/api.js +13 -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 +22 -5
- package/dist/publish/api.js +80 -9
- package/dist/publish/command.d.ts +27 -0
- package/dist/publish/command.js +261 -50
- 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 +59 -5
- package/dist/publish/upload.js +138 -30
- 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 +7 -2
- package/dist/shared/countLimit.js +12 -5
- package/dist/shared/http.d.ts +23 -0
- package/dist/shared/http.js +58 -0
- package/package.json +7 -3
package/dist/relay/server.d.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
+
import { type IncomingMessage } from "node:http";
|
|
1
2
|
import { EventStore } from "./store.js";
|
|
2
3
|
import { TesterRegistry } from "./testers.js";
|
|
3
4
|
import { AnnotationStore } from "./annotations.js";
|
|
4
5
|
import type { DiagEvent } from "@vincentt-xr/harness/events";
|
|
5
6
|
export interface RelayOptions {
|
|
6
7
|
port?: number;
|
|
8
|
+
/**
|
|
9
|
+
* The interface to listen on. Loopback by default and in every caller: the
|
|
10
|
+
* phone reaches the relay only through the tunnel client on this machine,
|
|
11
|
+
* which dials loopback, and `/query` and `feedback/next` are local-only.
|
|
12
|
+
*/
|
|
13
|
+
host?: string;
|
|
7
14
|
/** WS path the client connects to (default matches HarnessProvider). */
|
|
8
15
|
path?: string;
|
|
9
16
|
capacity?: number;
|
|
@@ -92,6 +99,20 @@ export declare function startRelay(opts?: RelayOptions): RelayHandle;
|
|
|
92
99
|
* does not match is not a label, and the events simply carry no viewer.
|
|
93
100
|
*/
|
|
94
101
|
export declare function readViewerLabel(raw: string | string[] | undefined): string | undefined;
|
|
102
|
+
/**
|
|
103
|
+
* Whether a request arrived through the preview tunnel. The tunnel client stamps
|
|
104
|
+
* `x-vincentt-viewer` on EVERY request it forwards, after it has sanitised the
|
|
105
|
+
* viewer's headers, so a viewer cannot remove it; a local CLI never sends it.
|
|
106
|
+
* Any value counts, including a malformed or duplicated one: presence is the
|
|
107
|
+
* signal, and a viewer adding a second copy only makes it more present.
|
|
108
|
+
*/
|
|
109
|
+
export declare function cameThroughTunnel(req: IncomingMessage): boolean;
|
|
110
|
+
/**
|
|
111
|
+
* A raw request path the relay refuses before `URL` normalises it: a `.` or `..`
|
|
112
|
+
* segment, or an encoded dot or separator. Without this, `/__harness/../query`
|
|
113
|
+
* normalises onto a local-only route.
|
|
114
|
+
*/
|
|
115
|
+
export declare function isUnsafeRelayPath(rawUrl: string): boolean;
|
|
95
116
|
/**
|
|
96
117
|
* Clamp the hold to `[0, MAX]`, explicitly, with `0` meaning ANSWER NOW.
|
|
97
118
|
*
|
package/dist/relay/server.js
CHANGED
|
@@ -117,7 +117,8 @@ export function startRelay(opts = {}) {
|
|
|
117
117
|
});
|
|
118
118
|
ws.on("close", () => log("client disconnected"));
|
|
119
119
|
});
|
|
120
|
-
|
|
120
|
+
const host = opts.host ?? "127.0.0.1";
|
|
121
|
+
http.listen(port, host, () => log(`relay listening on ${host}:${port} (ws ${wsPath}, http /query)`));
|
|
121
122
|
return {
|
|
122
123
|
store,
|
|
123
124
|
annotations,
|
|
@@ -140,9 +141,42 @@ export function readViewerLabel(raw) {
|
|
|
140
141
|
return undefined;
|
|
141
142
|
return VIEWER_LABEL_RE.test(raw) ? raw : undefined;
|
|
142
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Whether a request arrived through the preview tunnel. The tunnel client stamps
|
|
146
|
+
* `x-vincentt-viewer` on EVERY request it forwards, after it has sanitised the
|
|
147
|
+
* viewer's headers, so a viewer cannot remove it; a local CLI never sends it.
|
|
148
|
+
* Any value counts, including a malformed or duplicated one: presence is the
|
|
149
|
+
* signal, and a viewer adding a second copy only makes it more present.
|
|
150
|
+
*/
|
|
151
|
+
export function cameThroughTunnel(req) {
|
|
152
|
+
return req.headers[VIEWER_STAMP_HEADER] !== undefined;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* A raw request path the relay refuses before `URL` normalises it: a `.` or `..`
|
|
156
|
+
* segment, or an encoded dot or separator. Without this, `/__harness/../query`
|
|
157
|
+
* normalises onto a local-only route.
|
|
158
|
+
*/
|
|
159
|
+
export function isUnsafeRelayPath(rawUrl) {
|
|
160
|
+
const raw = rawUrl.split("?")[0] ?? "";
|
|
161
|
+
if (/%(2e|2f|5c)/i.test(raw) || raw.includes("\\"))
|
|
162
|
+
return true;
|
|
163
|
+
return raw.split("/").some((s) => s === "." || s === "..");
|
|
164
|
+
}
|
|
143
165
|
function handleHttp(req, res, ctx) {
|
|
166
|
+
if (isUnsafeRelayPath(req.url ?? "/")) {
|
|
167
|
+
res.writeHead(404).end();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
144
170
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
145
171
|
const p = url.pathname;
|
|
172
|
+
// Local-only: the diagnostics read and the reverse-channel drain belong to
|
|
173
|
+
// the creator's own CLI. A link holder reaches the relay only through the
|
|
174
|
+
// tunnel, and is refused here even if a route to it were ever opened.
|
|
175
|
+
const localOnly = p === "/query" || p === `${ctx.wsPath}/feedback/next`;
|
|
176
|
+
if (localOnly && cameThroughTunnel(req)) {
|
|
177
|
+
res.writeHead(404).end();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
146
180
|
if (p === "/query") {
|
|
147
181
|
handleQuery(req, res, url, ctx);
|
|
148
182
|
return;
|
|
@@ -1 +1,28 @@
|
|
|
1
|
+
export declare const AUTH_EXPIRED_MESSAGE = "Your Vincentt access token is no longer valid. Run `vincentt login` to sign in again.";
|
|
1
2
|
export declare function authFailureMessage(label: string, status: number, detail: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* The api refused the token. Distinct from every other refusal because the
|
|
5
|
+
* remedy is the same wherever it happens and it is not a retry: a command that
|
|
6
|
+
* reads it prints authExpiredLines() and exits EXIT.NOT_AUTHENTICATED.
|
|
7
|
+
*/
|
|
8
|
+
export declare class AuthExpiredError extends Error {
|
|
9
|
+
readonly status = 401;
|
|
10
|
+
constructor();
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Every authenticated api client calls this on a non-OK response before it
|
|
14
|
+
* reads the error body. authPaths.test.ts drives each client with a 401 and
|
|
15
|
+
* fails if one reaches its own error arm instead.
|
|
16
|
+
*/
|
|
17
|
+
export declare function rejectAuthExpired(res: Response): void;
|
|
18
|
+
export declare function authExpiredLines(): string[];
|
|
19
|
+
export declare const NOT_SIGNED_IN_LINES: readonly string[];
|
|
20
|
+
/** No token on this machine. Thrown where a verb needs one and cannot ask for it. */
|
|
21
|
+
export declare class NotSignedInError extends Error {
|
|
22
|
+
constructor();
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The api refused the token, in either shape a client reports it: the shared
|
|
26
|
+
* AuthExpiredError, or a client error carrying the 401 status.
|
|
27
|
+
*/
|
|
28
|
+
export declare function isAuthRefused(err: unknown): boolean;
|
package/dist/shared/authError.js
CHANGED
|
@@ -2,10 +2,54 @@
|
|
|
2
2
|
// universal first-run state: every existing `vct_` token was invalidated, so the raw
|
|
3
3
|
// "(401): <body>" is unactionable noise for every user at once. Name the cause and give
|
|
4
4
|
// the one instruction that fixes it. Any other status keeps its existing "(<n>): <body>"
|
|
5
|
-
// shape
|
|
5
|
+
// shape: a 500/timeout is a different problem and must not be mislabeled a stale token.
|
|
6
|
+
export const AUTH_EXPIRED_MESSAGE = "Your Vincentt access token is no longer valid. Run `vincentt login` to sign in again.";
|
|
6
7
|
export function authFailureMessage(label, status, detail) {
|
|
7
|
-
if (status === 401)
|
|
8
|
-
return
|
|
9
|
-
}
|
|
8
|
+
if (status === 401)
|
|
9
|
+
return AUTH_EXPIRED_MESSAGE;
|
|
10
10
|
return `${label} (${status})${detail ? `: ${detail}` : ""}`;
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* The api refused the token. Distinct from every other refusal because the
|
|
14
|
+
* remedy is the same wherever it happens and it is not a retry: a command that
|
|
15
|
+
* reads it prints authExpiredLines() and exits EXIT.NOT_AUTHENTICATED.
|
|
16
|
+
*/
|
|
17
|
+
export class AuthExpiredError extends Error {
|
|
18
|
+
status = 401;
|
|
19
|
+
constructor() {
|
|
20
|
+
super(AUTH_EXPIRED_MESSAGE);
|
|
21
|
+
this.name = "AuthExpiredError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Every authenticated api client calls this on a non-OK response before it
|
|
26
|
+
* reads the error body. authPaths.test.ts drives each client with a 401 and
|
|
27
|
+
* fails if one reaches its own error arm instead.
|
|
28
|
+
*/
|
|
29
|
+
export function rejectAuthExpired(res) {
|
|
30
|
+
if (res.status === 401)
|
|
31
|
+
throw new AuthExpiredError();
|
|
32
|
+
}
|
|
33
|
+
export function authExpiredLines() {
|
|
34
|
+
return ["", `✗ ${AUTH_EXPIRED_MESSAGE}`, ""];
|
|
35
|
+
}
|
|
36
|
+
export const NOT_SIGNED_IN_LINES = [
|
|
37
|
+
"✗ You're not signed in.",
|
|
38
|
+
" Run `vincentt login` to connect this machine to your Vincentt account.",
|
|
39
|
+
];
|
|
40
|
+
/** No token on this machine. Thrown where a verb needs one and cannot ask for it. */
|
|
41
|
+
export class NotSignedInError extends Error {
|
|
42
|
+
constructor() {
|
|
43
|
+
super(NOT_SIGNED_IN_LINES.join("\n"));
|
|
44
|
+
this.name = "NotSignedInError";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The api refused the token, in either shape a client reports it: the shared
|
|
49
|
+
* AuthExpiredError, or a client error carrying the 401 status.
|
|
50
|
+
*/
|
|
51
|
+
export function isAuthRefused(err) {
|
|
52
|
+
if (err instanceof AuthExpiredError)
|
|
53
|
+
return true;
|
|
54
|
+
return err instanceof Error && err.status === 401;
|
|
55
|
+
}
|
package/dist/shared/config.d.ts
CHANGED
|
@@ -117,7 +117,27 @@ export interface ResolvedConfig {
|
|
|
117
117
|
apiUrl: string;
|
|
118
118
|
pat: string;
|
|
119
119
|
}
|
|
120
|
-
|
|
120
|
+
/**
|
|
121
|
+
* Every environment variable the CLI reads a credential from. They are removed
|
|
122
|
+
* from the environment of the creator's dev command, which is repo-controlled.
|
|
123
|
+
* A credential read added to this file without a name here fails a unit test.
|
|
124
|
+
*/
|
|
125
|
+
export declare const CLI_CREDENTIAL_ENV: readonly string[];
|
|
126
|
+
export declare function withoutCliCredentials(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
127
|
+
export declare const DEFAULT_API_URL = "https://api.vincentt.studio";
|
|
128
|
+
/**
|
|
129
|
+
* The API host the person running the command configured: an explicit flag, then
|
|
130
|
+
* VINCENTT_API_URL, then the machine config, then the default. Every verb reads
|
|
131
|
+
* its host through here, so a token is never sent to one host by one verb and to
|
|
132
|
+
* another by the next. A binding's override is layered on top by decideApiUrl.
|
|
133
|
+
*/
|
|
134
|
+
export declare function configuredApiUrl(input: {
|
|
135
|
+
flagApiUrl?: string;
|
|
136
|
+
envApiUrl?: string;
|
|
137
|
+
machineApiUrl?: string;
|
|
138
|
+
}): string;
|
|
139
|
+
/** configuredApiUrl from this process's environment and a machine config already read. */
|
|
140
|
+
export declare function machineApiUrl(machine: Partial<MachineConfig>, flagApiUrl?: string): string;
|
|
121
141
|
export interface ResolveConfigOptions {
|
|
122
142
|
/** Honor a binding's apiUrl override (the `--allow-custom-api` flag). */
|
|
123
143
|
allowCustomApi?: boolean;
|
package/dist/shared/config.js
CHANGED
|
@@ -12,6 +12,7 @@ import { randomBytes } from "node:crypto";
|
|
|
12
12
|
import { promises as fs } from "node:fs";
|
|
13
13
|
import os from "node:os";
|
|
14
14
|
import path from "node:path";
|
|
15
|
+
import { NotSignedInError } from "./authError.js";
|
|
15
16
|
import { recordLocation } from "./recordLocation.js";
|
|
16
17
|
export const MACHINE_CONFIG_PATH = path.join(os.homedir(), ".vincentt", "config.json");
|
|
17
18
|
const BINDING_DIR = ".vincentt";
|
|
@@ -216,9 +217,41 @@ async function ensureGitignored(cwd, pattern) {
|
|
|
216
217
|
// A missing/unwritable .gitignore must not fail project creation.
|
|
217
218
|
}
|
|
218
219
|
}
|
|
219
|
-
|
|
220
|
+
/**
|
|
221
|
+
* Every environment variable the CLI reads a credential from. They are removed
|
|
222
|
+
* from the environment of the creator's dev command, which is repo-controlled.
|
|
223
|
+
* A credential read added to this file without a name here fails a unit test.
|
|
224
|
+
*/
|
|
225
|
+
export const CLI_CREDENTIAL_ENV = ["VINCENTT_PAT"];
|
|
226
|
+
export function withoutCliCredentials(env) {
|
|
227
|
+
const out = { ...env };
|
|
228
|
+
// Compared upper-cased: Windows environment names are case-insensitive.
|
|
229
|
+
for (const key of Object.keys(out)) {
|
|
230
|
+
if (CLI_CREDENTIAL_ENV.includes(key.toUpperCase()))
|
|
231
|
+
delete out[key];
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
export const DEFAULT_API_URL = "https://api.vincentt.studio";
|
|
236
|
+
/**
|
|
237
|
+
* The API host the person running the command configured: an explicit flag, then
|
|
238
|
+
* VINCENTT_API_URL, then the machine config, then the default. Every verb reads
|
|
239
|
+
* its host through here, so a token is never sent to one host by one verb and to
|
|
240
|
+
* another by the next. A binding's override is layered on top by decideApiUrl.
|
|
241
|
+
*/
|
|
242
|
+
export function configuredApiUrl(input) {
|
|
243
|
+
return strip(input.flagApiUrl || input.envApiUrl || input.machineApiUrl || DEFAULT_API_URL);
|
|
244
|
+
}
|
|
245
|
+
/** configuredApiUrl from this process's environment and a machine config already read. */
|
|
246
|
+
export function machineApiUrl(machine, flagApiUrl) {
|
|
247
|
+
return configuredApiUrl({
|
|
248
|
+
flagApiUrl,
|
|
249
|
+
envApiUrl: process.env.VINCENTT_API_URL,
|
|
250
|
+
machineApiUrl: machine.apiUrl,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
220
253
|
export function decideApiUrl(input) {
|
|
221
|
-
const configuredUrl = input
|
|
254
|
+
const configuredUrl = configuredApiUrl(input);
|
|
222
255
|
const binding = input.bindingApiUrl;
|
|
223
256
|
if (!binding || strip(binding) === strip(configuredUrl)) {
|
|
224
257
|
return { apiUrl: configuredUrl, configuredUrl };
|
|
@@ -255,10 +288,8 @@ export async function resolveConfig(cwd, opts = {}) {
|
|
|
255
288
|
}
|
|
256
289
|
}
|
|
257
290
|
const pat = process.env.VINCENTT_PAT ?? machine.pat;
|
|
258
|
-
if (!pat)
|
|
259
|
-
throw new
|
|
260
|
-
`or set the VINCENTT_PAT env var.`);
|
|
261
|
-
}
|
|
291
|
+
if (!pat)
|
|
292
|
+
throw new NotSignedInError();
|
|
262
293
|
return { apiUrl: strip(decided.apiUrl), pat };
|
|
263
294
|
}
|
|
264
295
|
const BINDING_REL_PATH = `${BINDING_DIR}/${BINDING_FILE}`;
|
|
@@ -292,8 +323,8 @@ export function apiUrlAdvisory(decided, ctx) {
|
|
|
292
323
|
` your machine config:${pad} ${decided.configuredUrl}`,
|
|
293
324
|
"",
|
|
294
325
|
" Your access token is sent with every request, so it would go to",
|
|
295
|
-
` ${hostOf(decided.ignoredBindingApiUrl)}. A binding file travels with a repository
|
|
296
|
-
" anyone who can commit to it can set this.",
|
|
326
|
+
` ${hostOf(decided.ignoredBindingApiUrl)}. A binding file travels with a repository,`,
|
|
327
|
+
" so anyone who can commit to it can set this.",
|
|
297
328
|
"",
|
|
298
329
|
" Ignoring the binding and using your machine config.",
|
|
299
330
|
" To use the binding's API on purpose, re-run with --allow-custom-api.",
|
|
@@ -74,7 +74,12 @@ export declare function countRows(i: ResolvedCountLimit, axisLabel: string, plan
|
|
|
74
74
|
*/
|
|
75
75
|
export declare function consoleLines(i: ResolvedCountLimit, path: string): string[];
|
|
76
76
|
/**
|
|
77
|
-
* The published-project ceiling, refused at open-publish.
|
|
77
|
+
* The published-project ceiling, refused at open-publish or at commit.
|
|
78
|
+
*
|
|
79
|
+
* Commit re-checks because two publishes opened in the same org can both be
|
|
80
|
+
* admitted and race to go live; the loser is refused after its bytes were
|
|
81
|
+
* uploaded. The api keeps those bytes as a stored version, so the last line
|
|
82
|
+
* changes to say so and nothing else does.
|
|
78
83
|
*
|
|
79
84
|
* "One published project" means ONE LIVE ADDRESS AT A TIME, and the copy says
|
|
80
85
|
* so, because the other reading — one project ever published — is what makes a
|
|
@@ -85,7 +90,7 @@ export declare function consoleLines(i: ResolvedCountLimit, path: string): strin
|
|
|
85
90
|
* though it is: a creator refused here is publishing a SECOND project, so the
|
|
86
91
|
* exclusion never applies on the screen that would explain it.
|
|
87
92
|
*/
|
|
88
|
-
export declare function publishedLimitRefused(i: ResolvedCountLimit): string[];
|
|
93
|
+
export declare function publishedLimitRefused(i: ResolvedCountLimit, stage?: "open" | "commit"): string[];
|
|
89
94
|
/**
|
|
90
95
|
* The concurrent-preview ceiling, refused at mint.
|
|
91
96
|
*
|
|
@@ -61,7 +61,7 @@ export function countRows(i, axisLabel, planSuffix) {
|
|
|
61
61
|
if (i.orgName || i.orgSlug)
|
|
62
62
|
rows.push(["Organization", orgLabel(i.orgName, i.orgSlug)]);
|
|
63
63
|
if (i.planName)
|
|
64
|
-
rows.push(["Plan", `${i.planName}
|
|
64
|
+
rows.push(["Plan", `${i.planName} (${planSuffix})`]);
|
|
65
65
|
rows.push([axisLabel, `${i.count} of ${i.ceiling} used`]);
|
|
66
66
|
return alignedRows(rows);
|
|
67
67
|
}
|
|
@@ -92,7 +92,12 @@ export function consoleLines(i, path) {
|
|
|
92
92
|
// A renderer for it added here would be an export with no caller, which is worse
|
|
93
93
|
// than nothing: it reads as coverage and cannot fail.
|
|
94
94
|
/**
|
|
95
|
-
* The published-project ceiling, refused at open-publish.
|
|
95
|
+
* The published-project ceiling, refused at open-publish or at commit.
|
|
96
|
+
*
|
|
97
|
+
* Commit re-checks because two publishes opened in the same org can both be
|
|
98
|
+
* admitted and race to go live; the loser is refused after its bytes were
|
|
99
|
+
* uploaded. The api keeps those bytes as a stored version, so the last line
|
|
100
|
+
* changes to say so and nothing else does.
|
|
96
101
|
*
|
|
97
102
|
* "One published project" means ONE LIVE ADDRESS AT A TIME, and the copy says
|
|
98
103
|
* so, because the other reading — one project ever published — is what makes a
|
|
@@ -103,7 +108,7 @@ export function consoleLines(i, path) {
|
|
|
103
108
|
* though it is: a creator refused here is publishing a SECOND project, so the
|
|
104
109
|
* exclusion never applies on the screen that would explain it.
|
|
105
110
|
*/
|
|
106
|
-
export function publishedLimitRefused(i) {
|
|
111
|
+
export function publishedLimitRefused(i, stage = "open") {
|
|
107
112
|
return [
|
|
108
113
|
"",
|
|
109
114
|
"✗ This organization is already publishing as many projects as its plan allows.",
|
|
@@ -115,7 +120,9 @@ export function publishedLimitRefused(i) {
|
|
|
115
120
|
" everything already published stays exactly as it is.",
|
|
116
121
|
"",
|
|
117
122
|
...consoleLines(i, "projects"),
|
|
118
|
-
|
|
123
|
+
stage === "commit"
|
|
124
|
+
? " Your files were uploaded and kept, but nothing went live."
|
|
125
|
+
: " Nothing was uploaded.",
|
|
119
126
|
"",
|
|
120
127
|
];
|
|
121
128
|
}
|
|
@@ -135,7 +142,7 @@ export function previewLimitRefused(i) {
|
|
|
135
142
|
"",
|
|
136
143
|
...countRows(i, "Previews", "previews are counted per organization"),
|
|
137
144
|
"",
|
|
138
|
-
" Every preview that was running is still running
|
|
145
|
+
" Every preview that was running is still running. This one did not start.",
|
|
139
146
|
" A preview stops when whoever started it stops it, or when it reaches its",
|
|
140
147
|
" 12-hour limit.",
|
|
141
148
|
"",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** A request that did not finish inside its deadline. Nothing is known about its outcome. */
|
|
2
|
+
export declare class RequestTimeout extends Error {
|
|
3
|
+
readonly ms: number;
|
|
4
|
+
constructor(ms: number);
|
|
5
|
+
}
|
|
6
|
+
export declare const API_DEADLINE_MS = 30000;
|
|
7
|
+
/**
|
|
8
|
+
* Wrap a fetch so each call is aborted after `ms`. The deadline covers the body
|
|
9
|
+
* read too: the timer is not cleared when headers arrive, and aborting a request
|
|
10
|
+
* whose body was already read is a no-op. The timer is unref'd so it never holds
|
|
11
|
+
* the process open. A caller's own signal still aborts the request.
|
|
12
|
+
*/
|
|
13
|
+
export declare function withDeadline(fetchImpl: typeof fetch, ms: number): typeof fetch;
|
|
14
|
+
/**
|
|
15
|
+
* The global fetch with the api deadline. Looked up per call, never captured, so
|
|
16
|
+
* a test that stubs the global still reaches its stub.
|
|
17
|
+
*/
|
|
18
|
+
export declare const apiFetch: typeof fetch;
|
|
19
|
+
/**
|
|
20
|
+
* A request abandoned by the network or by its deadline, as opposed to a
|
|
21
|
+
* response the server sent. Its outcome on the server is unknown.
|
|
22
|
+
*/
|
|
23
|
+
export declare function isTransportError(err: unknown): boolean;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// The one fetch wrapper with a deadline. Every api call goes through apiFetch, so
|
|
2
|
+
// a stalled server or a dead network ends in a bounded error instead of a hang
|
|
3
|
+
// (including the Ctrl-C path, which ends the preview session with an api call).
|
|
4
|
+
/** A request that did not finish inside its deadline. Nothing is known about its outcome. */
|
|
5
|
+
export class RequestTimeout extends Error {
|
|
6
|
+
ms;
|
|
7
|
+
constructor(ms) {
|
|
8
|
+
super(`the request did not finish within ${Math.round(ms / 1000)}s`);
|
|
9
|
+
this.ms = ms;
|
|
10
|
+
this.name = "RequestTimeout";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export const API_DEADLINE_MS = 30_000;
|
|
14
|
+
/**
|
|
15
|
+
* Wrap a fetch so each call is aborted after `ms`. The deadline covers the body
|
|
16
|
+
* read too: the timer is not cleared when headers arrive, and aborting a request
|
|
17
|
+
* whose body was already read is a no-op. The timer is unref'd so it never holds
|
|
18
|
+
* the process open. A caller's own signal still aborts the request.
|
|
19
|
+
*/
|
|
20
|
+
export function withDeadline(fetchImpl, ms) {
|
|
21
|
+
return (async (input, init = {}) => {
|
|
22
|
+
const controller = new AbortController();
|
|
23
|
+
const timer = setTimeout(() => controller.abort(new RequestTimeout(ms)), ms);
|
|
24
|
+
timer.unref?.();
|
|
25
|
+
const outer = init.signal;
|
|
26
|
+
if (outer) {
|
|
27
|
+
if (outer.aborted)
|
|
28
|
+
controller.abort(outer.reason);
|
|
29
|
+
else
|
|
30
|
+
outer.addEventListener("abort", () => controller.abort(outer.reason), {
|
|
31
|
+
once: true,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
return await fetchImpl(input, { ...init, signal: controller.signal });
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
if (controller.signal.reason instanceof RequestTimeout)
|
|
39
|
+
throw controller.signal.reason;
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The global fetch with the api deadline. Looked up per call, never captured, so
|
|
46
|
+
* a test that stubs the global still reaches its stub.
|
|
47
|
+
*/
|
|
48
|
+
export const apiFetch = withDeadline((input, init) => globalThis.fetch(input, init), API_DEADLINE_MS);
|
|
49
|
+
/**
|
|
50
|
+
* A request abandoned by the network or by its deadline, as opposed to a
|
|
51
|
+
* response the server sent. Its outcome on the server is unknown.
|
|
52
|
+
*/
|
|
53
|
+
export function isTransportError(err) {
|
|
54
|
+
if (err instanceof RequestTimeout)
|
|
55
|
+
return true;
|
|
56
|
+
// undici reports a refused, reset or unresolvable connection as a TypeError.
|
|
57
|
+
return err instanceof TypeError || (err instanceof Error && err.name === "AbortError");
|
|
58
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincentt-xr/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.1",
|
|
4
4
|
"description": "Vincentt CLI — the vincentt command-line dev loop (login, create, publish, preview, diagnostics, feedback, relay)",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"files": [
|
|
8
|
-
"dist"
|
|
8
|
+
"dist",
|
|
9
|
+
"MIGRATION.md"
|
|
9
10
|
],
|
|
10
11
|
"exports": {
|
|
11
12
|
"./preview": {
|
|
@@ -20,11 +21,14 @@
|
|
|
20
21
|
"bin": {
|
|
21
22
|
"vincentt": "./dist/cli/index.js"
|
|
22
23
|
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
23
27
|
"dependencies": {
|
|
24
28
|
"qrcode": "^1.5.4",
|
|
25
29
|
"ws": "^8.18.0",
|
|
26
30
|
"zod": "^3.23.8",
|
|
27
|
-
"@vincentt-xr/harness": "1.
|
|
31
|
+
"@vincentt-xr/harness": "1.6.1"
|
|
28
32
|
},
|
|
29
33
|
"devDependencies": {
|
|
30
34
|
"@types/qrcode": "^1.5.5",
|