@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.
Files changed (59) hide show
  1. package/AGENTS.md +170 -11
  2. package/CHANGELOG.md +29 -0
  3. package/README.md +79 -15
  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 +213 -31
  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/config.d.ts +1 -0
  23. package/dist/config.d.ts.map +1 -1
  24. package/dist/config.js.map +1 -1
  25. package/dist/login.d.ts +1 -0
  26. package/dist/login.d.ts.map +1 -1
  27. package/dist/login.js +30 -16
  28. package/dist/login.js.map +1 -1
  29. package/dist/project.d.ts +8 -0
  30. package/dist/project.d.ts.map +1 -1
  31. package/dist/project.js +75 -7
  32. package/dist/project.js.map +1 -1
  33. package/dist/sdk-pairing.d.ts +9 -0
  34. package/dist/sdk-pairing.d.ts.map +1 -0
  35. package/dist/sdk-pairing.js +42 -0
  36. package/dist/sdk-pairing.js.map +1 -0
  37. package/dist/session.d.ts +15 -1
  38. package/dist/session.d.ts.map +1 -1
  39. package/dist/session.js +78 -16
  40. package/dist/session.js.map +1 -1
  41. package/dist/store.d.ts +10 -0
  42. package/dist/store.d.ts.map +1 -1
  43. package/dist/store.js +41 -0
  44. package/dist/store.js.map +1 -1
  45. package/dist/version.d.ts +2 -2
  46. package/dist/version.js +1 -1
  47. package/package.json +2 -1
  48. package/src/api.ts +80 -2
  49. package/src/approval-prompt.ts +75 -0
  50. package/src/args.ts +10 -1
  51. package/src/cli.ts +257 -32
  52. package/src/commands.ts +468 -30
  53. package/src/config.ts +5 -1
  54. package/src/login.ts +40 -15
  55. package/src/project.ts +82 -7
  56. package/src/sdk-pairing.ts +53 -0
  57. package/src/session.ts +106 -14
  58. package/src/store.ts +77 -0
  59. package/src/version.ts +1 -1
package/src/cli.ts CHANGED
@@ -23,25 +23,44 @@ import {
23
23
  } from './config.js';
24
24
  import {
25
25
  readCredentialsFile,
26
+ readPendingApproval,
26
27
  readPendingLogin,
28
+ writePendingApproval,
27
29
  writePendingLogin,
28
30
  writeStoredCredential,
29
31
  } from './store.js';
30
32
  import { BrassApi, BrassApiError, type AppVisibility } from './api.js';
31
33
  import { serviceTokenAuth, type AuthProvider } from './auth.js';
32
- import { loginDevice, postDeviceCancel, postSignOut, sessionAuth } from './session.js';
34
+ import {
35
+ loginDevice,
36
+ postDeviceAppSession,
37
+ postDeviceCancel,
38
+ postSignOut,
39
+ sessionAuth,
40
+ } from './session.js';
33
41
  import { loginStart, loginCheck } from './login.js';
42
+ import { renderApprovalPrompt } from './approval-prompt.js';
34
43
  import { createLogger, type Logger } from './log.js';
35
44
  import {
36
45
  publish,
37
46
  schemaPull,
47
+ documentContents,
38
48
  agentsPull,
39
49
  whoami,
40
50
  status,
41
51
  type CommandContext,
42
52
  type CredentialKind,
43
53
  } from './commands.js';
44
- import { readProjectState, resolveAppId, readManifest } from './project.js';
54
+ import {
55
+ readProjectState,
56
+ resolveAppId,
57
+ loadFirstManifest,
58
+ firstExistingManifestPath,
59
+ publishManifestCandidates,
60
+ sourceManifestCandidates,
61
+ MANIFEST_FILENAME,
62
+ type AppManifest,
63
+ } from './project.js';
45
64
 
46
65
  import { VERSION } from './version.js';
47
66
 
@@ -57,10 +76,21 @@ Usage:
57
76
  --wait [seconds] polls until approved (default 120s), renewing
58
77
  an expired code in place and printing the new one.
59
78
  brass logout End the stored sign-in for this environment, here and on the server.
60
- brass status [dir] Report the credential + app state and the one command to run next.
79
+ brass browser-session --return-to <url> [--app <appId>]
80
+ Print a URL that opens <url> signed in as you, for a browser
81
+ you drive. The URL is single-use and expires in two minutes.
82
+ brass status [dir] [--app <appId>]
83
+ Report the credential + app state and the one command to run next.
61
84
  brass publish [dir] Build output in [dir] (default: dist) is deployed to the app's hosting.
