@vincentt-xr/cli 0.1.5 → 0.3.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/README.md +94 -0
- package/dist/cli/args.d.ts +14 -0
- package/dist/cli/args.js +51 -0
- package/dist/cli/index.js +416 -100
- package/dist/lib/backend.d.ts +44 -20
- package/dist/lib/backend.js +98 -51
- package/dist/lib/diagnostics.d.ts +27 -2
- package/dist/lib/diagnostics.js +73 -8
- package/dist/lib/initCommand.d.ts +14 -0
- package/dist/lib/initCommand.js +75 -0
- package/dist/lib/lifecycle.d.ts +85 -19
- package/dist/lib/lifecycle.js +133 -130
- package/dist/lib/org.d.ts +29 -0
- package/dist/lib/org.js +45 -0
- package/dist/lib/projects.d.ts +82 -0
- package/dist/lib/projects.js +217 -0
- package/dist/lib/relayInfo.d.ts +41 -3
- package/dist/lib/relayInfo.js +94 -0
- package/dist/lib/whoami.d.ts +11 -0
- package/dist/lib/whoami.js +38 -0
- package/dist/login/authBackend.d.ts +30 -0
- package/dist/login/authBackend.js +80 -0
- package/dist/login/guard.d.ts +13 -0
- package/dist/login/guard.js +17 -0
- package/dist/login/loginCommand.d.ts +39 -0
- package/dist/login/loginCommand.js +72 -0
- package/dist/login/pkce.d.ts +47 -0
- package/dist/login/pkce.js +106 -0
- package/dist/login/pkceLogin.d.ts +53 -0
- package/dist/login/pkceLogin.js +152 -0
- package/dist/mcp/server.js +52 -25
- package/dist/preview/api.d.ts +84 -0
- package/dist/preview/api.js +174 -0
- package/dist/preview/command.d.ts +96 -0
- package/dist/preview/command.js +276 -0
- package/dist/preview/copy.d.ts +209 -0
- package/dist/preview/copy.js +444 -0
- package/dist/preview/index.d.ts +2 -3
- package/dist/preview/index.js +5 -6
- package/dist/preview/proxy.d.ts +12 -1
- package/dist/preview/proxy.js +17 -7
- package/dist/preview/qr.d.ts +30 -0
- package/dist/preview/qr.js +89 -0
- package/dist/preview/runner.d.ts +155 -30
- package/dist/preview/runner.js +219 -68
- package/dist/preview/stream.d.ts +52 -0
- package/dist/preview/stream.js +171 -0
- package/dist/preview/viewers.d.ts +113 -0
- package/dist/preview/viewers.js +242 -0
- package/dist/publish/api.d.ts +67 -0
- package/dist/publish/api.js +96 -0
- package/dist/publish/bounds.d.ts +25 -0
- package/dist/publish/bounds.js +53 -0
- package/dist/publish/classify.d.ts +51 -0
- package/dist/publish/classify.js +138 -0
- package/dist/publish/command.d.ts +27 -0
- package/dist/publish/command.js +328 -0
- package/dist/publish/consoleVerbs.d.ts +17 -0
- package/dist/publish/consoleVerbs.js +26 -0
- package/dist/publish/copy.d.ts +180 -0
- package/dist/publish/copy.js +606 -0
- package/dist/publish/defaults.d.ts +16 -0
- package/dist/publish/defaults.js +52 -0
- package/dist/publish/manifest.d.ts +27 -0
- package/dist/publish/manifest.js +81 -0
- package/dist/publish/paths.d.ts +25 -0
- package/dist/publish/paths.js +78 -0
- package/dist/publish/progress.d.ts +17 -0
- package/dist/publish/progress.js +45 -0
- package/dist/publish/prompt.d.ts +4 -0
- package/dist/publish/prompt.js +31 -0
- package/dist/publish/qa.f4.fixtures.d.ts +25 -0
- package/dist/publish/qa.f4.fixtures.js +140 -0
- package/dist/publish/upload.d.ts +30 -0
- package/dist/publish/upload.js +93 -0
- package/dist/relay/server.d.ts +32 -0
- package/dist/relay/server.js +80 -12
- package/dist/relay/store.d.ts +32 -3
- package/dist/relay/store.js +76 -22
- package/dist/relay/testers.d.ts +90 -0
- package/dist/relay/testers.js +144 -0
- package/dist/shared/authError.d.ts +1 -0
- package/dist/shared/authError.js +11 -0
- package/dist/shared/config.d.ts +73 -5
- package/dist/shared/config.js +133 -20
- package/package.json +2 -3
- package/dist/login/login.d.ts +0 -34
- package/dist/login/login.js +0 -148
- package/dist/preview/cloudflared.d.ts +0 -13
- package/dist/preview/cloudflared.js +0 -46
- package/dist/preview/tunnel.d.ts +0 -14
- package/dist/preview/tunnel.js +0 -28
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { UAClass } from "@vincentt-xr/harness/tunnel";
|
|
2
|
+
/** The roster block prints once, on the FOURTH TESTER's arrival, and never again. */
|
|
3
|
+
export declare const ROSTER_AT = 4;
|
|
4
|
+
/**
|
|
5
|
+
* The reconnect advisory's window (Flow L17). It prints only when a connection
|
|
6
|
+
* attaches within 120s of a disconnect WITH THE SAME UA CLASS. On a first
|
|
7
|
+
* connection, or at a demo where four fresh phones arrive, the creator never
|
|
8
|
+
* sees it.
|
|
9
|
+
*/
|
|
10
|
+
export declare const RECONNECT_WINDOW_MS = 120000;
|
|
11
|
+
export interface Viewer {
|
|
12
|
+
readonly label: string;
|
|
13
|
+
readonly uaClass: UAClass;
|
|
14
|
+
readonly connectedAt: number;
|
|
15
|
+
/** The tester this connection resolved to, once the harness introduced itself. */
|
|
16
|
+
readonly tester?: string;
|
|
17
|
+
}
|
|
18
|
+
/** One printable line, or a block of them. The runner writes; this decides. */
|
|
19
|
+
export interface ViewerLines {
|
|
20
|
+
/** `tester-1 connected · iPhone · Safari` */
|
|
21
|
+
readonly connect?: string;
|
|
22
|
+
/**
|
|
23
|
+
* The `!` reconnect advisory, printed BELOW the connect line so the connect
|
|
24
|
+
* line stays scannable. Held rather than printed immediately — see
|
|
25
|
+
* `attribute`.
|
|
26
|
+
*/
|
|
27
|
+
readonly advisory?: string[];
|
|
28
|
+
/** The one-time roster block, on the fourth tester. */
|
|
29
|
+
readonly roster?: string[];
|
|
30
|
+
}
|
|
31
|
+
export declare class ViewerRoster {
|
|
32
|
+
private readonly live;
|
|
33
|
+
/** Connections seen, ever. Printed once at the end; never a count of people. */
|
|
34
|
+
private total;
|
|
35
|
+
private rosterPrinted;
|
|
36
|
+
/** The most recent disconnect, for the L17 adjacency check. */
|
|
37
|
+
private lastDeparture;
|
|
38
|
+
/** Testers ever seen live, so a refresh is recognised as a return. */
|
|
39
|
+
private readonly testersSeen;
|
|
40
|
+
/**
|
|
41
|
+
* A held advisory per connection, keyed by label. The connection arrives before
|
|
42
|
+
* the harness mounts, so at connect time we do not yet know whether this is a
|
|
43
|
+
* known tester — and a hedge printed beside a definite `tester-1 refreshed`
|
|
44
|
+
* teaches the creator to distrust the definite line. So the advisory is
|
|
45
|
+
* DEFERRED here and dropped if attribution lands. A retracted line in a piped
|
|
46
|
+
* transcript is worse than a late one.
|
|
47
|
+
*/
|
|
48
|
+
private readonly heldAdvisory;
|
|
49
|
+
/** Live CONNECTIONS. The console counts these; the CLI does not display them. */
|
|
50
|
+
get size(): number;
|
|
51
|
+
get totalConnections(): number;
|
|
52
|
+
/** Live TESTERS — distinct, attributed, currently connected. The display count. */
|
|
53
|
+
get testerCount(): number;
|
|
54
|
+
/** Live connections with no tester yet: the pre-mount window, or a crashed app. */
|
|
55
|
+
get unattributedCount(): number;
|
|
56
|
+
labels(): string[];
|
|
57
|
+
has(label: string): boolean;
|
|
58
|
+
get(label: string): Viewer | undefined;
|
|
59
|
+
/** Distinct live testers, in arrival order. */
|
|
60
|
+
liveTesters(): string[];
|
|
61
|
+
/**
|
|
62
|
+
* connect records a CONNECTION. It prints nothing about a tester yet, because
|
|
63
|
+
* the tester is not known until the app mounts — see `attribute`, which is
|
|
64
|
+
* where the line a creator reads comes from.
|
|
65
|
+
*
|
|
66
|
+
* Idempotent per label: the tunnel client fires onViewer on EVERY OPEN frame,
|
|
67
|
+
* not once per connection, so a viewer that requests forty assets would
|
|
68
|
+
* otherwise announce itself forty times.
|
|
69
|
+
*/
|
|
70
|
+
connect(label: string, uaClass: UAClass, now: number): ViewerLines;
|
|
71
|
+
/**
|
|
72
|
+
* The harness introduced itself: this connection belongs to `tester`. This is
|
|
73
|
+
* where the creator's line comes from.
|
|
74
|
+
*
|
|
75
|
+
* Returns the connect line on a tester's FIRST connection, `refreshed` when a
|
|
76
|
+
* tester already seen comes back, and nothing for a second concurrent
|
|
77
|
+
* connection belonging to a tester already live — which is the ordinary case
|
|
78
|
+
* for one tab, and the reason one scan now reads as one thing.
|
|
79
|
+
*/
|
|
80
|
+
attribute(label: string, tester: string): ViewerLines;
|
|
81
|
+
/**
|
|
82
|
+
* The advisory leads with the RULE and follows with the instance. Leading with
|
|
83
|
+
* a guess about this specific connection would teach the creator a hedge rather
|
|
84
|
+
* than a behavior, and one uncertain sentence on a screen where everything
|
|
85
|
+
* else is fact teaches them to distrust the labels generally — which is the
|
|
86
|
+
* opposite of what the labels are for.
|
|
87
|
+
*
|
|
88
|
+
* It names CONNECTIONS, not devices, and closes on the fact that testers
|
|
89
|
+
* survive a refresh — so the remedy for the uncertainty is on the same line as
|
|
90
|
+
* the uncertainty.
|
|
91
|
+
*/
|
|
92
|
+
private reconnectAdvisory;
|
|
93
|
+
disconnect(label: string, now: number): ViewerLines;
|
|
94
|
+
/**
|
|
95
|
+
* The roster is a ONE-TIME SIGNPOST, not a live-updating footer. A redrawn
|
|
96
|
+
* status bar fights Ctrl-C, corrupts scrollback, breaks when the output is
|
|
97
|
+
* piped to a file — which is exactly what an agent does with it — and turns a
|
|
98
|
+
* plain stream into a TUI, which is a different product.
|
|
99
|
+
*
|
|
100
|
+
* It LEADS WITH A COUNT, and the count is exact: distinct attributed testers
|
|
101
|
+
* currently live. Never the number ever assigned, never a connection count.
|
|
102
|
+
* The old disclaimer ("this is not a device count") is gone — it existed only
|
|
103
|
+
* because the number above it was wrong.
|
|
104
|
+
*/
|
|
105
|
+
rosterBlock(): string[];
|
|
106
|
+
/**
|
|
107
|
+
* The `--viewer` miss (Flow L16). An unknown label is a FACT, not an error,
|
|
108
|
+
* because labels churn — and the remedy is on the same line.
|
|
109
|
+
*/
|
|
110
|
+
notFollowing(label: string): string;
|
|
111
|
+
/** The `--tester` miss. Same shape, different coordinate. */
|
|
112
|
+
notFollowingTester(tester: string): string;
|
|
113
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// The roster: who is connected, what the connect/disconnect lines say, when the
|
|
2
|
+
// roster block prints, and when the reconnect advisory prints.
|
|
3
|
+
//
|
|
4
|
+
// TWO COORDINATES, and the difference is the whole point of this file:
|
|
5
|
+
//
|
|
6
|
+
// viewer-N one CONNECTION. Assigned by the edge at the first HTTP byte, so it
|
|
7
|
+
// exists immediately — and churns, because a browser opens several
|
|
8
|
+
// per tab under HTTP/1.1 and a reconnect never reuses a label.
|
|
9
|
+
// tester-N one BROWSER TAB, across its refreshes. Assigned by the relay when
|
|
10
|
+
// the app mounts and the harness introduces itself, so it arrives
|
|
11
|
+
// LATE — and is stable, which is what makes it the display unit.
|
|
12
|
+
//
|
|
13
|
+
// The creator reads testers. `--viewer` still filters connections for
|
|
14
|
+
// connection-level debugging. Neither is derived from the other.
|
|
15
|
+
//
|
|
16
|
+
// It is a pure state machine over (label, uaClass, tester, now), so every rule
|
|
17
|
+
// below is unit-testable without a terminal or a socket. The runner owns the
|
|
18
|
+
// printing.
|
|
19
|
+
import { uaLabel, uaShort } from "./stream.js";
|
|
20
|
+
/** The roster block prints once, on the FOURTH TESTER's arrival, and never again. */
|
|
21
|
+
export const ROSTER_AT = 4;
|
|
22
|
+
/**
|
|
23
|
+
* The reconnect advisory's window (Flow L17). It prints only when a connection
|
|
24
|
+
* attaches within 120s of a disconnect WITH THE SAME UA CLASS. On a first
|
|
25
|
+
* connection, or at a demo where four fresh phones arrive, the creator never
|
|
26
|
+
* sees it.
|
|
27
|
+
*/
|
|
28
|
+
export const RECONNECT_WINDOW_MS = 120_000;
|
|
29
|
+
export class ViewerRoster {
|
|
30
|
+
live = new Map();
|
|
31
|
+
/** Connections seen, ever. Printed once at the end; never a count of people. */
|
|
32
|
+
total = 0;
|
|
33
|
+
rosterPrinted = false;
|
|
34
|
+
/** The most recent disconnect, for the L17 adjacency check. */
|
|
35
|
+
lastDeparture = null;
|
|
36
|
+
/** Testers ever seen live, so a refresh is recognised as a return. */
|
|
37
|
+
testersSeen = new Set();
|
|
38
|
+
/**
|
|
39
|
+
* A held advisory per connection, keyed by label. The connection arrives before
|
|
40
|
+
* the harness mounts, so at connect time we do not yet know whether this is a
|
|
41
|
+
* known tester — and a hedge printed beside a definite `tester-1 refreshed`
|
|
42
|
+
* teaches the creator to distrust the definite line. So the advisory is
|
|
43
|
+
* DEFERRED here and dropped if attribution lands. A retracted line in a piped
|
|
44
|
+
* transcript is worse than a late one.
|
|
45
|
+
*/
|
|
46
|
+
heldAdvisory = new Map();
|
|
47
|
+
/** Live CONNECTIONS. The console counts these; the CLI does not display them. */
|
|
48
|
+
get size() {
|
|
49
|
+
return this.live.size;
|
|
50
|
+
}
|
|
51
|
+
get totalConnections() {
|
|
52
|
+
return this.total;
|
|
53
|
+
}
|
|
54
|
+
/** Live TESTERS — distinct, attributed, currently connected. The display count. */
|
|
55
|
+
get testerCount() {
|
|
56
|
+
return this.liveTesters().length;
|
|
57
|
+
}
|
|
58
|
+
/** Live connections with no tester yet: the pre-mount window, or a crashed app. */
|
|
59
|
+
get unattributedCount() {
|
|
60
|
+
let n = 0;
|
|
61
|
+
for (const v of this.live.values())
|
|
62
|
+
if (v.tester === undefined)
|
|
63
|
+
n++;
|
|
64
|
+
return n;
|
|
65
|
+
}
|
|
66
|
+
labels() {
|
|
67
|
+
return [...this.live.keys()];
|
|
68
|
+
}
|
|
69
|
+
has(label) {
|
|
70
|
+
return this.live.has(label);
|
|
71
|
+
}
|
|
72
|
+
get(label) {
|
|
73
|
+
return this.live.get(label);
|
|
74
|
+
}
|
|
75
|
+
/** Distinct live testers, in arrival order. */
|
|
76
|
+
liveTesters() {
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const v of this.live.values()) {
|
|
79
|
+
if (v.tester !== undefined && !out.includes(v.tester))
|
|
80
|
+
out.push(v.tester);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* connect records a CONNECTION. It prints nothing about a tester yet, because
|
|
86
|
+
* the tester is not known until the app mounts — see `attribute`, which is
|
|
87
|
+
* where the line a creator reads comes from.
|
|
88
|
+
*
|
|
89
|
+
* Idempotent per label: the tunnel client fires onViewer on EVERY OPEN frame,
|
|
90
|
+
* not once per connection, so a viewer that requests forty assets would
|
|
91
|
+
* otherwise announce itself forty times.
|
|
92
|
+
*/
|
|
93
|
+
connect(label, uaClass, now) {
|
|
94
|
+
if (this.live.has(label))
|
|
95
|
+
return {};
|
|
96
|
+
this.live.set(label, { label, uaClass, connectedAt: now });
|
|
97
|
+
this.total++;
|
|
98
|
+
const advisory = this.reconnectAdvisory(label, uaClass, now);
|
|
99
|
+
if (advisory)
|
|
100
|
+
this.heldAdvisory.set(label, advisory);
|
|
101
|
+
// NOTHING PRINTS HERE. One tab opens two connections under HTTP/1.1, so a
|
|
102
|
+
// line per connection is what made one QR scan read as two records. The
|
|
103
|
+
// creator learns about this connection when it resolves to a tester, or —
|
|
104
|
+
// if it never does — through the un-attributed line below.
|
|
105
|
+
return {};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The harness introduced itself: this connection belongs to `tester`. This is
|
|
109
|
+
* where the creator's line comes from.
|
|
110
|
+
*
|
|
111
|
+
* Returns the connect line on a tester's FIRST connection, `refreshed` when a
|
|
112
|
+
* tester already seen comes back, and nothing for a second concurrent
|
|
113
|
+
* connection belonging to a tester already live — which is the ordinary case
|
|
114
|
+
* for one tab, and the reason one scan now reads as one thing.
|
|
115
|
+
*/
|
|
116
|
+
attribute(label, tester) {
|
|
117
|
+
const v = this.live.get(label);
|
|
118
|
+
if (v === undefined || v.tester !== undefined)
|
|
119
|
+
return {};
|
|
120
|
+
// A tester already live on ANOTHER connection: this is the tab's second
|
|
121
|
+
// socket, not an arrival.
|
|
122
|
+
const alreadyLive = this.liveTesters().includes(tester);
|
|
123
|
+
this.live.set(label, { ...v, tester });
|
|
124
|
+
// The advisory is dropped: we have a definite answer now.
|
|
125
|
+
const held = this.heldAdvisory.get(label);
|
|
126
|
+
this.heldAdvisory.delete(label);
|
|
127
|
+
if (alreadyLive)
|
|
128
|
+
return {};
|
|
129
|
+
const returning = this.testersSeen.has(tester);
|
|
130
|
+
this.testersSeen.add(tester);
|
|
131
|
+
if (returning) {
|
|
132
|
+
// A refresh, or a socket that dropped and came back. Same tester, same
|
|
133
|
+
// line, whichever it was — the creator's question is "is this the same
|
|
134
|
+
// person", and the answer is yes in both cases.
|
|
135
|
+
return { connect: `${tester} refreshed` };
|
|
136
|
+
}
|
|
137
|
+
const descriptor = uaLabel(v.uaClass);
|
|
138
|
+
const out = {
|
|
139
|
+
connect: descriptor ? `${tester} connected · ${descriptor}` : `${tester} connected`,
|
|
140
|
+
};
|
|
141
|
+
// Only an un-attributed neighbour can still justify the hedge; a known
|
|
142
|
+
// tester's arrival is certain, so the held advisory dies with it.
|
|
143
|
+
if (held && this.unattributedCount > 0)
|
|
144
|
+
out.advisory = held;
|
|
145
|
+
if (!this.rosterPrinted && this.testerCount >= ROSTER_AT) {
|
|
146
|
+
this.rosterPrinted = true;
|
|
147
|
+
out.roster = this.rosterBlock();
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The advisory leads with the RULE and follows with the instance. Leading with
|
|
153
|
+
* a guess about this specific connection would teach the creator a hedge rather
|
|
154
|
+
* than a behavior, and one uncertain sentence on a screen where everything
|
|
155
|
+
* else is fact teaches them to distrust the labels generally — which is the
|
|
156
|
+
* opposite of what the labels are for.
|
|
157
|
+
*
|
|
158
|
+
* It names CONNECTIONS, not devices, and closes on the fact that testers
|
|
159
|
+
* survive a refresh — so the remedy for the uncertainty is on the same line as
|
|
160
|
+
* the uncertainty.
|
|
161
|
+
*/
|
|
162
|
+
reconnectAdvisory(label, uaClass, now) {
|
|
163
|
+
const gone = this.lastDeparture;
|
|
164
|
+
if (!gone)
|
|
165
|
+
return undefined;
|
|
166
|
+
if (gone.label === label)
|
|
167
|
+
return undefined;
|
|
168
|
+
if (gone.uaClass !== uaClass)
|
|
169
|
+
return undefined;
|
|
170
|
+
if (now - gone.at > RECONNECT_WINDOW_MS)
|
|
171
|
+
return undefined;
|
|
172
|
+
return [
|
|
173
|
+
`! A connection that reconnects gets a new label. If ${gone.label} came back,`,
|
|
174
|
+
` it is ${label} now — labels last one connection. Testers survive a refresh.`,
|
|
175
|
+
];
|
|
176
|
+
}
|
|
177
|
+
disconnect(label, now) {
|
|
178
|
+
const v = this.live.get(label);
|
|
179
|
+
if (!v)
|
|
180
|
+
return {};
|
|
181
|
+
this.live.delete(label);
|
|
182
|
+
this.heldAdvisory.delete(label);
|
|
183
|
+
this.lastDeparture = { uaClass: v.uaClass, label, at: now };
|
|
184
|
+
// A tester leaves when its LAST connection does. A tab's two sockets close
|
|
185
|
+
// moments apart, and announcing a departure on the first would print
|
|
186
|
+
// `disconnected` for someone still looking at the app.
|
|
187
|
+
if (v.tester === undefined)
|
|
188
|
+
return {};
|
|
189
|
+
if (this.liveTesters().includes(v.tester))
|
|
190
|
+
return {};
|
|
191
|
+
return { connect: `${v.tester} disconnected` };
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* The roster is a ONE-TIME SIGNPOST, not a live-updating footer. A redrawn
|
|
195
|
+
* status bar fights Ctrl-C, corrupts scrollback, breaks when the output is
|
|
196
|
+
* piped to a file — which is exactly what an agent does with it — and turns a
|
|
197
|
+
* plain stream into a TUI, which is a different product.
|
|
198
|
+
*
|
|
199
|
+
* It LEADS WITH A COUNT, and the count is exact: distinct attributed testers
|
|
200
|
+
* currently live. Never the number ever assigned, never a connection count.
|
|
201
|
+
* The old disclaimer ("this is not a device count") is gone — it existed only
|
|
202
|
+
* because the number above it was wrong.
|
|
203
|
+
*/
|
|
204
|
+
rosterBlock() {
|
|
205
|
+
const rule = "─".repeat(65);
|
|
206
|
+
const byTester = new Map();
|
|
207
|
+
for (const v of this.live.values()) {
|
|
208
|
+
if (v.tester !== undefined && !byTester.has(v.tester)) {
|
|
209
|
+
byTester.set(v.tester, uaShort(v.uaClass));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const parts = [...byTester].map(([t, ua]) => `${t} ${ua}`.trim());
|
|
213
|
+
const n = byTester.size;
|
|
214
|
+
const lines = [rule, `${n} ${n === 1 ? "tester" : "testers"} · ${parts.join(" · ")}`];
|
|
215
|
+
// Without this, `4 testers` printed while five connections are live is the
|
|
216
|
+
// original defect in a new place — and the pre-mount gap makes that state
|
|
217
|
+
// routine, not rare.
|
|
218
|
+
const pending = this.unattributedCount;
|
|
219
|
+
if (pending > 0) {
|
|
220
|
+
lines.push(`+ ${pending} ${pending === 1 ? "connection" : "connections"} not yet attributed`);
|
|
221
|
+
}
|
|
222
|
+
lines.push(rule);
|
|
223
|
+
return lines;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The `--viewer` miss (Flow L16). An unknown label is a FACT, not an error,
|
|
227
|
+
* because labels churn — and the remedy is on the same line.
|
|
228
|
+
*/
|
|
229
|
+
notFollowing(label) {
|
|
230
|
+
const connected = this.labels();
|
|
231
|
+
return connected.length === 0
|
|
232
|
+
? `! No ${label} on this preview right now. Nothing is connected.`
|
|
233
|
+
: `! No ${label} on this preview right now. Connected: ${connected.join(", ")}.`;
|
|
234
|
+
}
|
|
235
|
+
/** The `--tester` miss. Same shape, different coordinate. */
|
|
236
|
+
notFollowingTester(tester) {
|
|
237
|
+
const live = this.liveTesters();
|
|
238
|
+
return live.length === 0
|
|
239
|
+
? `! No ${tester} on this preview right now. No testers are connected.`
|
|
240
|
+
: `! No ${tester} on this preview right now. Connected: ${live.join(", ")}.`;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "../shared/config.js";
|
|
2
|
+
import type { ManifestEntry } from "./manifest.js";
|
|
3
|
+
export interface PublishProject {
|
|
4
|
+
id: string;
|
|
5
|
+
slug: string;
|
|
6
|
+
name: string;
|
|
7
|
+
liveUrl: string;
|
|
8
|
+
publishState: "never" | "live" | "unpublished" | "not_serving";
|
|
9
|
+
firstPublishedAt: string | null;
|
|
10
|
+
liveVersion: number | null;
|
|
11
|
+
highestVersion: number | null;
|
|
12
|
+
/** The LIVE version's publishedAt. Ledger-derived; it is why the checklist needs one call. */
|
|
13
|
+
livePublishedAt: string | null;
|
|
14
|
+
rolledBackFrom: number | null;
|
|
15
|
+
}
|
|
16
|
+
export interface UploadTarget {
|
|
17
|
+
path: string;
|
|
18
|
+
/** The presigned S3 PUT. THE CLI NEVER PARSES IT. */
|
|
19
|
+
url: string;
|
|
20
|
+
/**
|
|
21
|
+
* The map the CLI MUST send verbatim. Content-Type and x-amz-checksum-sha256
|
|
22
|
+
* are both covered by the signature: dropping either yields
|
|
23
|
+
* SignatureDoesNotMatch, which reads as an auth bug and gets "fixed" by
|
|
24
|
+
* deleting the header — §2.7's contract traps 3 and 4.
|
|
25
|
+
*/
|
|
26
|
+
headers: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
export interface OpenedUpload {
|
|
29
|
+
uploadId: string;
|
|
30
|
+
uploads: UploadTarget[];
|
|
31
|
+
expiresAt: string;
|
|
32
|
+
}
|
|
33
|
+
export interface CommitResult {
|
|
34
|
+
version: number;
|
|
35
|
+
liveVersion: number;
|
|
36
|
+
highestVersion: number;
|
|
37
|
+
liveUrl: string;
|
|
38
|
+
publishedAt: string;
|
|
39
|
+
/** Response-side name of the request's `note`. The asymmetry is deliberate. */
|
|
40
|
+
releaseNote?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface ApiErrorDetail {
|
|
43
|
+
limit?: number;
|
|
44
|
+
observed?: number;
|
|
45
|
+
missing?: number;
|
|
46
|
+
mismatched?: number;
|
|
47
|
+
reason?: string;
|
|
48
|
+
path?: string;
|
|
49
|
+
}
|
|
50
|
+
export declare class PublishApiError extends Error {
|
|
51
|
+
readonly status: number;
|
|
52
|
+
readonly code: string;
|
|
53
|
+
readonly detail: ApiErrorDetail;
|
|
54
|
+
readonly retryAfterSec?: number | undefined;
|
|
55
|
+
constructor(status: number, code: string, message: string, detail?: ApiErrorDetail, retryAfterSec?: number | undefined);
|
|
56
|
+
}
|
|
57
|
+
/** The ONE call before the checklist. liveVersion + livePublishedAt ride it, by design. */
|
|
58
|
+
export declare function readProject(cfg: ResolvedConfig, projectId: string, fetchImpl?: typeof fetch): Promise<PublishProject>;
|
|
59
|
+
/**
|
|
60
|
+
* Open an upload. The request body carries `files` (path/size/sha256) and an
|
|
61
|
+
* optional `note`, AND NOTHING ELSE — there is no field for a MIME type and no
|
|
62
|
+
* field a parsed title, description or image URL could land in.
|
|
63
|
+
*/
|
|
64
|
+
export declare function openUpload(cfg: ResolvedConfig, projectId: string, files: ManifestEntry[], note: string | undefined, fetchImpl?: typeof fetch): Promise<OpenedUpload>;
|
|
65
|
+
export declare function commitUpload(cfg: ResolvedConfig, projectId: string, uploadId: string, fetchImpl?: typeof fetch): Promise<CommitResult>;
|
|
66
|
+
/** Abandon an open upload. Idempotent server-side; a failure here must never mask the real error. */
|
|
67
|
+
export declare function abandonUpload(cfg: ResolvedConfig, projectId: string, uploadId: string, fetchImpl?: typeof fetch): Promise<void>;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// The three-call publish transaction against the Go API, built against
|
|
2
|
+
// implementation.md §2.7's contract table — every wire field named canonically
|
|
3
|
+
// once, and the deliberate asymmetry (request `note`, response `releaseNote`)
|
|
4
|
+
// carried as written.
|
|
5
|
+
//
|
|
6
|
+
// The bytes do NOT transit the API. Open returns one presigned PUT per file,
|
|
7
|
+
// each signed for one exact key; the CLI PUTs direct to S3 and then commits.
|
|
8
|
+
export class PublishApiError extends Error {
|
|
9
|
+
status;
|
|
10
|
+
code;
|
|
11
|
+
detail;
|
|
12
|
+
retryAfterSec;
|
|
13
|
+
constructor(status, code, message, detail = {}, retryAfterSec) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.status = status;
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.detail = detail;
|
|
18
|
+
this.retryAfterSec = retryAfterSec;
|
|
19
|
+
this.name = "PublishApiError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async function readError(res) {
|
|
23
|
+
let body = {};
|
|
24
|
+
try {
|
|
25
|
+
body = (await res.json());
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// A non-JSON error body is still an error; the status carries the meaning.
|
|
29
|
+
}
|
|
30
|
+
const retryAfter = res.headers.get("retry-after");
|
|
31
|
+
return new PublishApiError(res.status, body.code ?? `http_${res.status}`, body.message ?? `request failed (${res.status})`, body.detail ?? {}, retryAfter ? Number(retryAfter) : undefined);
|
|
32
|
+
}
|
|
33
|
+
function authHeaders(cfg) {
|
|
34
|
+
return { authorization: `Bearer ${cfg.pat}` };
|
|
35
|
+
}
|
|
36
|
+
/** The ONE call before the checklist. liveVersion + livePublishedAt ride it, by design. */
|
|
37
|
+
export async function readProject(cfg, projectId, fetchImpl = fetch) {
|
|
38
|
+
const res = await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}`, {
|
|
39
|
+
headers: authHeaders(cfg),
|
|
40
|
+
});
|
|
41
|
+
if (!res.ok)
|
|
42
|
+
throw await readError(res);
|
|
43
|
+
const raw = (await res.json());
|
|
44
|
+
const num = (k) => typeof raw[k] === "number" ? raw[k] : null;
|
|
45
|
+
const str = (k) => typeof raw[k] === "string" ? raw[k] : null;
|
|
46
|
+
return {
|
|
47
|
+
id: String(raw.id ?? projectId),
|
|
48
|
+
slug: String(raw.slug ?? ""),
|
|
49
|
+
name: typeof raw.name === "string" ? raw.name : String(raw.slug ?? ""),
|
|
50
|
+
liveUrl: str("liveUrl") ?? "",
|
|
51
|
+
publishState: (str("publishState") ?? "never"),
|
|
52
|
+
firstPublishedAt: str("firstPublishedAt"),
|
|
53
|
+
liveVersion: num("liveVersion"),
|
|
54
|
+
highestVersion: num("highestVersion"),
|
|
55
|
+
livePublishedAt: str("livePublishedAt"),
|
|
56
|
+
rolledBackFrom: num("rolledBackFrom"),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Open an upload. The request body carries `files` (path/size/sha256) and an
|
|
61
|
+
* optional `note`, AND NOTHING ELSE — there is no field for a MIME type and no
|
|
62
|
+
* field a parsed title, description or image URL could land in.
|
|
63
|
+
*/
|
|
64
|
+
export async function openUpload(cfg, projectId, files, note, fetchImpl = fetch) {
|
|
65
|
+
const res = await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}/publishes`, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: { ...authHeaders(cfg), "content-type": "application/json" },
|
|
68
|
+
body: JSON.stringify({ files, ...(note ? { note } : {}) }),
|
|
69
|
+
});
|
|
70
|
+
if (!res.ok)
|
|
71
|
+
throw await readError(res);
|
|
72
|
+
const body = (await res.json());
|
|
73
|
+
if (!body.uploadId || !Array.isArray(body.uploads)) {
|
|
74
|
+
throw new PublishApiError(res.status, "malformed_response", "open upload: response missing uploadId/uploads");
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
uploadId: body.uploadId,
|
|
78
|
+
uploads: body.uploads,
|
|
79
|
+
expiresAt: body.expiresAt ?? "",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export async function commitUpload(cfg, projectId, uploadId, fetchImpl = fetch) {
|
|
83
|
+
const res = await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}/publishes/${encodeURIComponent(uploadId)}/commit`, { method: "POST", headers: authHeaders(cfg) });
|
|
84
|
+
if (!res.ok)
|
|
85
|
+
throw await readError(res);
|
|
86
|
+
return (await res.json());
|
|
87
|
+
}
|
|
88
|
+
/** Abandon an open upload. Idempotent server-side; a failure here must never mask the real error. */
|
|
89
|
+
export async function abandonUpload(cfg, projectId, uploadId, fetchImpl = fetch) {
|
|
90
|
+
try {
|
|
91
|
+
await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}/publishes/${encodeURIComponent(uploadId)}`, { method: "DELETE", headers: authHeaders(cfg) });
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Best-effort cleanup: the TTL reaps the row either way.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Manifest, ManifestEntry } from "./manifest.js";
|
|
2
|
+
export declare const MAX_TOTAL_BYTES = 524288000;
|
|
3
|
+
export declare const MAX_FILES = 5000;
|
|
4
|
+
export declare const MAX_FILE_BYTES = 104857600;
|
|
5
|
+
export type BoundKind = "total" | "count" | "file";
|
|
6
|
+
export interface BoundViolation {
|
|
7
|
+
kind: BoundKind;
|
|
8
|
+
observed: number;
|
|
9
|
+
limit: number;
|
|
10
|
+
/** The one offending path, for the per-file case. */
|
|
11
|
+
path?: string;
|
|
12
|
+
/** The three largest files by size — the actual diagnosis for the total case. */
|
|
13
|
+
largest: ManifestEntry[];
|
|
14
|
+
}
|
|
15
|
+
/** The three largest files, descending. Local information about the creator's own disk. */
|
|
16
|
+
export declare function largestFiles(files: ManifestEntry[], n?: number): ManifestEntry[];
|
|
17
|
+
/**
|
|
18
|
+
* The bounds are INCLUSIVE at the stated number: exactly 500 MiB, exactly 5000
|
|
19
|
+
* files and exactly 100 MiB all pass.
|
|
20
|
+
*/
|
|
21
|
+
export declare function checkBounds(manifest: Manifest, limits?: {
|
|
22
|
+
total?: number;
|
|
23
|
+
count?: number;
|
|
24
|
+
file?: number;
|
|
25
|
+
}): BoundViolation | undefined;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The three bounds, checked LOCALLY before any request (implementation.md §2.5,
|
|
2
|
+
// Flow L28). The manifest is built locally and the limits are known locally, so
|
|
3
|
+
// the creator learns in the same second they would otherwise have started a
|
|
4
|
+
// ten-minute upload.
|
|
5
|
+
//
|
|
6
|
+
// These are the compiled-in defaults the API also carries. A divergence would
|
|
7
|
+
// mean the CLI's printed limit is off by one from the API's, which is why QA
|
|
8
|
+
// drives a manifest exactly AT each bound.
|
|
9
|
+
export const MAX_TOTAL_BYTES = 524_288_000; // 500 MiB
|
|
10
|
+
export const MAX_FILES = 5_000;
|
|
11
|
+
export const MAX_FILE_BYTES = 104_857_600; // 100 MiB
|
|
12
|
+
/** The three largest files, descending. Local information about the creator's own disk. */
|
|
13
|
+
export function largestFiles(files, n = 3) {
|
|
14
|
+
return [...files].sort((a, b) => b.size - a.size).slice(0, n);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The bounds are INCLUSIVE at the stated number: exactly 500 MiB, exactly 5000
|
|
18
|
+
* files and exactly 100 MiB all pass.
|
|
19
|
+
*/
|
|
20
|
+
export function checkBounds(manifest, limits = {}) {
|
|
21
|
+
const maxTotal = limits.total ?? MAX_TOTAL_BYTES;
|
|
22
|
+
const maxCount = limits.count ?? MAX_FILES;
|
|
23
|
+
const maxFile = limits.file ?? MAX_FILE_BYTES;
|
|
24
|
+
// Per-file first: one pathological object is a more specific diagnosis than
|
|
25
|
+
// the total it also blows, and naming the file is the whole remedy.
|
|
26
|
+
const oversize = manifest.files.find((f) => f.size > maxFile);
|
|
27
|
+
if (oversize) {
|
|
28
|
+
return {
|
|
29
|
+
kind: "file",
|
|
30
|
+
observed: oversize.size,
|
|
31
|
+
limit: maxFile,
|
|
32
|
+
path: oversize.path,
|
|
33
|
+
largest: largestFiles(manifest.files),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (manifest.files.length > maxCount) {
|
|
37
|
+
return {
|
|
38
|
+
kind: "count",
|
|
39
|
+
observed: manifest.files.length,
|
|
40
|
+
limit: maxCount,
|
|
41
|
+
largest: largestFiles(manifest.files),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (manifest.totalBytes > maxTotal) {
|
|
45
|
+
return {
|
|
46
|
+
kind: "total",
|
|
47
|
+
observed: manifest.totalBytes,
|
|
48
|
+
limit: maxTotal,
|
|
49
|
+
largest: largestFiles(manifest.files),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export type Tier = "present" | "missing" | "template-default" | "likely-default" | "dead-image";
|
|
2
|
+
/** The five tags the checklist reads. `twitter:card` is read but reported only through `social`. */
|
|
3
|
+
export type FieldKey = "title" | "description" | "image";
|
|
4
|
+
export interface Finding {
|
|
5
|
+
field: FieldKey;
|
|
6
|
+
tier: Tier;
|
|
7
|
+
/** The value found, if any. Both default tiers PRINT it — evidence is what makes them safe. */
|
|
8
|
+
value?: string;
|
|
9
|
+
/** The tag as it appears in the source, e.g. `<meta property="og:image">`. */
|
|
10
|
+
tag: string;
|
|
11
|
+
}
|
|
12
|
+
export interface FileFindings {
|
|
13
|
+
/** The dist-relative html path, e.g. `index.html`. */
|
|
14
|
+
file: string;
|
|
15
|
+
findings: Finding[];
|
|
16
|
+
}
|
|
17
|
+
export interface ParsedMeta {
|
|
18
|
+
title?: string;
|
|
19
|
+
ogTitle?: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
ogDescription?: string;
|
|
22
|
+
ogImage?: string;
|
|
23
|
+
ogUrl?: string;
|
|
24
|
+
twitterCard?: string;
|
|
25
|
+
}
|
|
26
|
+
export declare function parseMeta(html: string): ParsedMeta;
|
|
27
|
+
export interface ClassifyContext {
|
|
28
|
+
/** Every normalized path in the manifest — what makes the dead-og:image check structural. */
|
|
29
|
+
manifestPaths: ReadonlySet<string>;
|
|
30
|
+
/** `<slug>.<apex>`, for the og:url host heuristic. Absent skips that check. */
|
|
31
|
+
expectedHost?: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* CLASSIFY, per 2A:
|
|
35
|
+
* absent -> MISSING
|
|
36
|
+
* value in the bundled corpus -> TEMPLATE-DEFAULT (high confidence)
|
|
37
|
+
* value matches a generic shape -> LIKELY-DEFAULT (low confidence)
|
|
38
|
+
* else -> PRESENT
|
|
39
|
+
*
|
|
40
|
+
* Plus the one structural certainty: an og:image naming a path that is not in the
|
|
41
|
+
* manifest is a dead social image, and the manifest either contains it or it does
|
|
42
|
+
* not.
|
|
43
|
+
*/
|
|
44
|
+
export declare function classify(meta: ParsedMeta, ctx: ClassifyContext): Finding[];
|
|
45
|
+
/**
|
|
46
|
+
* The manifest path an og:image refers to, or undefined when it is an absolute
|
|
47
|
+
* URL on some other host — which the CLI cannot check and must not guess about.
|
|
48
|
+
*/
|
|
49
|
+
export declare function localManifestPath(value: string): string | undefined;
|
|
50
|
+
/** Every `.html` in the manifest, classified. Multiple files group under their filename. */
|
|
51
|
+
export declare function classifyDist(distDir: string, manifestPaths: ReadonlySet<string>, readFile: (abs: string) => Promise<string>, join: (dir: string, rel: string) => string, ctx?: Omit<ClassifyContext, "manifestPaths">): Promise<FileFindings[]>;
|