@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.
Files changed (92) hide show
  1. package/README.md +94 -0
  2. package/dist/cli/args.d.ts +14 -0
  3. package/dist/cli/args.js +51 -0
  4. package/dist/cli/index.js +416 -100
  5. package/dist/lib/backend.d.ts +44 -20
  6. package/dist/lib/backend.js +98 -51
  7. package/dist/lib/diagnostics.d.ts +27 -2
  8. package/dist/lib/diagnostics.js +73 -8
  9. package/dist/lib/initCommand.d.ts +14 -0
  10. package/dist/lib/initCommand.js +75 -0
  11. package/dist/lib/lifecycle.d.ts +85 -19
  12. package/dist/lib/lifecycle.js +133 -130
  13. package/dist/lib/org.d.ts +29 -0
  14. package/dist/lib/org.js +45 -0
  15. package/dist/lib/projects.d.ts +82 -0
  16. package/dist/lib/projects.js +217 -0
  17. package/dist/lib/relayInfo.d.ts +41 -3
  18. package/dist/lib/relayInfo.js +94 -0
  19. package/dist/lib/whoami.d.ts +11 -0
  20. package/dist/lib/whoami.js +38 -0
  21. package/dist/login/authBackend.d.ts +30 -0
  22. package/dist/login/authBackend.js +80 -0
  23. package/dist/login/guard.d.ts +13 -0
  24. package/dist/login/guard.js +17 -0
  25. package/dist/login/loginCommand.d.ts +39 -0
  26. package/dist/login/loginCommand.js +72 -0
  27. package/dist/login/pkce.d.ts +47 -0
  28. package/dist/login/pkce.js +106 -0
  29. package/dist/login/pkceLogin.d.ts +53 -0
  30. package/dist/login/pkceLogin.js +152 -0
  31. package/dist/mcp/server.js +52 -25
  32. package/dist/preview/api.d.ts +84 -0
  33. package/dist/preview/api.js +174 -0
  34. package/dist/preview/command.d.ts +96 -0
  35. package/dist/preview/command.js +276 -0
  36. package/dist/preview/copy.d.ts +209 -0
  37. package/dist/preview/copy.js +444 -0
  38. package/dist/preview/index.d.ts +2 -3
  39. package/dist/preview/index.js +5 -6
  40. package/dist/preview/proxy.d.ts +12 -1
  41. package/dist/preview/proxy.js +17 -7
  42. package/dist/preview/qr.d.ts +30 -0
  43. package/dist/preview/qr.js +89 -0
  44. package/dist/preview/runner.d.ts +155 -30
  45. package/dist/preview/runner.js +219 -68
  46. package/dist/preview/stream.d.ts +52 -0
  47. package/dist/preview/stream.js +171 -0
  48. package/dist/preview/viewers.d.ts +113 -0
  49. package/dist/preview/viewers.js +242 -0
  50. package/dist/publish/api.d.ts +67 -0
  51. package/dist/publish/api.js +96 -0
  52. package/dist/publish/bounds.d.ts +25 -0
  53. package/dist/publish/bounds.js +53 -0
  54. package/dist/publish/classify.d.ts +51 -0
  55. package/dist/publish/classify.js +138 -0
  56. package/dist/publish/command.d.ts +27 -0
  57. package/dist/publish/command.js +328 -0
  58. package/dist/publish/consoleVerbs.d.ts +17 -0
  59. package/dist/publish/consoleVerbs.js +26 -0
  60. package/dist/publish/copy.d.ts +180 -0
  61. package/dist/publish/copy.js +606 -0
  62. package/dist/publish/defaults.d.ts +16 -0
  63. package/dist/publish/defaults.js +52 -0
  64. package/dist/publish/manifest.d.ts +27 -0
  65. package/dist/publish/manifest.js +81 -0
  66. package/dist/publish/paths.d.ts +25 -0
  67. package/dist/publish/paths.js +78 -0
  68. package/dist/publish/progress.d.ts +17 -0
  69. package/dist/publish/progress.js +45 -0
  70. package/dist/publish/prompt.d.ts +4 -0
  71. package/dist/publish/prompt.js +31 -0
  72. package/dist/publish/qa.f4.fixtures.d.ts +25 -0
  73. package/dist/publish/qa.f4.fixtures.js +140 -0
  74. package/dist/publish/upload.d.ts +30 -0
  75. package/dist/publish/upload.js +93 -0
  76. package/dist/relay/server.d.ts +32 -0
  77. package/dist/relay/server.js +80 -12
  78. package/dist/relay/store.d.ts +32 -3
  79. package/dist/relay/store.js +76 -22
  80. package/dist/relay/testers.d.ts +90 -0
  81. package/dist/relay/testers.js +144 -0
  82. package/dist/shared/authError.d.ts +1 -0
  83. package/dist/shared/authError.js +11 -0
  84. package/dist/shared/config.d.ts +73 -5
  85. package/dist/shared/config.js +133 -20
  86. package/package.json +2 -3
  87. package/dist/login/login.d.ts +0 -34
  88. package/dist/login/login.js +0 -148
  89. package/dist/preview/cloudflared.d.ts +0 -13
  90. package/dist/preview/cloudflared.js +0 -46
  91. package/dist/preview/tunnel.d.ts +0 -14
  92. 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[]>;