@brass-build/cli 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +177 -11
- package/CHANGELOG.md +29 -0
- package/README.md +76 -14
- package/dist/api.d.ts +45 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +21 -2
- package/dist/api.js.map +1 -1
- package/dist/approval-prompt.d.ts +25 -0
- package/dist/approval-prompt.d.ts.map +1 -0
- package/dist/approval-prompt.js +44 -0
- package/dist/approval-prompt.js.map +1 -0
- package/dist/args.d.ts.map +1 -1
- package/dist/args.js +10 -1
- package/dist/args.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +197 -19
- package/dist/cli.js.map +1 -1
- package/dist/commands.d.ts +28 -1
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +369 -29
- package/dist/commands.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +8 -1
- package/dist/config.js.map +1 -1
- package/dist/login.d.ts +1 -0
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +24 -12
- package/dist/login.js.map +1 -1
- package/dist/project.d.ts +8 -0
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +75 -7
- package/dist/project.js.map +1 -1
- package/dist/sdk-pairing.d.ts +9 -0
- package/dist/sdk-pairing.d.ts.map +1 -0
- package/dist/sdk-pairing.js +42 -0
- package/dist/sdk-pairing.js.map +1 -0
- package/dist/session.d.ts +18 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +95 -12
- package/dist/session.js.map +1 -1
- package/dist/store.d.ts +10 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +41 -0
- package/dist/store.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +2 -1
- package/src/api.ts +80 -2
- package/src/approval-prompt.ts +75 -0
- package/src/args.ts +10 -1
- package/src/cli.ts +240 -18
- package/src/commands.ts +479 -36
- package/src/config.ts +15 -2
- package/src/login.ts +34 -11
- package/src/project.ts +82 -7
- package/src/sdk-pairing.ts +53 -0
- package/src/session.ts +135 -11
- package/src/store.ts +77 -0
- package/src/version.ts +1 -1
package/src/commands.ts
CHANGED
|
@@ -13,8 +13,14 @@ import {
|
|
|
13
13
|
type HostingVersion,
|
|
14
14
|
type HostingVersionStatus,
|
|
15
15
|
type RefreshCapabilitiesResponse,
|
|
16
|
+
type DocumentDetail,
|
|
17
|
+
type DocumentJoin,
|
|
18
|
+
type DocumentJoinSnapshot,
|
|
19
|
+
type DocumentJoinTailOp,
|
|
20
|
+
type DocumentWorkStatus,
|
|
16
21
|
type DocumentStreams,
|
|
17
22
|
type DocumentTypeSummary,
|
|
23
|
+
getPresigned,
|
|
18
24
|
type AgentInstructionsResponse,
|
|
19
25
|
type OrganizationSummary,
|
|
20
26
|
putPresigned,
|
|
@@ -22,7 +28,9 @@ import {
|
|
|
22
28
|
import type { Logger } from './log.js';
|
|
23
29
|
import type { Profile, ResolvedCredential } from './config.js';
|
|
24
30
|
import {
|
|
31
|
+
loadFirstManifest,
|
|
25
32
|
mergeSchemaIntoManifest,
|
|
33
|
+
publishManifestCandidates,
|
|
26
34
|
readManifest,
|
|
27
35
|
writeManifest,
|
|
28
36
|
writeProjectAppId,
|
|
@@ -34,6 +42,9 @@ import {
|
|
|
34
42
|
type AppManifest,
|
|
35
43
|
} from './project.js';
|
|
36
44
|
import { basename } from 'node:path';
|
|
45
|
+
import { createRequire } from 'node:module';
|
|
46
|
+
import { readFileSync } from 'node:fs';
|
|
47
|
+
import { sdkMismatchMessage, type LoadedSdk } from './sdk-pairing.js';
|
|
37
48
|
|
|
38
49
|
export interface CommandContext {
|
|
39
50
|
api: BrassApi;
|
|
@@ -74,8 +85,11 @@ export interface PublishOptions {
|
|
|
74
85
|
requireAccess?: boolean;
|
|
75
86
|
// Preferred hosting subdomain on first enable.
|
|
76
87
|
slug?: string;
|
|
77
|
-
//
|
|
78
|
-
|
|
88
|
+
// An explicit `--manifest` path, read for a create `name` / `client_token`.
|
|
89
|
+
// Absent searches the bundle's `/.well-known/` copy and then the repo root,
|
|
90
|
+
// so an app that placed its manifest where the capability docs say is found
|
|
91
|
+
// without naming it.
|
|
92
|
+
manifestPath?: string;
|
|
79
93
|
// Polling knobs (defaulted); overridable so tests don't wait on wall clock.
|
|
80
94
|
pollAttempts?: number;
|
|
81
95
|
pollIntervalMs?: number;
|
|
@@ -100,9 +114,20 @@ const DEFAULT_POLL_INTERVAL_MS = 2000;
|
|
|
100
114
|
const realSleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
101
115
|
|
|
102
116
|
export async function publish(ctx: CommandContext, opts: PublishOptions): Promise<PublishResult> {
|
|
117
|
+
// The bundle is read and hashed BEFORE anything about the app is changed.
|
|
118
|
+
// Every step below writes: it creates the app on a first run, moves the
|
|
119
|
+
// visibility, claims the slug, and sets the load gate. A build that emitted
|
|
120
|
+
// nothing, or a directory that is not there, would otherwise leave the app
|
|
121
|
+
// public and ungated and then refuse the publish, so the flags took effect
|
|
122
|
+
// and the bundle they were for never shipped.
|
|
103
123
|
if (!(await isDirectory(opts.dir))) {
|
|
104
124
|
throw new Error(`Publish directory not found: ${opts.dir}`);
|
|
105
125
|
}
|
|
126
|
+
const entries = await collectZipEntries(opts.dir);
|
|
127
|
+
if (Object.keys(entries).length === 0) {
|
|
128
|
+
throw new Error(`No files found to publish under ${opts.dir}`);
|
|
129
|
+
}
|
|
130
|
+
const hash = contentHash(entries);
|
|
106
131
|
|
|
107
132
|
const resolved = await resolveOrCreateApp(ctx, opts);
|
|
108
133
|
const appId = resolved.appId;
|
|
@@ -114,19 +139,13 @@ export async function publish(ctx: CommandContext, opts: PublishOptions): Promis
|
|
|
114
139
|
await ensureGate(ctx, appId, opts.requireAccess, hosting);
|
|
115
140
|
}
|
|
116
141
|
|
|
117
|
-
const entries = await collectZipEntries(opts.dir);
|
|
118
|
-
if (Object.keys(entries).length === 0) {
|
|
119
|
-
throw new Error(`No files found to publish under ${opts.dir}`);
|
|
120
|
-
}
|
|
121
|
-
const hash = contentHash(entries);
|
|
122
|
-
|
|
123
142
|
// Skip the upload + unpack-wait when the app already serves this exact
|
|
124
143
|
// bundle. The posture reconcile above still ran, so an unchanged re-publish
|
|
125
144
|
// converges visibility / gate / slug and returns the live URL for the price
|
|
126
145
|
// of a couple of reads instead of an upload and a poll loop.
|
|
127
146
|
const unchanged = await activeVersionMatches(ctx, appId, hash);
|
|
128
147
|
if (unchanged !== null) {
|
|
129
|
-
const status = await
|
|
148
|
+
const status = await awaitSlotReady(
|
|
130
149
|
ctx,
|
|
131
150
|
appId,
|
|
132
151
|
await ctx.api.get<HostingStatus>(`/apps/${encodeURIComponent(appId)}/hosting`),
|
|
@@ -164,17 +183,24 @@ export async function publish(ctx: CommandContext, opts: PublishOptions): Promis
|
|
|
164
183
|
throw new Error(`Deploy failed: ${version.failure_reason ?? 'unknown reason'}`);
|
|
165
184
|
}
|
|
166
185
|
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const status = await
|
|
186
|
+
// The status read comes first because the refresh below reads the manifest
|
|
187
|
+
// the app SERVES, and the app names the version it serves a moment after the
|
|
188
|
+
// deploy reports ready. A refresh fired on the ready alone reads the previous
|
|
189
|
+
// version's manifest, or, on a first publish, none at all and a failed read
|
|
190
|
+
// over a deploy that worked.
|
|
191
|
+
const status = await awaitSlotReady(
|
|
173
192
|
ctx,
|
|
174
193
|
appId,
|
|
175
194
|
await ctx.api.get<HostingStatus>(`/apps/${encodeURIComponent(appId)}/hosting`),
|
|
176
195
|
opts.sleep ?? realSleep,
|
|
196
|
+
version.version_id,
|
|
177
197
|
);
|
|
198
|
+
|
|
199
|
+
// Best-effort: the platform crawls the served manifest after a deploy on
|
|
200
|
+
// its own, but an explicit refresh surfaces capability warnings (e.g. a
|
|
201
|
+
// schema missing `family`) right here instead of silently later.
|
|
202
|
+
const warnings = await refreshCapabilities(ctx, appId);
|
|
203
|
+
|
|
178
204
|
if (status.url) ctx.log.success(`Deployed: ${status.url}`);
|
|
179
205
|
return { app_id: appId, version_id: version.version_id, url: status.url, warnings };
|
|
180
206
|
}
|
|
@@ -184,17 +210,28 @@ async function resolveOrCreateApp(
|
|
|
184
210
|
opts: PublishOptions,
|
|
185
211
|
): Promise<{ appId: string; detail?: AppDetail }> {
|
|
186
212
|
if (opts.appId !== undefined) return { appId: opts.appId };
|
|
187
|
-
|
|
188
|
-
|
|
213
|
+
// An explicit `--manifest` names one file and nothing else, so a typo in it
|
|
214
|
+
// is reported rather than silently answered by a manifest somewhere else.
|
|
215
|
+
const searched =
|
|
216
|
+
opts.manifestPath !== undefined ? [opts.manifestPath] : publishManifestCandidates(opts.dir);
|
|
217
|
+
const found = await loadFirstManifest(ctx.cwd, searched);
|
|
218
|
+
const name = opts.name ?? found?.manifest.name;
|
|
189
219
|
if (name === undefined || name.trim() === '') {
|
|
220
|
+
// A manifest that is absent and one that sets no `name` need different
|
|
221
|
+
// fixes, so a manifest that was read is named rather than reported as one
|
|
222
|
+
// of the paths the search missed.
|
|
223
|
+
const fix =
|
|
224
|
+
'Pass --name to create one, or --app / BRASS_APP_ID / a .brass/project.json for an existing app.';
|
|
190
225
|
throw new Error(
|
|
191
|
-
|
|
226
|
+
found === null
|
|
227
|
+
? `No app to publish to, and no manifest to name one from (looked at ${searched.join(' and ')}). ${fix}`
|
|
228
|
+
: `No app to publish to, and ${found.path} sets no "name". ${fix}`,
|
|
192
229
|
);
|
|
193
230
|
}
|
|
194
231
|
// A `client_token` makes create idempotent, so a repeated create-from-
|
|
195
232
|
// scratch (ephemeral CI has no persisted project.json) resolves the same
|
|
196
233
|
// app rather than minting a duplicate.
|
|
197
|
-
const clientToken = opts.clientToken ?? manifest
|
|
234
|
+
const clientToken = opts.clientToken ?? found?.manifest.client_token;
|
|
198
235
|
const organizationId = await resolveCreateOrganizationId(ctx, opts.organizationId);
|
|
199
236
|
const body: { name: string; organization_id?: string; client_token?: string } = {
|
|
200
237
|
name: name.trim(),
|
|
@@ -203,6 +240,20 @@ async function resolveOrCreateApp(
|
|
|
203
240
|
if (typeof clientToken === 'string' && clientToken.trim() !== '') {
|
|
204
241
|
body.client_token = clientToken.trim();
|
|
205
242
|
}
|
|
243
|
+
// A create is the one call that can duplicate an app, and it duplicates
|
|
244
|
+
// silently: the run succeeds, and the second app only shows up later as a
|
|
245
|
+
// pile of them. Say so here, naming where the key belongs, because a caller
|
|
246
|
+
// who reached this line by passing --name never saw the manifest search.
|
|
247
|
+
if (body.client_token === undefined) {
|
|
248
|
+
ctx.log.warn(
|
|
249
|
+
(found === null
|
|
250
|
+
? `No manifest found at ${searched.join(' or ')}.`
|
|
251
|
+
: `${found.path} sets no "client_token".`) +
|
|
252
|
+
' Creating this app without one, so a run that starts with no' +
|
|
253
|
+
' .brass/project.json creates a second app instead of resolving this' +
|
|
254
|
+
' one. Set "client_token" in the manifest, or pass --client-token.',
|
|
255
|
+
);
|
|
256
|
+
}
|
|
206
257
|
const app = await ctx.api.post<AppDetail>('/apps', body);
|
|
207
258
|
await writeProjectAppId(ctx.cwd, ctx.profile, app.app_id);
|
|
208
259
|
// With a key the call resolves-or-creates, so avoid asserting it was new.
|
|
@@ -289,30 +340,42 @@ async function ensureHosting(
|
|
|
289
340
|
// ready yet is re-read a few times before publish gives up. Publishing
|
|
290
341
|
// reports success only once the slot will actually serve, so an unreachable
|
|
291
342
|
// one is a failed publish rather than a URL that answers 404.
|
|
292
|
-
const
|
|
293
|
-
const
|
|
343
|
+
const SLOT_READY_ATTEMPTS = 3;
|
|
344
|
+
const SLOT_READY_GAP_MS = 2000;
|
|
294
345
|
|
|
295
|
-
|
|
346
|
+
// Poll the hosting status until it reports what the rest of the publish reads:
|
|
347
|
+
// the slot registered, and, when the caller names a version, the app serving
|
|
348
|
+
// that one. Reading the manifest the app serves needs the second.
|
|
349
|
+
//
|
|
350
|
+
// A slot that never registers is fatal, since it will not serve. A version the
|
|
351
|
+
// status has not named yet is not: the app names it a moment later, so the
|
|
352
|
+
// publish reports what it did rather than failing over the lag.
|
|
353
|
+
async function awaitSlotReady(
|
|
296
354
|
ctx: CommandContext,
|
|
297
355
|
appId: string,
|
|
298
356
|
status: HostingStatus,
|
|
299
357
|
sleep: (ms: number) => Promise<void>,
|
|
358
|
+
versionId?: string,
|
|
300
359
|
): Promise<HostingStatus> {
|
|
301
|
-
// An api that
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
360
|
+
// An api that omits `gate_settled` tells us nothing to act on.
|
|
361
|
+
const ready = (s: HostingStatus): boolean =>
|
|
362
|
+
s.gate_settled !== false && (versionId === undefined || s.active_version === versionId);
|
|
363
|
+
let latest = status;
|
|
364
|
+
for (let attempt = 1; attempt < SLOT_READY_ATTEMPTS && !ready(latest); attempt++) {
|
|
365
|
+
await sleep(SLOT_READY_GAP_MS);
|
|
366
|
+
latest = await ctx.api.get<HostingStatus>(`/apps/${encodeURIComponent(appId)}/hosting`);
|
|
307
367
|
}
|
|
308
368
|
// Says what is true of the SLOT, because both publish paths end here: the
|
|
309
369
|
// one that uploaded a new bundle and the one that found the app already
|
|
310
370
|
// serving this exact bundle and skipped the upload. A message naming an
|
|
311
371
|
// upload sends the second caller looking for one that never happened.
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
'
|
|
315
|
-
|
|
372
|
+
if (latest.gate_settled === false) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
'The hosted slot never registered, so it will not serve. ' +
|
|
375
|
+
'Run publish again to retry.',
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
return latest;
|
|
316
379
|
}
|
|
317
380
|
|
|
318
381
|
// The currently-served version when it is `ready` and already carries `hash`,
|
|
@@ -388,6 +451,336 @@ async function refreshCapabilities(
|
|
|
388
451
|
}
|
|
389
452
|
}
|
|
390
453
|
|
|
454
|
+
export interface ContentsOptions {
|
|
455
|
+
docId: string;
|
|
456
|
+
// Which stream to read; the API defaults to `default`.
|
|
457
|
+
stream?: string | undefined;
|
|
458
|
+
branch?: string | undefined;
|
|
459
|
+
// Where to write the JSON. `null` prints it to stdout instead.
|
|
460
|
+
outPath: string | null;
|
|
461
|
+
// How long to wait for a document whose content is still being prepared,
|
|
462
|
+
// in milliseconds. `0` reads once and fails if it is not ready.
|
|
463
|
+
waitMs?: number;
|
|
464
|
+
// Polling knobs (defaulted); overridable so tests don't wait on wall clock.
|
|
465
|
+
pollIntervalMs?: number;
|
|
466
|
+
sleep?: (ms: number) => Promise<void>;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export interface ContentsResult {
|
|
470
|
+
doc_id: string;
|
|
471
|
+
stream: string;
|
|
472
|
+
out_path: string | null;
|
|
473
|
+
roots: string[];
|
|
474
|
+
bytes: number;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Read what a document holds and write it out as JSON.
|
|
478
|
+
//
|
|
479
|
+
// The document is fetched the way an app fetches one, as the compact CRDT
|
|
480
|
+
// snapshot plus the tail that follows it, and unpacked here. That is what
|
|
481
|
+
// makes the answer the WHOLE document rather than a slice of it: the machine
|
|
482
|
+
// running this is the one with the memory to hold a large one, which is the
|
|
483
|
+
// same reason a browser can. A reader building against an example file needs
|
|
484
|
+
// the record types the file carries, and no window over a store carries them,
|
|
485
|
+
// since an import arrives grouped by entity type.
|
|
486
|
+
export async function documentContents(
|
|
487
|
+
ctx: CommandContext,
|
|
488
|
+
opts: ContentsOptions,
|
|
489
|
+
): Promise<ContentsResult> {
|
|
490
|
+
// Work that REPLACES the document's content holds it read-only while it
|
|
491
|
+
// runs, and the join answers such a document with an empty snapshot: a
|
|
492
|
+
// drive-linked file whose first open enqueues the import is the case that
|
|
493
|
+
// reaches here, and this join is what enqueues it. Writing that out lands
|
|
494
|
+
// an empty file and reports success, which is indistinguishable from a
|
|
495
|
+
// document that really is empty. An outbound push leaves the content
|
|
496
|
+
// readable and is not this.
|
|
497
|
+
let join = await joinDocument(ctx, opts);
|
|
498
|
+
const waitMs = opts.waitMs ?? DEFAULT_CONTENTS_WAIT_MS;
|
|
499
|
+
// Only work that is still running is worth waiting on. Work that has
|
|
500
|
+
// already stopped is answered below without spending a poll on it.
|
|
501
|
+
if (isContentBlocked(join.status) && join.status.pending && waitMs > 0) {
|
|
502
|
+
await awaitContentReady(ctx, opts, join.status, waitMs);
|
|
503
|
+
// One more join, because the poll reads the light route: the content the
|
|
504
|
+
// wait was for arrives only on a route that carries it.
|
|
505
|
+
join = await joinDocument(ctx, opts);
|
|
506
|
+
}
|
|
507
|
+
// A status that has stopped while still blocking is work that ended without
|
|
508
|
+
// content, which is terminal however long anyone waits. `ready` is the one
|
|
509
|
+
// non-terminal reading of that pair, and it is a transient flip on the way
|
|
510
|
+
// to the status clearing, so what tells the two apart is whether the join
|
|
511
|
+
// came back carrying anything.
|
|
512
|
+
if (isContentBlocked(join.status) && !carriesContent(join)) {
|
|
513
|
+
throw contentUnavailable(opts.docId, join.status, waitMs);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Anything the response could not carry inline arrives as a presigned URL,
|
|
517
|
+
// which is how one response delivers a document of any size.
|
|
518
|
+
const [snapshot, ops] = await Promise.all([
|
|
519
|
+
join.snapshot === null ? null : resolveSnapshot(join.snapshot),
|
|
520
|
+
// Seq order, because a tail is replayed in it.
|
|
521
|
+
mapWithConcurrency(
|
|
522
|
+
[...join.tail_ops].sort((a, b) => a.seq - b.seq),
|
|
523
|
+
TAIL_FETCH_CONCURRENCY,
|
|
524
|
+
resolveTailOp,
|
|
525
|
+
),
|
|
526
|
+
]);
|
|
527
|
+
|
|
528
|
+
// Loaded here rather than imported at the top: it pulls the CRDT engine's
|
|
529
|
+
// WASM, which no other command needs and every other command would
|
|
530
|
+
// otherwise pay for at startup.
|
|
531
|
+
assertSdkPairing();
|
|
532
|
+
const { readDocumentContents } = await import('@brass-build/client/internal');
|
|
533
|
+
const contents = readDocumentContents(snapshot, ops);
|
|
534
|
+
const roots = Object.keys(contents);
|
|
535
|
+
const body = serializeContents(contents, opts.docId);
|
|
536
|
+
const bytes = Buffer.byteLength(body, 'utf8');
|
|
537
|
+
|
|
538
|
+
if (opts.outPath === null) {
|
|
539
|
+
ctx.log.write(body);
|
|
540
|
+
} else {
|
|
541
|
+
await writeTextFile(opts.outPath, body);
|
|
542
|
+
ctx.log.success(
|
|
543
|
+
`Wrote ${roots.length === 1 ? '1 root' : `${roots.length} roots`} of ${opts.docId} to ${opts.outPath} (${bytes} bytes)`,
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
return {
|
|
547
|
+
doc_id: join.doc_id,
|
|
548
|
+
stream: opts.stream ?? DEFAULT_STREAM,
|
|
549
|
+
out_path: opts.outPath,
|
|
550
|
+
roots,
|
|
551
|
+
bytes,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// The stream a join reads when the caller names none. The API owns the
|
|
556
|
+
// default; this is only what the result reports back.
|
|
557
|
+
const DEFAULT_STREAM = 'default';
|
|
558
|
+
|
|
559
|
+
// How long a read waits on a document whose content is still being prepared.
|
|
560
|
+
//
|
|
561
|
+
// The command's own join is what enqueues that work for a drive-linked file's
|
|
562
|
+
// first open, so refusing and telling the caller to try later hands them a
|
|
563
|
+
// wait they then have to run by hand. `brass publish` already waits on the
|
|
564
|
+
// slow work it starts; this is the same bargain.
|
|
565
|
+
//
|
|
566
|
+
// The budget is the extraction worker's own 900s ceiling: past it the run is
|
|
567
|
+
// dead rather than slow, so waiting longer reports nothing a shorter wait
|
|
568
|
+
// would not.
|
|
569
|
+
const DEFAULT_CONTENTS_WAIT_MS = 900_000;
|
|
570
|
+
const CONTENTS_POLL_INTERVAL_MS = 2_000;
|
|
571
|
+
const POLL_FAILURES_TOLERATED = 5;
|
|
572
|
+
|
|
573
|
+
// Narrows as well as answers: a blocked status is by definition present, and
|
|
574
|
+
// every caller here goes on to read its wording.
|
|
575
|
+
function isContentBlocked(
|
|
576
|
+
status: DocumentWorkStatus | undefined,
|
|
577
|
+
): status is DocumentWorkStatus {
|
|
578
|
+
return status?.blocking === true;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Whether a join carries document state at all. A never-compacted document
|
|
582
|
+
// lives entirely in its tail, so neither half alone answers this.
|
|
583
|
+
function carriesContent(join: DocumentJoin): boolean {
|
|
584
|
+
return join.snapshot !== null || join.tail_ops.length > 0;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async function joinDocument(ctx: CommandContext, opts: ContentsOptions): Promise<DocumentJoin> {
|
|
588
|
+
const query = new URLSearchParams({ include: 'snapshot' });
|
|
589
|
+
if (opts.stream !== undefined) query.set('stream', opts.stream);
|
|
590
|
+
if (opts.branch !== undefined) query.set('branch', opts.branch);
|
|
591
|
+
return ctx.api.get<DocumentJoin>(
|
|
592
|
+
`/documents/${encodeURIComponent(opts.docId)}?${query.toString()}`,
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Poll until the document's inbound work stops, or the budget runs out.
|
|
597
|
+
//
|
|
598
|
+
// The poll reads the LIGHT document route rather than re-joining: a join
|
|
599
|
+
// resolves the snapshot, presigns every spilled tail op, and records a
|
|
600
|
+
// recent-open, so polling it would re-do all of that every couple of seconds
|
|
601
|
+
// and stamp the caller's own recency surfaces once per attempt. The light
|
|
602
|
+
// route carries the same status and does none of it.
|
|
603
|
+
//
|
|
604
|
+
// Returning is not a claim that the content arrived, only that the work
|
|
605
|
+
// stopped; the caller re-joins and reads what it actually got.
|
|
606
|
+
async function awaitContentReady(
|
|
607
|
+
ctx: CommandContext,
|
|
608
|
+
opts: ContentsOptions,
|
|
609
|
+
initial: DocumentWorkStatus,
|
|
610
|
+
waitMs: number,
|
|
611
|
+
): Promise<void> {
|
|
612
|
+
const sleep = opts.sleep ?? realSleep;
|
|
613
|
+
const intervalMs = opts.pollIntervalMs ?? CONTENTS_POLL_INTERVAL_MS;
|
|
614
|
+
const attempts = Math.max(1, Math.ceil(waitMs / intervalMs));
|
|
615
|
+
// One line per phase, not one per attempt: the platform re-words the status
|
|
616
|
+
// as the work moves between steps, and that transition is the only thing a
|
|
617
|
+
// reader learns from a second line. A heartbeat every couple of seconds
|
|
618
|
+
// would bury it, and a long import would fill a CI log with it.
|
|
619
|
+
let said = initial.message;
|
|
620
|
+
ctx.log.info(`${opts.docId}: ${said}`);
|
|
621
|
+
let consecutiveFailures = 0;
|
|
622
|
+
for (let i = 0; i < attempts; i++) {
|
|
623
|
+
await sleep(intervalMs);
|
|
624
|
+
let detail: DocumentDetail;
|
|
625
|
+
try {
|
|
626
|
+
detail = await ctx.api.get<DocumentDetail>(`/documents/${encodeURIComponent(opts.docId)}`);
|
|
627
|
+
consecutiveFailures = 0;
|
|
628
|
+
} catch (err) {
|
|
629
|
+
// A status read is idempotent and the next one is seconds away, so one
|
|
630
|
+
// that fails is worth another attempt: this wait is minutes long, and
|
|
631
|
+
// ending it on a single blip costs the caller the whole import even
|
|
632
|
+
// though the work continues on the server. A run of them is something
|
|
633
|
+
// else (a credential that expired, a document that was deleted), and
|
|
634
|
+
// that error is the answer rather than a silent spin, so only
|
|
635
|
+
// CONSECUTIVE failures count and a success clears them.
|
|
636
|
+
consecutiveFailures += 1;
|
|
637
|
+
if (consecutiveFailures > POLL_FAILURES_TOLERATED) throw err;
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
const status = detail.status;
|
|
641
|
+
if (!isContentBlocked(status) || !status.pending) return;
|
|
642
|
+
if (status.message !== said) {
|
|
643
|
+
said = status.message;
|
|
644
|
+
ctx.log.info(`${opts.docId}: ${said}`);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Why a document could not be read, in the caller's terms.
|
|
650
|
+
//
|
|
651
|
+
// The three cases differ in what the caller should do next, and saying "run
|
|
652
|
+
// this again once it settles" to all of them is advice that is wrong for two
|
|
653
|
+
// of them: work that has stopped never settles, and a caller who asked not to
|
|
654
|
+
// wait wants to know that waiting is available.
|
|
655
|
+
function contentUnavailable(
|
|
656
|
+
docId: string,
|
|
657
|
+
status: DocumentWorkStatus | undefined,
|
|
658
|
+
waitMs: number,
|
|
659
|
+
): Error {
|
|
660
|
+
const message = status?.message ?? 'The content is not available.';
|
|
661
|
+
if (status?.pending !== true) {
|
|
662
|
+
return new Error(`${docId} could not be prepared: ${message}`);
|
|
663
|
+
}
|
|
664
|
+
if (waitMs === 0) {
|
|
665
|
+
return new Error(
|
|
666
|
+
`${docId} is not readable yet: ${message} It is still being prepared; ` +
|
|
667
|
+
`drop --no-wait to wait for it.`,
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
return new Error(
|
|
671
|
+
`${docId} is not readable yet: ${message} It was still being prepared after ` +
|
|
672
|
+
`${formatDuration(waitMs)}; run this again, or pass --wait <seconds> to wait longer.`,
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Refuse an SDK that is not the one this CLI names, before reaching for
|
|
677
|
+
// anything in it. `sdk-pairing.ts` carries why the version is what gets
|
|
678
|
+
// compared; this is the half that reads the two numbers off disk.
|
|
679
|
+
function assertSdkPairing(): void {
|
|
680
|
+
const message = sdkMismatchMessage({
|
|
681
|
+
pinned: readOwnPin(),
|
|
682
|
+
loaded: resolveLoadedSdk(),
|
|
683
|
+
});
|
|
684
|
+
if (message !== null) throw new Error(message);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// The version this build names, from the CLI's own manifest. `..` from either
|
|
688
|
+
// layout the module runs in (`dist/commands.js` installed, `src/commands.ts`
|
|
689
|
+
// in the repo) is the package root.
|
|
690
|
+
function readOwnPin(): string | undefined {
|
|
691
|
+
try {
|
|
692
|
+
const manifest = JSON.parse(
|
|
693
|
+
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
|
|
694
|
+
) as { dependencies?: Record<string, string> };
|
|
695
|
+
return manifest.dependencies?.['@brass-build/client'];
|
|
696
|
+
} catch {
|
|
697
|
+
return undefined;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// The copy that would answer an import from here, or null when none is
|
|
702
|
+
// installed to read (an in-repo run maps the specifier to source).
|
|
703
|
+
function resolveLoadedSdk(): LoadedSdk | null {
|
|
704
|
+
try {
|
|
705
|
+
const path = createRequire(import.meta.url).resolve(
|
|
706
|
+
'@brass-build/client/package.json',
|
|
707
|
+
);
|
|
708
|
+
const { version } = JSON.parse(readFileSync(path, 'utf8')) as { version: string };
|
|
709
|
+
return { version, path };
|
|
710
|
+
} catch {
|
|
711
|
+
return null;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function formatDuration(ms: number): string {
|
|
716
|
+
const seconds = Math.round(ms / 1000);
|
|
717
|
+
if (seconds < 120) return `${seconds}s`;
|
|
718
|
+
return `${Math.round(seconds / 60)}m`;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// How many of a tail's presigned ops are fetched at once.
|
|
722
|
+
//
|
|
723
|
+
// A tail can name a large number of ops whose bytes are fetched separately,
|
|
724
|
+
// and that is the ordinary case rather than the rare one: a bulk write puts
|
|
725
|
+
// nearly every op's bytes outside the response. Firing them all together
|
|
726
|
+
// opens a connection per op and draws resets, on a link that is somebody's
|
|
727
|
+
// home connection. Bounded so the fetch finishes instead of failing partway.
|
|
728
|
+
const TAIL_FETCH_CONCURRENCY = 16;
|
|
729
|
+
|
|
730
|
+
// `Promise.all` with a ceiling on how many run together, preserving input
|
|
731
|
+
// order in the result.
|
|
732
|
+
async function mapWithConcurrency<T, R>(
|
|
733
|
+
items: readonly T[],
|
|
734
|
+
limit: number,
|
|
735
|
+
run: (item: T) => Promise<R>,
|
|
736
|
+
): Promise<R[]> {
|
|
737
|
+
const results: R[] = Array.from({ length: items.length });
|
|
738
|
+
let next = 0;
|
|
739
|
+
const worker = async (): Promise<void> => {
|
|
740
|
+
for (let i = next++; i < items.length; i = next++) {
|
|
741
|
+
results[i] = await run(items[i] as T);
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
745
|
+
return results;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// One JSON string is what a caller reads and pipes, and V8 caps a single
|
|
749
|
+
// string at 512 MiB however much memory the machine has. A document past
|
|
750
|
+
// that throws a `RangeError` naming neither the document nor the limit, so
|
|
751
|
+
// the ceiling is stated here rather than left to surface as a bare
|
|
752
|
+
// `Invalid string length` out of `JSON.stringify`.
|
|
753
|
+
function serializeContents(contents: object, docId: string): string {
|
|
754
|
+
try {
|
|
755
|
+
return `${JSON.stringify(contents, null, 2)}\n`;
|
|
756
|
+
} catch (cause) {
|
|
757
|
+
if (cause instanceof RangeError) {
|
|
758
|
+
throw new Error(
|
|
759
|
+
`${docId} holds more than a single JSON string can carry (512 MiB), ` +
|
|
760
|
+
`so it cannot be written as one file. Read a smaller stream of it ` +
|
|
761
|
+
`with --stream <name>.`,
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
throw cause;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function decodeBase64(value: string): Uint8Array {
|
|
769
|
+
return new Uint8Array(Buffer.from(value, 'base64'));
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async function resolveSnapshot(snapshot: DocumentJoinSnapshot): Promise<Uint8Array> {
|
|
773
|
+
return snapshot.kind === 'inline'
|
|
774
|
+
? decodeBase64(snapshot.data_b64)
|
|
775
|
+
: await getPresigned(snapshot.url);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
async function resolveTailOp(op: DocumentJoinTailOp): Promise<Uint8Array> {
|
|
779
|
+
return 'op_blob_b64' in op
|
|
780
|
+
? decodeBase64(op.op_blob_b64)
|
|
781
|
+
: await getPresigned(op.op_blob_url);
|
|
782
|
+
}
|
|
783
|
+
|
|
391
784
|
export interface SchemaPullOptions {
|
|
392
785
|
docId: string;
|
|
393
786
|
// Manifest to write the pulled `schema` into (created if absent).
|
|
@@ -609,6 +1002,11 @@ export interface StatusInputs {
|
|
|
609
1002
|
// sign-in that is still relayable from one it has to restart, which is the
|
|
610
1003
|
// difference between waiting and asking the human for a second approval.
|
|
611
1004
|
pendingLogin: { userCode: string; verificationUrl: string; expiresAt: number } | null;
|
|
1005
|
+
// An app approval `brass browser-session` asked for that no one has granted
|
|
1006
|
+
// yet. Reported for the same reason the pending sign-in is: it is a step
|
|
1007
|
+
// only the human can take, so a caller that does not hear about it has no
|
|
1008
|
+
// way to know why the app it just asked about is refused.
|
|
1009
|
+
pendingApproval: { appId: string; approvalUrl: string; expiresAt: number } | null;
|
|
612
1010
|
}
|
|
613
1011
|
|
|
614
1012
|
export interface StatusResult {
|
|
@@ -632,6 +1030,13 @@ export interface StatusResult {
|
|
|
632
1030
|
verification_url: string;
|
|
633
1031
|
expires_in_seconds: number;
|
|
634
1032
|
} | null;
|
|
1033
|
+
// The app approval awaiting a human, when one is outstanding for the app
|
|
1034
|
+
// this status resolved, with the seconds it has left.
|
|
1035
|
+
pending_approval: {
|
|
1036
|
+
app_id: string;
|
|
1037
|
+
approval_url: string;
|
|
1038
|
+
expires_in_seconds: number;
|
|
1039
|
+
} | null;
|
|
635
1040
|
app_id: string | null;
|
|
636
1041
|
hosting: { enabled: boolean; deployed: boolean; url: string | null } | null;
|
|
637
1042
|
// Whether the state is such that a `publish` would proceed (a credential
|
|
@@ -667,12 +1072,13 @@ export async function status(inp: StatusInputs): Promise<StatusResult> {
|
|
|
667
1072
|
ready_to_publish: false,
|
|
668
1073
|
next:
|
|
669
1074
|
pending !== null
|
|
670
|
-
? `a sign-in for ${inp.profile} is waiting for approval:
|
|
671
|
-
`${pending.verificationUrl} and the code ${pending.userCode}
|
|
1075
|
+
? `a sign-in for ${inp.profile} is waiting for approval: put ` +
|
|
1076
|
+
`${pending.verificationUrl} and the code ${pending.userCode} in your ` +
|
|
1077
|
+
`reply, where the user will read them, then run ` +
|
|
672
1078
|
`'brass login --check --wait'. It polls until they approve and renews ` +
|
|
673
1079
|
`the code if it lapses, so there is no need to start a second sign-in.`
|
|
674
1080
|
: `no credential for ${inp.profile}. Run 'brass login --start' to begin a ` +
|
|
675
|
-
`sign-in,
|
|
1081
|
+
`sign-in, put the URL and short code it prints in your reply, then run ` +
|
|
676
1082
|
`'brass login --check --wait' until it reports approved (or set ` +
|
|
677
1083
|
`BRASS_SERVICE_TOKEN). The session feeds the build steps ` +
|
|
678
1084
|
`('brass agents pull' for the organization's instructions, ` +
|
|
@@ -862,15 +1268,45 @@ function pendingLoginReport(inp: StatusInputs): StatusResult['pending_login'] {
|
|
|
862
1268
|
};
|
|
863
1269
|
}
|
|
864
1270
|
|
|
1271
|
+
// Report an outstanding approval only while the link is still good and only
|
|
1272
|
+
// against the app this status resolved, so a caller is never sent to relay a
|
|
1273
|
+
// URL the page refuses or one that answers a question about another app.
|
|
1274
|
+
function pendingApprovalReport(inp: StatusInputs): StatusResult['pending_approval'] {
|
|
1275
|
+
const pending = inp.pendingApproval;
|
|
1276
|
+
if (pending === null || pending.appId !== inp.appId) return null;
|
|
1277
|
+
const remaining = Math.round((pending.expiresAt - Date.now()) / 1000);
|
|
1278
|
+
if (remaining <= 0) return null;
|
|
1279
|
+
return {
|
|
1280
|
+
app_id: pending.appId,
|
|
1281
|
+
approval_url: pending.approvalUrl,
|
|
1282
|
+
expires_in_seconds: remaining,
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
|
|
865
1286
|
function finish(
|
|
866
1287
|
inp: StatusInputs,
|
|
867
|
-
rest: Omit<StatusResult, 'profile' | 'app_id' | 'pending_login'>,
|
|
1288
|
+
rest: Omit<StatusResult, 'profile' | 'app_id' | 'pending_login' | 'pending_approval'>,
|
|
868
1289
|
): StatusResult {
|
|
1290
|
+
const pendingApproval = pendingApprovalReport(inp);
|
|
869
1291
|
const result: StatusResult = {
|
|
870
1292
|
profile: inp.profile,
|
|
871
1293
|
app_id: inp.appId,
|
|
872
1294
|
pending_login: pendingLoginReport(inp),
|
|
1295
|
+
pending_approval: pendingApproval,
|
|
873
1296
|
...rest,
|
|
1297
|
+
// An outstanding approval outranks whatever this branch would otherwise
|
|
1298
|
+
// say, on the same terms as the pending sign-in above it: it is the one
|
|
1299
|
+
// step the caller cannot take itself, and everything else the branch could
|
|
1300
|
+
// name is a step it can. Only once the credential is good, since an
|
|
1301
|
+
// approval is moot to a caller that cannot authenticate at all.
|
|
1302
|
+
...(pendingApproval !== null && rest.authenticated === true
|
|
1303
|
+
? {
|
|
1304
|
+
next:
|
|
1305
|
+
`${pendingApproval.app_id} is waiting for approval before it can be opened ` +
|
|
1306
|
+
`in a browser: give the user ${pendingApproval.approval_url} and ask them to ` +
|
|
1307
|
+
`approve, then run 'brass browser-session' again. Publishing is unaffected.`,
|
|
1308
|
+
}
|
|
1309
|
+
: {}),
|
|
874
1310
|
};
|
|
875
1311
|
const cred =
|
|
876
1312
|
result.credential === 'none'
|
|
@@ -892,6 +1328,13 @@ function finish(
|
|
|
892
1328
|
);
|
|
893
1329
|
}
|
|
894
1330
|
inp.log.info(` app: ${result.app_id ?? 'none (a first publish creates one)'}`);
|
|
1331
|
+
if (result.pending_approval !== null) {
|
|
1332
|
+
const a = result.pending_approval;
|
|
1333
|
+
inp.log.info(
|
|
1334
|
+
` approval: awaiting a human at ${a.approval_url} ` +
|
|
1335
|
+
`(${Math.ceil(a.expires_in_seconds / 60)} min left)`,
|
|
1336
|
+
);
|
|
1337
|
+
}
|
|
895
1338
|
if (result.hosting !== null) {
|
|
896
1339
|
const h = result.hosting;
|
|
897
1340
|
const hostingLine = h.url
|