@brass-build/cli 0.1.0 → 0.3.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/src/api.ts CHANGED
@@ -34,6 +34,10 @@ export interface HostingStatus {
34
34
  // Whether the edge gates the bundle behind the app's audience. A public
35
35
  // showcase turns this off so the bundle is world-loadable.
36
36
  require_access?: boolean;
37
+ // Whether the platform has registered this slot to serve. False means the
38
+ // hosted URL answers 404 however the rest of this body reads. Optional: an
39
+ // older api omits it, and an absent field is not a failure to report.
40
+ gate_settled?: boolean;
37
41
  }
38
42
 
39
43
  export interface HostingUploadUrl {
@@ -110,6 +114,28 @@ export class BrassApiError extends Error {
110
114
  }
111
115
  }
112
116
 
117
+ // `fetch` carries no timeout of its own, so a host that accepts the connection
118
+ // and then answers nothing holds the command open for as long as the caller
119
+ // leaves it running. In CI that is a job that never finishes rather than a
120
+ // failure anyone can read, so every request the CLI makes is bounded.
121
+ const REQUEST_TIMEOUT_MS = 30_000;
122
+ // The upload carries the whole bundle, so it is bounded far more loosely than
123
+ // a request that carries a few hundred bytes.
124
+ const UPLOAD_TIMEOUT_MS = 300_000;
125
+
126
+ /** A failed fetch as the caller should read it, naming a timeout as one. */
127
+ export function networkError(what: string, cause: unknown): BrassApiError {
128
+ const timedOut =
129
+ cause instanceof Error &&
130
+ (cause.name === 'TimeoutError' || cause.name === 'AbortError');
131
+ return new BrassApiError(
132
+ 0,
133
+ timedOut
134
+ ? `Timed out ${what}.`
135
+ : `Network error ${what}: ${String(cause)}`,
136
+ );
137
+ }
138
+
113
139
  export class BrassApi {
114
140
  private readonly apiBaseUrl: string;
115
141
  private readonly auth: AuthProvider;
@@ -132,7 +158,7 @@ export class BrassApi {
132
158
  private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
133
159
  const headers: Record<string, string> = {
134
160
  ...(await this.auth.headers()),
135
- // Version telemetry; the api Lambda logs it so pinned CI copies
161
+ // Version telemetry; the platform logs it so pinned CI copies
136
162
  // stay visible in the deployed-version distribution.
137
163
  'x-brass-client': CLI_CLIENT_ID,
138
164
  };
@@ -143,9 +169,12 @@ export class BrassApi {
143
169
  }
144
170
  let response: Response;
145
171
  try {
146
- response = await fetch(`${this.apiBaseUrl}${path}`, init);
172
+ response = await fetch(`${this.apiBaseUrl}${path}`, {
173
+ ...init,
174
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
175
+ });
147
176
  } catch (cause) {
148
- throw new BrassApiError(0, `Network error reaching ${this.apiBaseUrl}: ${String(cause)}`);
177
+ throw networkError(`reaching ${this.apiBaseUrl}`, cause);
149
178
  }
150
179
  if (!response.ok) {
151
180
  throw new BrassApiError(response.status, await errorMessage(response));
@@ -155,10 +184,10 @@ export class BrassApi {
155
184
  }
156
185
  }
157
186
 
158
- // Upload bytes to a presigned S3 URL. Deliberately carries NO Brass headers
159
- // and no bearer: the presigned signature pins an exact header set, and a
160
- // stray `authorization` header would break it (the same rule the SDK's
161
- // `api-headers` module enforces on the document path).
187
+ // Upload bytes to the presigned storage URL the platform handed back.
188
+ // Deliberately carries NO Brass headers and no bearer: the presigned
189
+ // signature pins an exact header set, so a stray `authorization` header
190
+ // invalidates it and the upload is refused.
162
191
  export async function putPresigned(
163
192
  url: string,
164
193
  bytes: Uint8Array,
@@ -173,9 +202,10 @@ export async function putPresigned(
173
202
  // `ArrayBufferLike` is sound and satisfies fetch's `BufferSource`
174
203
  // (which pins `ArrayBufferView<ArrayBuffer>`) across lib configs.
175
204
  body: bytes as Uint8Array<ArrayBuffer>,
205
+ signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
176
206
  });
177
207
  } catch (cause) {
178
- throw new BrassApiError(0, `Network error uploading the bundle: ${String(cause)}`);
208
+ throw networkError('uploading the bundle', cause);
179
209
  }
180
210
  if (!response.ok) {
181
211
  throw new BrassApiError(response.status, `Bundle upload failed (${response.status})`);
package/src/args.ts CHANGED
@@ -7,13 +7,16 @@ export interface ParsedArgs {
7
7
  // A flag present with no value (`--json`) stores `true`; a valued flag
8
8
  // (`--doc abc` / `--doc=abc`) stores the string.
9
9
  flags: Record<string, string | true>;
10
+ // Boolean flags written with a value (`--json=false`). A boolean flag is on
11
+ // by being present, so any value behind one contradicts the flag itself, and
12
+ // the reading a caller expects is the opposite of the one it would get.
13
+ valuedBooleans: string[];
10
14
  }
11
15
 
12
- // Flags that never take a value, so `brass publish --yes ./dist` parses
13
- // `./dist` as a positional rather than the value of `--yes`.
16
+ // Flags that never take a value, so `brass agents pull --stdout ./dir` parses
17
+ // `./dir` as a positional rather than the value of `--stdout`.
14
18
  const BOOLEAN_FLAGS = new Set([
15
19
  'json',
16
- 'yes',
17
20
  'help',
18
21
  'version',
19
22
  'stdout',
@@ -52,18 +55,102 @@ export function unknownFlags(parsed: ParsedArgs): string[] {
52
55
  return Object.keys(parsed.flags).filter((name) => !KNOWN_FLAGS.has(name));
53
56
  }
54
57
 
58
+ // The flags every command reads, whichever it is: where to talk to and what
59
+ // to print.
60
+ const COMMON_FLAGS = [
61
+ 'api-url',
62
+ 'auth-url',
63
+ 'dashboard-url',
64
+ 'help',
65
+ 'json',
66
+ 'version',
67
+ ] as const;
68
+
69
+ // What each command reads beyond those, and how many positionals it takes
70
+ // (the command word included, so `publish [dir]` is 2). A flag one command
71
+ // reads is not thereby a flag of the next: `--wait` is the sign-in poll's
72
+ // deadline and `publish` has its own, so `brass publish --wait 600` waits
73
+ // exactly as long as it would have without the flag. Refusing it names the
74
+ // mistake, where accepting it leaves the caller believing they set something.
75
+ const COMMAND_ARGS: Record<string, { flags: readonly string[]; positionals: number }> = {
76
+ login: { flags: ['check', 'new', 'start', 'wait'], positionals: 1 },
77
+ logout: { flags: [], positionals: 1 },
78
+ status: { flags: ['app', 'manifest', 'token'], positionals: 2 },
79
+ publish: {
80
+ flags: [
81
+ 'app',
82
+ 'client-token',
83
+ 'gate',
84
+ 'manifest',
85
+ 'name',
86
+ 'org',
87
+ 'slug',
88
+ 'token',
89
+ 'visibility',
90
+ ],
91
+ positionals: 2,
92
+ },
93
+ schema: { flags: ['doc', 'out', 'token'], positionals: 2 },
94
+ agents: { flags: ['org', 'out', 'stdout', 'token'], positionals: 2 },
95
+ whoami: { flags: ['token'], positionals: 1 },
96
+ };
97
+
98
+ // The flags `command` does not read, out of the ones this CLI knows. A name
99
+ // the CLI knows nowhere is `unknownFlags`' answer and stays there, so a
100
+ // misspelling is reported as one rather than as a flag of another command.
101
+ // A command this CLI does not have answers for itself.
102
+ export function flagsNotReadBy(parsed: ParsedArgs, command: string): string[] {
103
+ const spec = COMMAND_ARGS[command];
104
+ if (spec === undefined) return [];
105
+ const reads = new Set<string>([...COMMON_FLAGS, ...spec.flags]);
106
+ return Object.keys(parsed.flags).filter(
107
+ (name) => KNOWN_FLAGS.has(name) && !reads.has(name),
108
+ );
109
+ }
110
+
111
+ // The positionals past the ones `command` reads. An ignored one is the same
112
+ // silence a flag no command reads leaves: `brass publish out dist` publishes
113
+ // `out`, and the caller who meant `dist` is told nothing.
114
+ export function extraPositionals(parsed: ParsedArgs, command: string): string[] {
115
+ const spec = COMMAND_ARGS[command];
116
+ if (spec === undefined) return [];
117
+ return parsed.positionals.slice(spec.positionals);
118
+ }
119
+
120
+ // The flags of `command` that carry a value, so a bare one is the caller
121
+ // naming something the run then resolves a default for. `--wait` is absent
122
+ // here because a bare `--wait` is its own answer (the default deadline).
123
+ export function valueFlagsOf(command: string): string[] {
124
+ const spec = COMMAND_ARGS[command];
125
+ if (spec === undefined) return [];
126
+ return [...COMMON_FLAGS, ...spec.flags].filter(
127
+ (name) => !BOOLEAN_FLAGS.has(name) && name !== 'wait',
128
+ );
129
+ }
130
+
55
131
  // Which of `names` were given without a value (`--api-url --json`, or
56
132
  // `--api-url` last on the line). The parser stores those as `true` and
57
133
  // `stringFlag` reads that as absent, so an origin flag in this state resolves
58
134
  // the production default while the command line names another stack. That is
59
135
  // the same silent retarget `unknownFlags` catches for a misspelled name.
136
+ //
137
+ // An EMPTY value counts, and it is the shape a caller actually reaches: a
138
+ // script writing `--slug "$SLUG"` against an unset variable passes the flag
139
+ // with nothing behind it. That value is not absent the way a bare flag is, so
140
+ // it resolves no default and travels to the server as an empty string, where
141
+ // the mistake is reported (if at all) in the server's vocabulary rather than
142
+ // as the missing value it is.
60
143
  export function valuelessFlags(parsed: ParsedArgs, names: readonly string[]): string[] {
61
- return names.filter((name) => parsed.flags[name] === true);
144
+ return names.filter((name) => {
145
+ const value = parsed.flags[name];
146
+ return value === true || value === '';
147
+ });
62
148
  }
63
149
 
64
150
  export function parseArgs(argv: readonly string[]): ParsedArgs {
65
151
  const positionals: string[] = [];
66
152
  const flags: Record<string, string | true> = {};
153
+ const valuedBooleans: string[] = [];
67
154
  for (let i = 0; i < argv.length; i++) {
68
155
  const arg = argv[i];
69
156
  if (arg === undefined) continue;
@@ -71,7 +158,16 @@ export function parseArgs(argv: readonly string[]): ParsedArgs {
71
158
  const body = arg.slice(2);
72
159
  const eq = body.indexOf('=');
73
160
  if (eq !== -1) {
74
- flags[body.slice(0, eq)] = body.slice(eq + 1);
161
+ const name = body.slice(0, eq);
162
+ if (BOOLEAN_FLAGS.has(name)) {
163
+ // Recorded rather than read: `--json=false` reads as ON, which is
164
+ // the reverse of what the caller wrote, and `--stdout=false` would
165
+ // send a file's contents to stdout and write nothing.
166
+ valuedBooleans.push(name);
167
+ flags[name] = true;
168
+ continue;
169
+ }
170
+ flags[name] = body.slice(eq + 1);
75
171
  continue;
76
172
  }
77
173
  if (BOOLEAN_FLAGS.has(body)) {
@@ -89,7 +185,7 @@ export function parseArgs(argv: readonly string[]): ParsedArgs {
89
185
  positionals.push(arg);
90
186
  }
91
187
  }
92
- return { positionals, flags };
188
+ return { positionals, flags, valuedBooleans };
93
189
  }
94
190
 
95
191
  // Read a flag expected to carry a string value; a bare boolean flag (no
package/src/cli.ts CHANGED
@@ -6,7 +6,10 @@ import {
6
6
  parseArgs,
7
7
  stringFlag,
8
8
  boolFlag,
9
+ extraPositionals,
10
+ flagsNotReadBy,
9
11
  unknownFlags,
12
+ valueFlagsOf,
10
13
  valuelessFlags,
11
14
  type ParsedArgs,
12
15
  } from './args.js';
@@ -18,10 +21,15 @@ import {
18
21
  type Origins,
19
22
  type Profile,
20
23
  } from './config.js';
21
- import { readCredentialsFile, readPendingLogin, writeStoredCredential } from './store.js';
24
+ import {
25
+ readCredentialsFile,
26
+ readPendingLogin,
27
+ writePendingLogin,
28
+ writeStoredCredential,
29
+ } from './store.js';
22
30
  import { BrassApi, BrassApiError, type AppVisibility } from './api.js';
23
31
  import { serviceTokenAuth, type AuthProvider } from './auth.js';
24
- import { loginDevice, sessionAuth } from './session.js';
32
+ import { loginDevice, postDeviceCancel, postSignOut, sessionAuth } from './session.js';
25
33
  import { loginStart, loginCheck } from './login.js';
26
34
  import { createLogger, type Logger } from './log.js';
27
35
  import {
@@ -31,6 +39,7 @@ import {
31
39
  whoami,
32
40
  status,
33
41
  type CommandContext,
42
+ type CredentialKind,
34
43
  } from './commands.js';
35
44
  import { readProjectState, resolveAppId, readManifest } from './project.js';
36
45
 
@@ -47,7 +56,7 @@ Usage:
47
56
  brass login --check Check a started sign-in once; stores the session when approved.
48
57
  --wait [seconds] polls until approved (default 120s), renewing
49
58
  an expired code in place and printing the new one.
50
- brass logout Forget the stored sign-in for this environment.
59
+ brass logout End the stored sign-in for this environment, here and on the server.
51
60
  brass status [dir] Report the credential + app state and the one command to run next.
52
61
  brass publish [dir] Build output in [dir] (default: dist) is deployed to the app's hosting.
53
62
  brass schema pull --doc <docId> [--out brass-app.json]
@@ -82,8 +91,15 @@ Publish flags:
82
91
  export async function run(argv: readonly string[]): Promise<number> {
83
92
  const parsed = parseArgs(argv);
84
93
  const command = parsed.positionals[0];
85
-
86
- if (boolFlag(parsed, 'version') && command === undefined) {
94
+ // Which credential the invocation resolved, once it has. A 401 is reported
95
+ // in that credential's own vocabulary, so it stays undefined until
96
+ // `buildContext` settles it and a failure before then carries no guess.
97
+ let credentialKind: CredentialKind | undefined;
98
+
99
+ // Answered whichever command follows, like `--help` beside it: both are
100
+ // declared flags of every command (`COMMON_FLAGS`), so a caller who asks
101
+ // which version is installed gets the answer rather than a publish.
102
+ if (boolFlag(parsed, 'version')) {
87
103
  process.stdout.write(`${VERSION}\n`);
88
104
  return 0;
89
105
  }
@@ -103,6 +119,41 @@ export async function run(argv: readonly string[]): Promise<number> {
103
119
  return 1;
104
120
  }
105
121
 
122
+ // Then what THIS command reads, which is the same check one scope in: a
123
+ // flag or a positional the command ignores leaves the run differing from
124
+ // what the command line asked for, with nothing said. Judged from argv
125
+ // alone, so it answers a caller who has not signed in.
126
+ const misplaced = flagsNotReadBy(parsed, command);
127
+ if (misplaced.length > 0) {
128
+ const names = misplaced.map((n) => `--${n}`).join(', ');
129
+ process.stderr.write(
130
+ `error: ${names} ${misplaced.length === 1 ? 'is not a flag' : 'are not flags'} of \`brass ${command}\`\n\n${USAGE}`,
131
+ );
132
+ return 1;
133
+ }
134
+ const extra = extraPositionals(parsed, command);
135
+ if (extra.length > 0) {
136
+ const args = extra.map((p) => JSON.stringify(p)).join(', ');
137
+ process.stderr.write(
138
+ `error: unexpected ${extra.length === 1 ? 'argument' : 'arguments'} ${args} for \`brass ${command}\`\n\n${USAGE}`,
139
+ );
140
+ return 1;
141
+ }
142
+ if (parsed.valuedBooleans.length > 0) {
143
+ const names = parsed.valuedBooleans.map((n) => `--${n}`).join(', ');
144
+ process.stderr.write(
145
+ `error: ${names} ${parsed.valuedBooleans.length === 1 ? 'takes' : 'take'} no value\n`,
146
+ );
147
+ return 1;
148
+ }
149
+ const bare = valuelessFlags(parsed, valueFlagsOf(command));
150
+ if (bare.length > 0) {
151
+ process.stderr.write(
152
+ `error: Missing value for ${bare.map((n) => `--${n}`).join(', ')}\n`,
153
+ );
154
+ return 1;
155
+ }
156
+
106
157
  const json = boolFlag(parsed, 'json');
107
158
  const log = createLogger(json);
108
159
 
@@ -115,32 +166,63 @@ export async function run(argv: readonly string[]): Promise<number> {
115
166
  // (and the next step to obtain one) is a first-class outcome, not an error.
116
167
  if (command === 'status') return await runStatus(parsed, log);
117
168
 
118
- const ctx = await buildContext(parsed);
119
- switch (command) {
120
- case 'publish':
121
- log.result(await runPublish(ctx, parsed));
122
- return 0;
123
- case 'schema':
124
- log.result(await runSchema(ctx, parsed));
125
- return 0;
126
- case 'agents':
127
- log.result(await runAgents(ctx, parsed));
128
- return 0;
129
- case 'whoami':
130
- log.result(await whoami(ctx));
131
- return 0;
132
- default:
133
- process.stderr.write(`Unknown command "${command}".\n\n${USAGE}`);
134
- return 1;
169
+ // What the caller asked for is decided from argv alone, so it is decided
170
+ // BEFORE a credential is resolved. A misspelt command, a missing
171
+ // subcommand and an invalid flag value are all answerable without one, and
172
+ // resolving the credential first answers every one of them with "No
173
+ // credential. Run `brass login`": the caller re-authenticates over a typo,
174
+ // and only a caller who already has a credential is ever shown the message
175
+ // naming the real mistake.
176
+ if (!isCredentialedCommand(command)) {
177
+ process.stderr.write(`Unknown command "${command}".\n\n${USAGE}`);
178
+ return 1;
135
179
  }
180
+ const plan = planCommand(command, parsed);
181
+
182
+ const ctx = await buildContext(parsed);
183
+ credentialKind = ctx.credentialKind;
184
+ log.result(await plan(ctx));
185
+ return 0;
136
186
  } catch (err) {
137
- process.stderr.write(`${formatError(err)}\n`);
187
+ process.stderr.write(`${formatError(err, credentialKind)}\n`);
138
188
  return 1;
139
189
  }
140
190
  }
141
191
 
142
192
  type Context = CommandContext;
143
193
 
194
+ // The commands that need a resolved credential; `login` / `logout` / `status`
195
+ // are answered above without one.
196
+ const CREDENTIALED_COMMANDS = ['publish', 'schema', 'agents', 'whoami'] as const;
197
+ type CredentialedCommand = (typeof CREDENTIALED_COMMANDS)[number];
198
+
199
+ function isCredentialedCommand(value: string): value is CredentialedCommand {
200
+ return (CREDENTIALED_COMMANDS as readonly string[]).includes(value);
201
+ }
202
+
203
+ // The work a command will do once it has a credential.
204
+ type CommandPlan = (ctx: Context) => Promise<unknown>;
205
+
206
+ // Resolve argv to that work, validating everything argv alone decides and
207
+ // throwing on a shape the caller got wrong. Splitting the plan from the run is
208
+ // what lets the shape be judged before a credential is resolved.
209
+ function planCommand(command: CredentialedCommand, parsed: ParsedArgs): CommandPlan {
210
+ switch (command) {
211
+ case 'publish':
212
+ return planPublish(parsed);
213
+ case 'schema':
214
+ return planSchema(parsed);
215
+ case 'agents':
216
+ return planAgents(parsed);
217
+ case 'whoami':
218
+ return (ctx): Promise<unknown> => whoami(ctx);
219
+ default: {
220
+ const exhaustive: never = command;
221
+ throw new Error(`Unhandled command "${String(exhaustive)}"`);
222
+ }
223
+ }
224
+ }
225
+
144
226
  interface Base {
145
227
  origins: Origins;
146
228
  profile: Profile;
@@ -226,6 +308,7 @@ async function runLogin(parsed: ParsedArgs, log: Logger): Promise<number> {
226
308
  profile,
227
309
  log,
228
310
  ...(boolFlag(parsed, 'new') ? { force: true } : {}),
311
+ ...(waitSeconds !== undefined ? { resultFollows: true } : {}),
229
312
  });
230
313
  // `--start --wait` is the whole sign-in in one command: relay the code it
231
314
  // prints, then it holds for the approval up to the deadline.
@@ -238,18 +321,55 @@ async function runLogin(parsed: ParsedArgs, log: Logger): Promise<number> {
238
321
  // The RFC 8628 device grant: open the approval page (code prefilled), print
239
322
  // the URL + code as a fallback for a headless box, and poll until approval.
240
323
  const result = await loginDevice({ authBaseUrl: origins.authBaseUrl });
241
- await writeStoredCredential(profile, { session: { sid: result.sessionToken } });
324
+ await writeStoredCredential(profile, {
325
+ session: { sid: result.sessionToken, authBaseUrl: origins.authBaseUrl },
326
+ });
242
327
  log.success(result.email ? `Signed in as ${result.email}.` : 'Signed in.');
243
328
  log.result({ signed_in: true, ...(result.email !== undefined ? { email: result.email } : {}) });
244
329
  return 0;
245
330
  }
246
331
 
247
332
  async function runLogout(parsed: ParsedArgs, log: Logger): Promise<number> {
248
- const { profile } = resolveBase(parsed);
249
- await writeStoredCredential(profile, null);
250
- log.success('Signed out.');
251
- log.result({ signed_out: true });
252
- return 0;
333
+ const { origins, profile } = resolveBase(parsed);
334
+
335
+ // Revoke on the server first, while the pointer is still readable. A
336
+ // service token belongs to an organization and is revoked in the dashboard,
337
+ // so only a stored session has anything to end here. The session is revoked
338
+ // on the auth origin it was minted on: profiles key on the api host, so the
339
+ // invocation's own auth flag can name a different stack, where an unknown
340
+ // pointer reads as already-signed-out and the real session stays live.
341
+ const file = await readCredentialsFile();
342
+ const session = file?.credentials[profile]?.session;
343
+ const signedOut =
344
+ session === undefined
345
+ ? true
346
+ : await postSignOut(session.authBaseUrl ?? origins.authBaseUrl, session.sid);
347
+
348
+ // A started sign-in is redeemable by whoever holds the device code, and a
349
+ // human may still approve it after this command returns, so cancelling it on
350
+ // the server is what a sign-out owes. The grant, too, is cancelled on the
351
+ // auth origin that minted it.
352
+ const pending = await readPendingLogin(profile);
353
+ const cancelled =
354
+ pending === null ? true : await postDeviceCancel(pending.authBaseUrl, pending.deviceCode);
355
+
356
+ // Each record is dropped only once its server side is revoked. The record
357
+ // is the one handle that can name the session or grant to the server, so a
358
+ // failed delivery keeps it, which is what makes the retry this command
359
+ // recommends able to revoke anything.
360
+ if (signedOut) await writeStoredCredential(profile, null);
361
+ if (cancelled) await writePendingLogin(profile, null);
362
+ const revoked = signedOut && cancelled;
363
+
364
+ if (revoked) {
365
+ log.success('Signed out.');
366
+ } else {
367
+ log.warn(
368
+ '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.',
369
+ );
370
+ }
371
+ log.result({ signed_out: revoked, revoked });
372
+ return revoked ? 0 : 1;
253
373
  }
254
374
 
255
375
  async function runStatus(parsed: ParsedArgs, log: Logger): Promise<number> {
@@ -317,7 +437,7 @@ async function runStatus(parsed: ParsedArgs, log: Logger): Promise<number> {
317
437
  return 0;
318
438
  }
319
439
 
320
- async function runPublish(ctx: Context, parsed: ParsedArgs): Promise<unknown> {
440
+ function planPublish(parsed: ParsedArgs): CommandPlan {
321
441
  const dir = parsed.positionals[1] ?? 'dist';
322
442
  const flagApp = stringFlag(parsed, 'app');
323
443
  const envApp = process.env['BRASS_APP_ID'];
@@ -325,26 +445,29 @@ async function runPublish(ctx: Context, parsed: ParsedArgs): Promise<unknown> {
325
445
  const org = stringFlag(parsed, 'org');
326
446
  const slug = stringFlag(parsed, 'slug');
327
447
  const clientToken = stringFlag(parsed, 'client-token');
448
+ const manifestPath = stringFlag(parsed, 'manifest') ?? 'brass-app.json';
328
449
  const visibility = parseVisibilityFlag(stringFlag(parsed, 'visibility'));
329
450
  const requireAccess = parseGateFlag(stringFlag(parsed, 'gate'));
330
- const state = await readProjectState(ctx.cwd);
331
- const appId = resolveAppId({
332
- ...(flagApp !== undefined ? { flagApp } : {}),
333
- ...(envApp !== undefined ? { envApp } : {}),
334
- state,
335
- profile: ctx.profile,
336
- });
337
- return publish(ctx, {
338
- dir,
339
- manifestPath: stringFlag(parsed, 'manifest') ?? 'brass-app.json',
340
- ...(appId !== null ? { appId } : {}),
341
- ...(name !== undefined ? { name } : {}),
342
- ...(org !== undefined ? { organizationId: org } : {}),
343
- ...(clientToken !== undefined ? { clientToken } : {}),
344
- ...(slug !== undefined ? { slug } : {}),
345
- ...(visibility !== undefined ? { visibility } : {}),
346
- ...(requireAccess !== undefined ? { requireAccess } : {}),
347
- });
451
+ return async (ctx): Promise<unknown> => {
452
+ const state = await readProjectState(ctx.cwd);
453
+ const appId = resolveAppId({
454
+ ...(flagApp !== undefined ? { flagApp } : {}),
455
+ ...(envApp !== undefined ? { envApp } : {}),
456
+ state,
457
+ profile: ctx.profile,
458
+ });
459
+ return publish(ctx, {
460
+ dir,
461
+ manifestPath,
462
+ ...(appId !== null ? { appId } : {}),
463
+ ...(name !== undefined ? { name } : {}),
464
+ ...(org !== undefined ? { organizationId: org } : {}),
465
+ ...(clientToken !== undefined ? { clientToken } : {}),
466
+ ...(slug !== undefined ? { slug } : {}),
467
+ ...(visibility !== undefined ? { visibility } : {}),
468
+ ...(requireAccess !== undefined ? { requireAccess } : {}),
469
+ });
470
+ };
348
471
  }
349
472
 
350
473
  // How long `--wait` holds for the approval: bare `--wait` takes the default,
@@ -386,33 +509,48 @@ function parseGateFlag(value: string | undefined): boolean | undefined {
386
509
  return value === 'on';
387
510
  }
388
511
 
389
- async function runSchema(ctx: Context, parsed: ParsedArgs): Promise<unknown> {
390
- const sub = parsed.positionals[1];
391
- if (sub === 'pull') {
392
- const docId = stringFlag(parsed, 'doc');
393
- if (docId === undefined) throw new Error('brass schema pull requires --doc <docId>');
394
- return schemaPull(ctx, { docId, outPath: stringFlag(parsed, 'out') ?? 'brass-app.json' });
512
+ function planSchema(parsed: ParsedArgs): CommandPlan {
513
+ if (parsed.positionals[1] !== 'pull') {
514
+ throw new Error('Usage: brass schema pull --doc <docId> [--out brass-app.json]');
395
515
  }
396
- throw new Error('Usage: brass schema pull --doc <docId> [--out brass-app.json]');
516
+ const docId = stringFlag(parsed, 'doc');
517
+ if (docId === undefined) throw new Error('brass schema pull requires --doc <docId>');
518
+ const outPath = stringFlag(parsed, 'out') ?? 'brass-app.json';
519
+ return (ctx): Promise<unknown> => schemaPull(ctx, { docId, outPath });
397
520
  }
398
521
 
399
- async function runAgents(ctx: Context, parsed: ParsedArgs): Promise<unknown> {
522
+ function planAgents(parsed: ParsedArgs): CommandPlan {
400
523
  if (parsed.positionals[1] !== 'pull') {
401
524
  throw new Error(
402
525
  'Usage: brass agents pull [--out AGENTS.md | --stdout] [--org <organizationId>]',
403
526
  );
404
527
  }
528
+ // Both flags claim stdout: `--stdout` puts the instructions there verbatim,
529
+ // `--json` the result object. Together they interleave two payloads on one
530
+ // stream, so a caller parsing either reads the other's bytes as part of it.
531
+ if (boolFlag(parsed, 'stdout') && boolFlag(parsed, 'json')) {
532
+ throw new Error(
533
+ 'Pass one of --stdout or --json: both write to stdout, so together neither is parseable.',
534
+ );
535
+ }
405
536
  const org = stringFlag(parsed, 'org');
406
- return agentsPull(ctx, {
537
+ const options = {
407
538
  outPath: stringFlag(parsed, 'out') ?? 'AGENTS.md',
408
539
  ...(boolFlag(parsed, 'stdout') ? { stdout: true } : {}),
409
540
  ...(org !== undefined ? { organizationId: org } : {}),
410
- });
541
+ };
542
+ return (ctx): Promise<unknown> => agentsPull(ctx, options);
411
543
  }
412
544
 
413
- function formatError(err: unknown): string {
545
+ // Render a failure for the terminal, naming the fix for a 401 in the
546
+ // vocabulary of the credential that was actually rejected. A session's
547
+ // refusal already arrives carrying `brass login` (the refresh authors it), so
548
+ // appending a service-token hint there tells the caller to check an
549
+ // environment variable they never set, next to the sentence naming the real
550
+ // fix. `status` classifies the same 401 the same way.
551
+ function formatError(err: unknown, credentialKind?: CredentialKind): string {
414
552
  if (err instanceof BrassApiError) {
415
- if (err.status === 401) {
553
+ if (err.status === 401 && credentialKind !== 'session') {
416
554
  return `error: ${err.message} (the credential was rejected; check BRASS_SERVICE_TOKEN or --token)`;
417
555
  }
418
556
  return `error: ${err.message}`;