@brass-build/cli 0.1.0 → 0.2.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/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.
@@ -245,10 +328,38 @@ async function runLogin(parsed: ParsedArgs, log: Logger): Promise<number> {
245
328
  }
246
329
 
247
330
  async function runLogout(parsed: ParsedArgs, log: Logger): Promise<number> {
248
- const { profile } = resolveBase(parsed);
331
+ const { origins, profile } = resolveBase(parsed);
332
+
333
+ // Revoke on the server first, while the pointer is still readable. A
334
+ // service token belongs to an organization and is revoked in the dashboard,
335
+ // so only a stored session has anything to end here.
336
+ const file = await readCredentialsFile();
337
+ const stored = file?.credentials[profile]?.session?.sid;
338
+ const signedOut = stored === undefined ? true : await postSignOut(origins.authBaseUrl, stored);
339
+
340
+ // A started sign-in is redeemable by whoever holds the device code, and a
341
+ // 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.
344
+ const pending = await readPendingLogin(profile);
345
+ const cancelled =
346
+ pending === null ? true : await postDeviceCancel(origins.authBaseUrl, pending.deviceCode);
347
+
249
348
  await writeStoredCredential(profile, null);
250
- log.success('Signed out.');
251
- log.result({ signed_out: true });
349
+ await writePendingLogin(profile, null);
350
+ const revoked = signedOut && cancelled;
351
+
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
+ if (revoked) {
356
+ log.success('Signed out.');
357
+ } 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.',
360
+ );
361
+ }
362
+ log.result({ signed_out: true, revoked });
252
363
  return 0;
253
364
  }
254
365
 
@@ -317,7 +428,7 @@ async function runStatus(parsed: ParsedArgs, log: Logger): Promise<number> {
317
428
  return 0;
318
429
  }
319
430
 
320
- async function runPublish(ctx: Context, parsed: ParsedArgs): Promise<unknown> {
431
+ function planPublish(parsed: ParsedArgs): CommandPlan {
321
432
  const dir = parsed.positionals[1] ?? 'dist';
322
433
  const flagApp = stringFlag(parsed, 'app');
323
434
  const envApp = process.env['BRASS_APP_ID'];
@@ -325,26 +436,29 @@ async function runPublish(ctx: Context, parsed: ParsedArgs): Promise<unknown> {
325
436
  const org = stringFlag(parsed, 'org');
326
437
  const slug = stringFlag(parsed, 'slug');
327
438
  const clientToken = stringFlag(parsed, 'client-token');
439
+ const manifestPath = stringFlag(parsed, 'manifest') ?? 'brass-app.json';
328
440
  const visibility = parseVisibilityFlag(stringFlag(parsed, 'visibility'));
329
441
  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
- });
442
+ return async (ctx): Promise<unknown> => {
443
+ const state = await readProjectState(ctx.cwd);
444
+ const appId = resolveAppId({
445
+ ...(flagApp !== undefined ? { flagApp } : {}),
446
+ ...(envApp !== undefined ? { envApp } : {}),
447
+ state,
448
+ profile: ctx.profile,
449
+ });
450
+ return publish(ctx, {
451
+ dir,
452
+ manifestPath,
453
+ ...(appId !== null ? { appId } : {}),
454
+ ...(name !== undefined ? { name } : {}),
455
+ ...(org !== undefined ? { organizationId: org } : {}),
456
+ ...(clientToken !== undefined ? { clientToken } : {}),
457
+ ...(slug !== undefined ? { slug } : {}),
458
+ ...(visibility !== undefined ? { visibility } : {}),
459
+ ...(requireAccess !== undefined ? { requireAccess } : {}),
460
+ });
461
+ };
348
462
  }
349
463
 
350
464
  // How long `--wait` holds for the approval: bare `--wait` takes the default,
@@ -386,33 +500,48 @@ function parseGateFlag(value: string | undefined): boolean | undefined {
386
500
  return value === 'on';
387
501
  }
388
502
 
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' });
503
+ function planSchema(parsed: ParsedArgs): CommandPlan {
504
+ if (parsed.positionals[1] !== 'pull') {
505
+ throw new Error('Usage: brass schema pull --doc <docId> [--out brass-app.json]');
395
506
  }
396
- throw new Error('Usage: brass schema pull --doc <docId> [--out brass-app.json]');
507
+ const docId = stringFlag(parsed, 'doc');
508
+ 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 });
397
511
  }
398
512
 