62
85
  brass schema pull --doc <docId> [--out brass-app.json]
63
86
  Fetch a document's schema and write it into a manifest, verbatim.
87
+ brass contents --doc <docId> [--out contents.json | --stdout] [--stream <name>] [--branch <id>]
88
+ Write everything a document holds as JSON: every
89
+ root container, read whole. The whole document,
90
+ not a sample, so every record type it carries is
91
+ in the file. A document still being imported is
92
+ waited out (--wait <seconds> moves the deadline,
93
+ default 900; --no-wait reads once and fails).
64
94
  brass agents pull [--out AGENTS.md | --stdout] [--org <organizationId>]
65
95
  Write your organization's agent instructions (its
66
96
  AGENTS.md / CLAUDE.md) to a file, or --stdout to print
@@ -71,12 +101,24 @@ Usage:
71
101
  Authentication:
72
102
  Run 'brass login' to sign in with your browser (needed for schema pull),
73
103
  or set BRASS_SERVICE_TOKEN to a service token minted in the dashboard
74
- (org Settings -> Service tokens) for CI, or pass --token <token>.
104
+ (org Settings -> Access tokens) for CI, or pass --token <token>.
75
105
 
76
106
  Common flags:
77
107
  --token <token> Service token to authenticate with.
78
108
  --json Emit the machine-readable result on stdout.
79
109
 
110
+ Status flags:
111
+ --app <appId> Report on a specific app (else .brass/project.json / BRASS_APP_ID).
112
+
113
+ Sign-in flags:
114
+ --app <appId> Approve an app you do not own for the sign-in as
115
+ well, so 'brass browser-session' needs no second
116
+ approval. An app you own never does.
117
+
118
+ Browser-session flags:
119
+ --return-to <url> Where the browser lands, on the app's redirect allowlist.
120
+ --app <appId> The app to sign in to (else .brass/project.json / BRASS_APP_ID).
121
+
80
122
  Publish flags:
81
123
  --app <appId> Publish to a specific app (else .brass/project.json / BRASS_APP_ID).
82
124
  --name <name> Name for the app when creating one on first publish.
