@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.
Files changed (60) hide show
  1. package/AGENTS.md +177 -11
  2. package/CHANGELOG.md +29 -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 +197 -19
  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 +369 -29
  21. package/dist/commands.js.map +1 -1
  22. package/dist/config.d.ts +1 -0
  23. package/dist/config.d.ts.map +1 -1
  24. package/dist/config.js +8 -1
  25. package/dist/config.js.map +1 -1
  26. package/dist/login.d.ts +1 -0
  27. package/dist/login.d.ts.map +1 -1
  28. package/dist/login.js +24 -12
  29. package/dist/login.js.map +1 -1
  30. package/dist/project.d.ts +8 -0
  31. package/dist/project.d.ts.map +1 -1
  32. package/dist/project.js +75 -7
  33. package/dist/project.js.map +1 -1
  34. package/dist/sdk-pairing.d.ts +9 -0
  35. package/dist/sdk-pairing.d.ts.map +1 -0
  36. package/dist/sdk-pairing.js +42 -0
  37. package/dist/sdk-pairing.js.map +1 -0
  38. package/dist/session.d.ts +18 -1
  39. package/dist/session.d.ts.map +1 -1
  40. package/dist/session.js +95 -12
  41. package/dist/session.js.map +1 -1
  42. package/dist/store.d.ts +10 -0
  43. package/dist/store.d.ts.map +1 -1
  44. package/dist/store.js +41 -0
  45. package/dist/store.js.map +1 -1
  46. package/dist/version.d.ts +2 -2
  47. package/dist/version.js +1 -1
  48. package/package.json +2 -1
  49. package/src/api.ts +80 -2
  50. package/src/approval-prompt.ts +75 -0
  51. package/src/args.ts +10 -1
  52. package/src/cli.ts +240 -18
  53. package/src/commands.ts +479 -36
  54. package/src/config.ts +15 -2
  55. package/src/login.ts +34 -11
  56. package/src/project.ts +82 -7
  57. package/src/sdk-pairing.ts +53 -0
  58. package/src/session.ts +135 -11
  59. package/src/store.ts +77 -0
  60. 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;
@@ -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 awaitGateSettled(
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
- // 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(
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
- const manifest = await readManifest(opts.manifestPath);
188
- const name = opts.name ?? manifest?.name;
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
- 'No app to publish to. Pass --name to create one, or --app / BRASS_APP_ID / a .brass/project.json for an existing app.',
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?.client_token;
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 GATE_SETTLE_ATTEMPTS = 3;
293
- const GATE_SETTLE_GAP_MS = 2000;
343
+ const SLOT_READY_ATTEMPTS = 3;
344
+ const SLOT_READY_GAP_MS = 2000;
294
345
 
295
- async function awaitGateSettled(
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 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;
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
- throw new Error(
313
- 'The hosted slot never registered, so it will not serve. ' +
314
- 'Run publish again to retry.',
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: give the user ` +
671
- `${pending.verificationUrl} and the code ${pending.userCode}, then run ` +
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, give the user the URL and short code it prints, then run ` +
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