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