399
- async function runAgents(ctx: Context, parsed: ParsedArgs): Promise<unknown> {
513
+ function planAgents(parsed: ParsedArgs): CommandPlan {
400
514
  if (parsed.positionals[1] !== 'pull') {
401
515
  throw new Error(
402
516
  'Usage: brass agents pull [--out AGENTS.md | --stdout] [--org <organizationId>]',
403
517
  );
404
518
  }
519
+ // Both flags claim stdout: `--stdout` puts the instructions there verbatim,
520
+ // `--json` the result object. Together they interleave two payloads on one
521
+ // stream, so a caller parsing either reads the other's bytes as part of it.
522
+ if (boolFlag(parsed, 'stdout') && boolFlag(parsed, 'json')) {
523
+ throw new Error(
524
+ 'Pass one of --stdout or --json: both write to stdout, so together neither is parseable.',
525
+ );
526
+ }
405
527
  const org = stringFlag(parsed, 'org');
406
- return agentsPull(ctx, {
528
+ const options = {
407
529
  outPath: stringFlag(parsed, 'out') ?? 'AGENTS.md',
408
530
  ...(boolFlag(parsed, 'stdout') ? { stdout: true } : {}),
409
531
  ...(org !== undefined ? { organizationId: org } : {}),
410
- });
532
+ };
533
+ return (ctx): Promise<unknown> => agentsPull(ctx, options);
411
534
  }
412
535
 
413
- function formatError(err: unknown): string {
536
+ // Render a failure for the terminal, naming the fix for a 401 in the
537
+ // vocabulary of the credential that was actually rejected. A session's
538
+ // refusal already arrives carrying `brass login` (the refresh authors it), so
539
+ // appending a service-token hint there tells the caller to check an
540
+ // environment variable they never set, next to the sentence naming the real
541
+ // fix. `status` classifies the same 401 the same way.
542
+ function formatError(err: unknown, credentialKind?: CredentialKind): string {
414
543
  if (err instanceof BrassApiError) {
415
- if (err.status === 401) {
544
+ if (err.status === 401 && credentialKind !== 'session') {
416
545
  return `error: ${err.message} (the credential was rejected; check BRASS_SERVICE_TOKEN or --token)`;
417
546
  }
418
547
  return `error: ${err.message}`;
package/src/commands.ts CHANGED
@@ -109,7 +109,7 @@ export async function publish(ctx: CommandContext, opts: PublishOptions): Promis
109
109
  if (opts.visibility !== undefined) {
110
110
  await ensureVisibility(ctx, appId, opts.visibility, resolved.detail?.visibility);
111
111
  }
112
- const hosting = await ensureHosting(ctx, appId, opts.slug);
112
+ const hosting = await ensureHosting(ctx, appId, opts.slug, opts.requireAccess);
113
113
  if (opts.requireAccess !== undefined) {
114
114
  await ensureGate(ctx, appId, opts.requireAccess, hosting);
115
115
  }
@@ -126,7 +126,12 @@ export async function publish(ctx: CommandContext, opts: PublishOptions): Promis
126
126
  // of a couple of reads instead of an upload and a poll loop.
127
127
  const unchanged = await activeVersionMatches(ctx, appId, hash);
128
128
  if (unchanged !== null) {
129
- const status = await ctx.api.get<HostingStatus>(`/apps/${encodeURIComponent(appId)}/hosting`);
129
+ const status = await awaitGateSettled(
130
+ ctx,
131
+ appId,
132
+ await ctx.api.get<HostingStatus>(`/apps/${encodeURIComponent(appId)}/hosting`),
133
+ opts.sleep ?? realSleep,
134
+ );
130
135
  ctx.log.success(`Already up to date${status.url ? `: ${status.url}` : ''}`);
131
136
  // Refreshed here too, so publishing twice says the same thing both times.
132
137
  // A publisher acting on a warning re-runs publish to check, and a signal
@@ -164,7 +169,12 @@ export async function publish(ctx: CommandContext, opts: PublishOptions): Promis
164
169
  // schema missing `family`) right here instead of silently later.
165
170
  const warnings = await refreshCapabilities(ctx, appId);
166
171
 
167
- const status = await ctx.api.get<HostingStatus>(`/apps/${encodeURIComponent(appId)}/hosting`);
172
+ const status = await awaitGateSettled(
173
+ ctx,
174
+ appId,
175
+ await ctx.api.get<HostingStatus>(`/apps/${encodeURIComponent(appId)}/hosting`),
176
+ opts.sleep ?? realSleep,
177
+ );
168
178
  if (status.url) ctx.log.success(`Deployed: ${status.url}`);
169
179
  return { app_id: appId, version_id: version.version_id, url: status.url, warnings };
170
180
  }
@@ -223,14 +233,13 @@ async function ensureVisibility(
223
233
  }
224
234
 
225
235
  // Converge the hosted load gate to `want` (`true` = gated to the audience,
226
- // `false` = world-loadable). Always PATCH, even when the DDB flag already
227
- // reads `want`: the PATCH is what reaches the server-side reconcile that
228
- // repairs a wedged edge marker (a prior toggle whose flag write landed but
229
- // whose marker write lost every ETag race leaves the flag reading correct
230
- // while the marker disagrees). Skipping the PATCH on a matching flag would
231
- // leave such a gate wedged forever, since every later publish would skip it
232
- // too. `status` is the state `ensureHosting` just observed, used only to word
233
- // the log line. (`require_access` absent === off.)
236
+ // `false` = world-loadable). Always PATCH, even when the reported state
237
+ // already reads `want`: the PATCH is what asks the platform to re-settle a
238
+ // gate whose reported and served states have drifted apart, which reading the
239
+ // reported one alone cannot detect. Skipping it on a match would leave such an
240
+ // app stuck, since every later publish would skip it too. `status` is the
241
+ // state `ensureHosting` just observed, used only to word the log line.
242
+ // (`require_access` absent === off.)
234
243
  async function ensureGate(
235
244
  ctx: CommandContext,
236
245
  appId: string,
@@ -250,15 +259,22 @@ async function ensureGate(
250
259
  }
251
260
  }
252
261
 
262
+ // Enable hosting when the app is not hosted yet, at the load-gate state the
263
+ // publish wants. Stating the gate here rather than leaving it to the PATCH
264
+ // below settles it in one step: a new slot is gated by default, so a publish
265
+ // that wants a world-loadable one would otherwise turn the gate on and
266
+ // straight back off, doing twice the work to reach one state.
253
267
  async function ensureHosting(
254
268
  ctx: CommandContext,
255
269
  appId: string,
256
270
  slug?: string,
271
+ requireAccess?: boolean,
257
272
  ): Promise<HostingStatus> {
258
273
  const status = await ctx.api.get<HostingStatus>(`/apps/${encodeURIComponent(appId)}/hosting`);
259
274
  if (status.enabled) return status;
260
- const body: { slug?: string } = {};
275
+ const body: { slug?: string; require_access?: boolean } = {};
261
276
  if (slug !== undefined) body.slug = slug;
277
+ if (requireAccess !== undefined) body.require_access = requireAccess;
262
278
  const enabled = await ctx.api.post<HostingStatus>(
263
279
  `/apps/${encodeURIComponent(appId)}/hosting`,
264
280
  body,
@@ -267,11 +283,42 @@ async function ensureHosting(
267
283
  return enabled;
268
284
  }
269
285
 
270
- // The currently-served version when it is `ready` and already carries
271
- // `hash`, else null. A null (no active version, a non-ready active version, or
272
- // a hash mismatch) means `publish` must upload. A first deploy has no active
273
- // version, so it always uploads; a version predating content hashing has no
274
- // recorded hash and so never matches, forcing one re-upload that self-heals.
286
+ // A published bundle is not reachable until the platform has registered the
287
+ // slot to serve it, and that registration can lag or fail on its own. Reading
288
+ // the status is what asks the platform to settle it, so a slot that is not
289
+ // ready yet is re-read a few times before publish gives up. Publishing
290
+ // reports success only once the slot will actually serve, so an unreachable
291
+ // 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;
294
+
295
+ async function awaitGateSettled(
296
+ ctx: CommandContext,
297
+ appId: string,
298
+ status: HostingStatus,
299
+ sleep: (ms: number) => Promise<void>,
300
+ ): 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;
307
+ }
308
+ // Says what is true of the SLOT, because both publish paths end here: the
309
+ // one that uploaded a new bundle and the one that found the app already
310
+ // serving this exact bundle and skipped the upload. A message naming an
311
+ // 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
+ );
316
+ }
317
+
318
+ // The currently-served version when it is `ready` and already carries `hash`,
319
+ // else null. A null (no active version, a non-ready active version, or a hash
320
+ // mismatch) means `publish` must upload. A first deploy has no active version,
321
+ // so it always uploads.
275
322
  async function activeVersionMatches(
276
323
  ctx: CommandContext,
277
324
  appId: string,
@@ -287,8 +334,8 @@ async function activeVersionMatches(
287
334
 
288
335
  // Poll until the version reaches a KNOWN terminal state (`ready` / `failed`),
289
336
  // then return it; time out otherwise. Deliberately loops while the status is
290
- // anything other than a known terminal `pending` OR any status this pinned
291
- // CLI does not recognize rather than returning on `!== 'pending'`. That way
337
+ // anything other than a known terminal (`pending`, or any status this pinned
338
+ // CLI does not recognize) rather than returning on `!== 'pending'`. That way
292
339
  // a future server status (a finer-grained non-terminal like `unpacking`, or a
293
340
  // new terminal-failure like `rejected`) is not mistaken for "done": an
294
341
  // unrecognized non-terminal keeps polling, and an unrecognized terminal