@brass-build/cli 0.2.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.
- package/AGENTS.md +170 -11
- package/CHANGELOG.md +29 -0
- package/README.md +79 -15
- 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 +213 -31
- 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 +358 -24
- 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.map +1 -1
- package/dist/login.d.ts +1 -0
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +30 -16
- 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 +15 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +78 -16
- 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 +257 -32
- package/src/commands.ts +468 -30
- package/src/config.ts +5 -1
- package/src/login.ts +40 -15
- package/src/project.ts +82 -7
- package/src/sdk-pairing.ts +53 -0
- package/src/session.ts +106 -14
- 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;
|
|
@@ -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
|
|
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
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const status = await
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
293
|
-
const
|
|
338
|
+
const SLOT_READY_ATTEMPTS = 3;
|
|
339
|
+
const SLOT_READY_GAP_MS = 2000;
|
|
294
340
|
|
|
295
|
-
|
|
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
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
'
|
|
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:
|
|
671
|
-
`${pending.verificationUrl} and the code ${pending.userCode}
|
|
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,
|
|
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
|
package/src/config.ts
CHANGED
|
@@ -53,7 +53,11 @@ export type Profile = string;
|
|
|
53
53
|
// `BRASS_SERVICE_TOKEN`.
|
|
54
54
|
export interface StoredCredential {
|
|
55
55
|
token?: string;
|
|
56
|
-
session
|
|
56
|
+
// `authBaseUrl` is the auth origin the session was minted on, so a later
|
|
57
|
+
// `brass logout` revokes it there even when the invocation's own flags
|
|
58
|
+
// point elsewhere. Optional: a file an older CLI wrote carries only the
|
|
59
|
+
// sid, and the logout falls back to the invocation's auth origin.
|
|
60
|
+
session?: { sid: string; authBaseUrl?: string };
|
|
57
61
|
}
|
|
58
62
|
export interface CredentialsFile {
|
|
59
63
|
version: 1;
|