@@ -162,6 +204,12 @@ export async function run(argv: readonly string[]): Promise<number> {
162
204
  // one is obtained); everything else needs a resolved credential.
163
205
  if (command === 'login') return await runLogin(parsed, log);
164
206
  if (command === 'logout') return await runLogout(parsed, log);
207
+ // Reads the stored device pointer directly rather than through
208
+ // `buildContext`: what the handoff route takes is that credential, not an
209
+ // access token minted from it.
210
+ if (command === 'browser-session') {
211
+ return await runBrowserSession(parsed, log, boolFlag(parsed, 'json'));
212
+ }
165
213
  // status runs before a credential is required: reporting "no credential"
166
214
  // (and the next step to obtain one) is a first-class outcome, not an error.
167
215
  if (command === 'status') return await runStatus(parsed, log);
@@ -193,7 +241,7 @@ type Context = CommandContext;
193
241
 
194
242
  // The commands that need a resolved credential; `login` / `logout` / `status`
195
243
  // are answered above without one.
196
- const CREDENTIALED_COMMANDS = ['publish', 'schema', 'agents', 'whoami'] as const;
244
+ const CREDENTIALED_COMMANDS = ['publish', 'schema', 'contents', 'agents', 'whoami'] as const;
197
245
  type CredentialedCommand = (typeof CREDENTIALED_COMMANDS)[number];
198
246
 
199
247
  function isCredentialedCommand(value: string): value is CredentialedCommand {
@@ -212,6 +260,8 @@ function planCommand(command: CredentialedCommand, parsed: ParsedArgs): CommandP
212
260
  return planPublish(parsed);
213
261
  case 'schema':
214
262
  return planSchema(parsed);
263
+ case 'contents':
264
+ return planContents(parsed);
215
265
  case 'agents':
216
266
  return planAgents(parsed);
217
267
  case 'whoami':
@@ -283,7 +333,7 @@ async function resolveAuth(
283
333
  });
284
334
  if (credential === null) {
285
335
  throw new Error(
286
- 'No credential. Run `brass login`, or set BRASS_SERVICE_TOKEN (mint one in the dashboard: org Settings -> Service tokens), or pass --token.',
336
+ 'No credential. Run `brass login`, or set BRASS_SERVICE_TOKEN (mint one in the dashboard: org Settings -> Access tokens), or pass --token.',
287
337
  );
288
338
  }
289
339
  const auth =
@@ -300,13 +350,17 @@ async function runLogin(parsed: ParsedArgs, log: Logger): Promise<number> {
300
350
  // interactive flow.
301
351
  const start = boolFlag(parsed, 'start');
302
352
  const check = boolFlag(parsed, 'check');
303
- const waitSeconds = parseWaitFlag(parsed);
353
+ const waitSeconds = parseWaitFlag(parsed, DEFAULT_WAIT_SECONDS);
304
354
  if (start && check) throw new Error('Pass one of --start or --check.');
355
+ // The app this sign-in should also approve, so the human's one click covers
356
+ // the machine and the app it is about to hand a browser.
357
+ const targetAppId = stringFlag(parsed, 'app');
305
358
  if (start) {
306
359
  const code = await loginStart({
307
360
  authBaseUrl: origins.authBaseUrl,
308
361
  profile,
309
362
  log,
363
+ ...(targetAppId !== undefined ? { targetAppId } : {}),
310
364
  ...(boolFlag(parsed, 'new') ? { force: true } : {}),
311
365
  ...(waitSeconds !== undefined ? { resultFollows: true } : {}),
312
366
  });
@@ -320,8 +374,13 @@ async function runLogin(parsed: ParsedArgs, log: Logger): Promise<number> {
320
374
  }
321
375
  // The RFC 8628 device grant: open the approval page (code prefilled), print
322
376
  // the URL + code as a fallback for a headless box, and poll until approval.
323
- const result = await loginDevice({ authBaseUrl: origins.authBaseUrl });
324
- await writeStoredCredential(profile, { session: { sid: result.sessionToken } });
377
+ const result = await loginDevice({
378
+ authBaseUrl: origins.authBaseUrl,
379
+ ...(targetAppId !== undefined ? { targetAppId } : {}),
380
+ });
381
+ await writeStoredCredential(profile, {
382
+ session: { sid: result.sessionToken, authBaseUrl: origins.authBaseUrl },
383
+ });
325
384
  log.success(result.email ? `Signed in as ${result.email}.` : 'Signed in.');
326
385
  log.result({ signed_in: true, ...(result.email !== undefined ? { email: result.email } : {}) });
327
386
  return 0;
@@ -332,34 +391,138 @@ async function runLogout(parsed: ParsedArgs, log: Logger): Promise<number> {
332
391
 
333
392
  // Revoke on the server first, while the pointer is still readable. A
334
393
  // service token belongs to an organization and is revoked in the dashboard,
335
- // so only a stored session has anything to end here.
394
+ // so only a stored session has anything to end here. The session is revoked
395
+ // on the auth origin it was minted on: profiles key on the api host, so the
396
+ // invocation's own auth flag can name a different stack, where an unknown
397
+ // pointer reads as already-signed-out and the real session stays live.
336
398
  const file = await readCredentialsFile();
337
- const stored = file?.credentials[profile]?.session?.sid;
338
- const signedOut = stored === undefined ? true : await postSignOut(origins.authBaseUrl, stored);
399
+ const session = file?.credentials[profile]?.session;
400
+ const signedOut =
401
+ session === undefined
402
+ ? true
403
+ : await postSignOut(session.authBaseUrl ?? origins.authBaseUrl, session.sid);
339
404
 
340
405
  // A started sign-in is redeemable by whoever holds the device code, and a
341
406
  // human may still approve it after this command returns, so cancelling it on
342
- // the server is what a sign-out owes. Dropping the local record alone would
343
- // leave the grant live and this machine unable to name it.
407
+ // the server is what a sign-out owes. The grant, too, is cancelled on the
408
+ // auth origin that minted it.
344
409
  const pending = await readPendingLogin(profile);
345
410
  const cancelled =
346
- pending === null ? true : await postDeviceCancel(origins.authBaseUrl, pending.deviceCode);
347
-
348
- await writeStoredCredential(profile, null);
349
- await writePendingLogin(profile, null);
411
+ pending === null ? true : await postDeviceCancel(pending.authBaseUrl, pending.deviceCode);
412
+
413
+ // Each record is dropped only once its server side is revoked. The record
414
+ // is the one handle that can name the session or grant to the server, so a
415
+ // failed delivery keeps it, which is what makes the retry this command
416
+ // recommends able to revoke anything.
417
+ if (signedOut) await writeStoredCredential(profile, null);
418
+ if (cancelled) await writePendingLogin(profile, null);
350
419
  const revoked = signedOut && cancelled;
351
420
 
352
- // The local credential is gone either way, so say so, and name the part
353
- // that did not happen rather than reporting a clean sign-out over a
354
- // credential that still works.
355
421
  if (revoked) {
356
422
  log.success('Signed out.');
357
423
  } else {
358
- log.success(
359
- 'Signed out on this machine. Brass could not be reached to revoke the sign-in, so run `brass logout` again when it is.',
424
+ log.warn(
425
+ 'Brass could not be reached to revoke the sign-in, so it is kept on this machine. Run `brass logout` again when Brass is reachable.',
426
+ );
427
+ }
428
+ log.result({ signed_out: revoked, revoked });
429
+ return revoked ? 0 : 1;
430
+ }
431
+
432
+ // `brass browser-session`: turn this machine's sign-in into a URL that signs a
433
+ // browser in as the same human, so an agent that just built an app can open it
434
+ // and drive it rather than reporting it verified by types.
435
+ //
436
+ // The URL is what the command is for, so in the plain form it goes to stdout
437
+ // alone and the guidance to stderr, and `--json` puts it in the result object
438
+ // instead. Either way it is a credential with a two-minute life: it belongs in
439
+ // the browser being driven, not in a log or a commit.
440
+ async function runBrowserSession(
441
+ parsed: ParsedArgs,
442
+ log: Logger,
443
+ json: boolean,
444
+ ): Promise<number> {
445
+ const { origins, profile } = resolveBase(parsed);
446
+ const returnTo = stringFlag(parsed, 'return-to');
447
+ if (returnTo === undefined) {
448
+ throw new Error(
449
+ 'brass browser-session requires --return-to <url> (where the browser should land)',
450
+ );
451
+ }
452
+ const state = await readProjectState(process.cwd());
453
+ const flagApp = stringFlag(parsed, 'app');
454
+ const envApp = process.env['BRASS_APP_ID'];
455
+ const appId = resolveAppId({
456
+ ...(flagApp !== undefined ? { flagApp } : {}),
457
+ ...(envApp !== undefined ? { envApp } : {}),
458
+ state,
459
+ profile,
460
+ });
461
+ if (appId === null) {
462
+ throw new Error(
463
+ 'No app to sign in to. Pass --app <appId>, or run this from a directory `brass publish` has written .brass/project.json in.',
360
464
  );
361
465
  }
362
- log.result({ signed_out: true, revoked });
466
+
467
+ // The device pointer itself, not an access token minted from it: the handoff
468
+ // route authenticates the machine, and only that credential names one. A
469
+ // service token belongs to an organization rather than to a person, so it
470
+ // has no session for a browser to be handed.
471
+ const file = await readCredentialsFile();
472
+ const session = file?.credentials[profile]?.session;
473
+ if (session === undefined) {
474
+ throw new Error(
475
+ 'No Brass sign-in on this machine. Run `brass login` (a service token cannot be handed to a browser).',
476
+ );
477
+ }
478
+
479
+ const outcome = await postDeviceAppSession(
480
+ session.authBaseUrl ?? origins.authBaseUrl,
481
+ session.sid,
482
+ { appId, returnTo },
483
+ );
484
+ if (outcome.state === 'needs_approval') {
485
+ // Recorded so `brass status` can report what is outstanding. A caller that
486
+ // relays the URL and then asks status what to do next is otherwise told
487
+ // the app is ready, which is true of publishing and false of the thing it
488
+ // was doing.
489
+ await writePendingApproval(profile, {
490
+ appId,
491
+ approvalUrl: outcome.approvalUrl,
492
+ authBaseUrl: session.authBaseUrl ?? origins.authBaseUrl,
493
+ expiresAt: Date.now() + outcome.expiresIn * 1000,
494
+ });
495
+ // Through the same frame the sign-in's approval prints in, and with the
496
+ // same relay note: this is the same request with a different subject, and
497
+ // it reaches the human only if whoever runs this passes it on.
498
+ log.info(
499
+ `\n${renderApprovalPrompt({
500
+ heading: ` To open ${appId} in a browser, it needs approving for this machine.`,
501
+ url: outcome.approvalUrl,
502
+ relaying: true,
503
+ closing:
504
+ 'The person approving must be signed in to Brass as the account this ' +
505
+ 'machine is signed in as.\nThen run this command again.',
506
+ })}`,
507
+ );
508
+ log.result({ state: 'needs_approval', app_id: appId, approval_url: outcome.approvalUrl });
509
+ return 1;
510
+ }
511
+ await writePendingApproval(profile, null);
512
+ if (json) {
513
+ log.result({
514
+ state: 'ready',
515
+ url: outcome.url,
516
+ app_id: appId,
517
+ return_to: returnTo,
518
+ expires_in_seconds: outcome.expiresIn,
519
+ });
520
+ } else {
521
+ log.info(
522
+ `Open this in the browser you are driving, within ${outcome.expiresIn} seconds. It signs in once and cannot be reused.`,
523
+ );
524
+ log.write(`${outcome.url}\n`);
525
+ }
363
526
  return 0;
364
527
  }
365
528
 
@@ -400,13 +563,28 @@ async function runStatus(parsed: ParsedArgs, log: Logger): Promise<number> {
400
563
  state,
401
564
  profile,
402
565
  });
403
- const manifest = await readManifest(stringFlag(parsed, 'manifest') ?? 'brass-app.json');
566
+ const manifestFlag = stringFlag(parsed, 'manifest');
567
+ // `status` reports rather than acts, so a manifest that cannot be parsed is
568
+ // named and the rest of the report still runs. Publishing that same tree
569
+ // fails on it, which is the point at which it has to be fixed.
570
+ let manifest: AppManifest | undefined;
571
+ try {
572
+ manifest = (
573
+ await loadFirstManifest(
574
+ cwd,
575
+ manifestFlag !== undefined ? [manifestFlag] : publishManifestCandidates(dir),
576
+ )
577
+ )?.manifest;
578
+ } catch (err) {
579
+ log.warn(err instanceof Error ? err.message : String(err));
580
+ }
404
581
  const manifestName =
405
582
  typeof manifest?.name === 'string' && manifest.name.trim() !== ''
406
583
  ? manifest.name.trim()
407
584
  : null;
408
585
 
409
586
  const pending = await readPendingLogin(profile);
587
+ const pendingApproval = await readPendingApproval(profile);
410
588
  const result = await status({
411
589
  api,
412
590
  profile,
@@ -423,6 +601,7 @@ async function runStatus(parsed: ParsedArgs, log: Logger): Promise<number> {
423
601
  verificationUrl: pending.verificationUriComplete ?? pending.verificationUri,
424
602
  expiresAt: pending.expiresAt,
425
603
  },
604
+ pendingApproval,
426
605
  });
427
606
  log.result(result);
428
607
  return 0;
@@ -436,7 +615,7 @@ function planPublish(parsed: ParsedArgs): CommandPlan {
436
615
  const org = stringFlag(parsed, 'org');
437
616
  const slug = stringFlag(parsed, 'slug');
438
617
  const clientToken = stringFlag(parsed, 'client-token');
439
- const manifestPath = stringFlag(parsed, 'manifest') ?? 'brass-app.json';
618
+ const manifestPath = stringFlag(parsed, 'manifest');
440
619
  const visibility = parseVisibilityFlag(stringFlag(parsed, 'visibility'));
441
620
  const requireAccess = parseGateFlag(stringFlag(parsed, 'gate'));
442
621
  return async (ctx): Promise<unknown> => {
@@ -449,7 +628,7 @@ function planPublish(parsed: ParsedArgs): CommandPlan {
449
628
  });
450
629
  return publish(ctx, {
451
630
  dir,
452
- manifestPath,
631
+ ...(manifestPath !== undefined ? { manifestPath } : {}),
453
632
  ...(appId !== null ? { appId } : {}),
454
633
  ...(name !== undefined ? { name } : {}),
455
634
  ...(org !== undefined ? { organizationId: org } : {}),
@@ -467,11 +646,19 @@ function planPublish(parsed: ParsedArgs): CommandPlan {
467
646
  // so a second check resumes the same sign-in.
468
647
  const DEFAULT_WAIT_SECONDS = 120;
469
648
  const MAX_WAIT_SECONDS = 3600;
470
-
471
- function parseWaitFlag(parsed: ParsedArgs): number | undefined {
649
+ // `brass contents` waits by default, so its `--wait` only moves the budget.
650
+ // The default is the extraction worker's own ceiling: past it the run has
651
+ // stopped rather than slowed.
652
+ const CONTENTS_WAIT_SECONDS = 900;
653
+
654
+ // `--wait` with no value means "the command's own default deadline", which
655
+ // differs per command, so the default is the caller's to name. `undefined`
656
+ // back means the flag was absent, which each command reads for itself: the
657
+ // sign-in poll does not wait at all, and a document read waits anyway.
658
+ function parseWaitFlag(parsed: ParsedArgs, bareValue: number): number | undefined {
472
659
  const raw = parsed.flags['wait'];
473
660
  if (raw === undefined) return undefined;
474
- if (raw === true) return DEFAULT_WAIT_SECONDS;
661
+ if (raw === true) return bareValue;
475
662
  const seconds = Number(raw);
476
663
  if (!Number.isInteger(seconds) || seconds <= 0 || seconds > MAX_WAIT_SECONDS) {
477
664
  throw new Error(`Invalid --wait "${raw}" (expected whole seconds, 1 to ${MAX_WAIT_SECONDS})`);
@@ -500,14 +687,52 @@ function parseGateFlag(value: string | undefined): boolean | undefined {
500
687
  return value === 'on';
501
688
  }
502
689
 
690
+ function planContents(parsed: ParsedArgs): CommandPlan {
691
+ const docId = stringFlag(parsed, 'doc');
692
+ if (docId === undefined) throw new Error('brass contents requires --doc <docId>');
693
+ const toStdout = boolFlag(parsed, 'stdout');
694
+ const outPath = stringFlag(parsed, 'out');
695
+ // Both name where the JSON goes, so together neither answer is the one the
696
+ // caller gets.
697
+ if (toStdout && outPath !== undefined) {
698
+ throw new Error('Pass one of --stdout or --out, not both');
699
+ }
700
+ // A document whose content is still being prepared is waited out rather
701
+ // than refused, because this command's own join is what starts that work.
702
+ // The two flags name the same budget from opposite ends, so together
703
+ // neither answer is the one the caller gets.
704
+ const noWait = boolFlag(parsed, 'no-wait');
705
+ const waitSeconds = parseWaitFlag(parsed, CONTENTS_WAIT_SECONDS);
706
+ if (noWait && waitSeconds !== undefined) {
707
+ throw new Error('Pass one of --wait or --no-wait, not both');
708
+ }
709
+ const opts = {
710
+ docId,
711
+ stream: stringFlag(parsed, 'stream'),
712
+ branch: stringFlag(parsed, 'branch'),
713
+ outPath: toStdout ? null : (outPath ?? 'contents.json'),
714
+ waitMs: noWait ? 0 : (waitSeconds ?? CONTENTS_WAIT_SECONDS) * 1000,
715
+ };
716
+ return (ctx): Promise<unknown> => documentContents(ctx, opts);
717
+ }
718
+
503
719
  function planSchema(parsed: ParsedArgs): CommandPlan {
504
720
  if (parsed.positionals[1] !== 'pull') {
505
721
  throw new Error('Usage: brass schema pull --doc <docId> [--out brass-app.json]');
506
722
  }
507
723
  const docId = stringFlag(parsed, 'doc');
508
724
  if (docId === undefined) throw new Error('brass schema pull requires --doc <docId>');
509
- const outPath = stringFlag(parsed, 'out') ?? 'brass-app.json';
510
- return (ctx): Promise<unknown> => schemaPull(ctx, { docId, outPath });
725
+ const outFlag = stringFlag(parsed, 'out');
726
+ return async (ctx): Promise<unknown> => {
727
+ // Merge into the manifest the repo already keeps rather than writing a
728
+ // second one beside it: a pulled schema in a file the bundle never serves
729
+ // is a schema the platform never reads.
730
+ const outPath =
731
+ outFlag ??
732
+ (await firstExistingManifestPath(ctx.cwd, sourceManifestCandidates())) ??
733
+ MANIFEST_FILENAME;
734
+ return schemaPull(ctx, { docId, outPath });
735
+ };
511
736
  }
512
737
 
513
738
  function planAgents(parsed: ParsedArgs): CommandPlan {