@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/login/ensure.js
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
|
-
// First-run login guard. An auth-requiring verb
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
/**
|
|
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.
|
|
12
|
-
|
|
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
|
}
|
package/dist/login/guard.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
15
|
-
|
|
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
|
|
18
|
-
whoAmI: () => Promise<Identity
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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();
|
package/dist/login/pkce.js
CHANGED
|
@@ -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
|
|
84
|
+
message: "State mismatch. Ignoring an unexpected callback.",
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
87
|
const error = params.get("error");
|
package/dist/login/pkceLogin.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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}
|
|
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
|
|
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" ||
|
|
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
|
|
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
|
|
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}
|
|
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}
|
|
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
|
-
? [
|
|
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
|
|
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) {
|
package/dist/preview/api.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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;
|
package/dist/preview/api.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
package/dist/preview/command.js
CHANGED
|
@@ -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.
|
|
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":
|