@brass-build/cli 0.1.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 (70) hide show
  1. package/AGENTS.md +170 -0
  2. package/CHANGELOG.md +12 -0
  3. package/LICENSE +21 -0
  4. package/README.md +172 -0
  5. package/dist/api.d.ts +73 -0
  6. package/dist/api.d.ts.map +1 -0
  7. package/dist/api.js +97 -0
  8. package/dist/api.js.map +1 -0
  9. package/dist/args.d.ts +10 -0
  10. package/dist/args.d.ts.map +1 -0
  11. package/dist/args.js +94 -0
  12. package/dist/args.js.map +1 -0
  13. package/dist/auth.d.ts +5 -0
  14. package/dist/auth.d.ts.map +1 -0
  15. package/dist/auth.js +12 -0
  16. package/dist/auth.js.map +1 -0
  17. package/dist/bin/brass.d.ts +3 -0
  18. package/dist/bin/brass.d.ts.map +1 -0
  19. package/dist/bin/brass.js +11 -0
  20. package/dist/bin/brass.js.map +1 -0
  21. package/dist/cli.d.ts +4 -0
  22. package/dist/cli.d.ts.map +1 -0
  23. package/dist/cli.js +364 -0
  24. package/dist/cli.js.map +1 -0
  25. package/dist/commands.d.ts +94 -0
  26. package/dist/commands.d.ts.map +1 -0
  27. package/dist/commands.js +559 -0
  28. package/dist/commands.js.map +1 -0
  29. package/dist/config.d.ts +40 -0
  30. package/dist/config.d.ts.map +1 -0
  31. package/dist/config.js +76 -0
  32. package/dist/config.js.map +1 -0
  33. package/dist/log.d.ts +9 -0
  34. package/dist/log.d.ts.map +1 -0
  35. package/dist/log.js +32 -0
  36. package/dist/log.js.map +1 -0
  37. package/dist/login.d.ts +21 -0
  38. package/dist/login.d.ts.map +1 -0
  39. package/dist/login.js +158 -0
  40. package/dist/login.js.map +1 -0
  41. package/dist/project.d.ts +32 -0
  42. package/dist/project.d.ts.map +1 -0
  43. package/dist/project.js +129 -0
  44. package/dist/project.js.map +1 -0
  45. package/dist/session.d.ts +47 -0
  46. package/dist/session.d.ts.map +1 -0
  47. package/dist/session.js +224 -0
  48. package/dist/session.js.map +1 -0
  49. package/dist/store.d.ts +17 -0
  50. package/dist/store.d.ts.map +1 -0
  51. package/dist/store.js +90 -0
  52. package/dist/store.js.map +1 -0
  53. package/dist/version.d.ts +3 -0
  54. package/dist/version.d.ts.map +1 -0
  55. package/dist/version.js +8 -0
  56. package/dist/version.js.map +1 -0
  57. package/package.json +42 -0
  58. package/src/api.ts +195 -0
  59. package/src/args.ts +107 -0
  60. package/src/auth.ts +16 -0
  61. package/src/bin/brass.ts +11 -0
  62. package/src/cli.ts +422 -0
  63. package/src/commands.ts +864 -0
  64. package/src/config.ts +132 -0
  65. package/src/log.ts +41 -0
  66. package/src/login.ts +211 -0
  67. package/src/project.ts +176 -0
  68. package/src/session.ts +319 -0
  69. package/src/store.ts +123 -0
  70. package/src/version.ts +8 -0
@@ -0,0 +1,864 @@
1
+ // The command implementations, each taking an already-authenticated
2
+ // `BrassApi` plus its inputs and returning a structured result (or throwing
3
+ // `BrassApiError` / `Error`). The bin layer wires real IO; tests drive these
4
+ // directly against a mocked client, including the full publish sequence.
5
+
6
+ import {
7
+ BrassApi,
8
+ BrassApiError,
9
+ type AppDetail,
10
+ type AppVisibility,
11
+ type HostingStatus,
12
+ type HostingUploadUrl,
13
+ type HostingVersion,
14
+ type HostingVersionStatus,
15
+ type RefreshCapabilitiesResponse,
16
+ type DocumentStreams,
17
+ type DocumentTypeSummary,
18
+ type AgentInstructionsResponse,
19
+ type OrganizationSummary,
20
+ putPresigned,
21
+ } from './api.js';
22
+ import type { Logger } from './log.js';
23
+ import type { Profile, ResolvedCredential } from './config.js';
24
+ import {
25
+ mergeSchemaIntoManifest,
26
+ readManifest,
27
+ writeManifest,
28
+ writeProjectAppId,
29
+ writeTextFile,
30
+ collectZipEntries,
31
+ contentHash,
32
+ zipEntries,
33
+ isDirectory,
34
+ type AppManifest,
35
+ } from './project.js';
36
+ import { basename } from 'node:path';
37
+
38
+ export interface CommandContext {
39
+ api: BrassApi;
40
+ cwd: string;
41
+ // The credential/state key for this invocation (`prod` / `dev` /
42
+ // `origin:<host>`).
43
+ profile: string;
44
+ // The kind of credential this invocation authenticates with. A `publish`
45
+ // create resolves the owning org from the caller's single membership for a
46
+ // human `session`; a `service` token binds to its own org server-side, so
47
+ // no org lookup is needed there.
48
+ credentialKind: CredentialKind;
49
+ log: Logger;
50
+ }
51
+
52
+ export interface PublishOptions {
53
+ // Directory of the built static bundle to upload.
54
+ dir: string;
55
+ // Resolved app id, or undefined to create a fresh app on first publish.
56
+ appId?: string;
57
+ // Name for a first-create when there is no app yet and the manifest has none.
58
+ name?: string;
59
+ // The organization a first-create app is owned by. Optional: a signed-in
60
+ // human defaults to their single org (and is asked to choose when they have
61
+ // several); a service token defaults to the token's own org. Every app is
62
+ // owned by an org, so a create always resolves one.
63
+ organizationId?: string;
64
+ // A stable idempotency key for a first create, so repeated create-from-
65
+ // scratch runs resolve the same app instead of duplicating. Overrides the
66
+ // manifest's `client_token`.
67
+ clientToken?: string;
68
+ // Set the app's visibility (default `private`). `public` publishes a
69
+ // showcase any signed-in user can list and open.
70
+ visibility?: AppVisibility;
71
+ // Desired hosted load-gate state. `true` gates the bundle behind the app's
72
+ // audience; `false` makes it world-loadable (an open showcase). Absent
73
+ // leaves the current state (the gate defaults on at first enable).
74
+ requireAccess?: boolean;
75
+ // Preferred hosting subdomain on first enable.
76
+ slug?: string;
77
+ // Path to the served manifest, read for a create `name` / `client_token`.
78
+ manifestPath: string;
79
+ // Polling knobs (defaulted); overridable so tests don't wait on wall clock.
80
+ pollAttempts?: number;
81
+ pollIntervalMs?: number;
82
+ sleep?: (ms: number) => Promise<void>;
83
+ }
84
+
85
+ export interface PublishResult {
86
+ app_id: string;
87
+ version_id: string;
88
+ url: string | null;
89
+ // What the platform read back from the published manifest and wants the
90
+ // publisher to act on (a schema missing `family`, an `x-brass-` keyword the
91
+ // installed client does not implement). Carried in the RESULT as well as on
92
+ // stderr, because `--json` is how a script or a coding agent publishes and
93
+ // parsing prose out of a log is not something to ask of them. Empty when
94
+ // the manifest reads clean.
95
+ warnings: string[];
96
+ }
97
+
98
+ const DEFAULT_POLL_ATTEMPTS = 60;
99
+ const DEFAULT_POLL_INTERVAL_MS = 2000;
100
+ const realSleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
101
+
102
+ export async function publish(ctx: CommandContext, opts: PublishOptions): Promise<PublishResult> {
103
+ if (!(await isDirectory(opts.dir))) {
104
+ throw new Error(`Publish directory not found: ${opts.dir}`);
105
+ }
106
+
107
+ const resolved = await resolveOrCreateApp(ctx, opts);
108
+ const appId = resolved.appId;
109
+ if (opts.visibility !== undefined) {
110
+ await ensureVisibility(ctx, appId, opts.visibility, resolved.detail?.visibility);
111
+ }
112
+ const hosting = await ensureHosting(ctx, appId, opts.slug);
113
+ if (opts.requireAccess !== undefined) {
114
+ await ensureGate(ctx, appId, opts.requireAccess, hosting);
115
+ }
116
+
117
+ const entries = await collectZipEntries(opts.dir);
118
+ if (Object.keys(entries).length === 0) {
119
+ throw new Error(`No files found to publish under ${opts.dir}`);
120
+ }
121
+ const hash = contentHash(entries);
122
+
123
+ // Skip the upload + unpack-wait when the app already serves this exact
124
+ // bundle. The posture reconcile above still ran, so an unchanged re-publish
125
+ // converges visibility / gate / slug and returns the live URL for the price
126
+ // of a couple of reads instead of an upload and a poll loop.
127
+ const unchanged = await activeVersionMatches(ctx, appId, hash);
128
+ if (unchanged !== null) {
129
+ const status = await ctx.api.get<HostingStatus>(`/apps/${encodeURIComponent(appId)}/hosting`);
130
+ ctx.log.success(`Already up to date${status.url ? `: ${status.url}` : ''}`);
131
+ // Refreshed here too, so publishing twice says the same thing both times.
132
+ // A publisher acting on a warning re-runs publish to check, and a signal
133
+ // that appears only on the run that happened to upload reads as fixed.
134
+ const warnings = await refreshCapabilities(ctx, appId);
135
+ return { app_id: appId, version_id: unchanged.version_id, url: status.url, warnings };
136
+ }
137
+
138
+ ctx.log.info('Packaging the bundle...');
139
+ const bundle = zipEntries(entries);
140
+
141
+ ctx.log.info('Uploading...');
142
+ const upload = await ctx.api.post<HostingUploadUrl>(
143
+ `/apps/${encodeURIComponent(appId)}/hosting/upload-url`,
144
+ { content_hash: hash },
145
+ );
146
+ await putPresigned(upload.upload_url, bundle, 'application/zip');
147
+
148
+ const completed = await ctx.api.post<HostingVersion>(
149
+ `/apps/${encodeURIComponent(appId)}/hosting/versions/${encodeURIComponent(upload.version_id)}/complete`,
150
+ {},
151
+ );
152
+
153
+ const version = await waitForVersion(ctx, appId, completed, opts);
154
+ // Only an explicit `ready` is success. `waitForVersion` returns solely on a
155
+ // terminal status (or throws on timeout), so a `!== 'ready'` here is `failed`
156
+ // (or a future terminal-failure status): treat it as a failed deploy rather
157
+ // than falling through to a success the CLI would report with exit 0.
158
+ if (version.status !== 'ready') {
159
+ throw new Error(`Deploy failed: ${version.failure_reason ?? 'unknown reason'}`);
160
+ }
161
+
162
+ // Best-effort: the platform crawls the served manifest after a deploy on
163
+ // its own, but an explicit refresh surfaces capability warnings (e.g. a
164
+ // schema missing `family`) right here instead of silently later.
165
+ const warnings = await refreshCapabilities(ctx, appId);
166
+
167
+ const status = await ctx.api.get<HostingStatus>(`/apps/${encodeURIComponent(appId)}/hosting`);
168
+ if (status.url) ctx.log.success(`Deployed: ${status.url}`);
169
+ return { app_id: appId, version_id: version.version_id, url: status.url, warnings };
170
+ }
171
+
172
+ async function resolveOrCreateApp(
173
+ ctx: CommandContext,
174
+ opts: PublishOptions,
175
+ ): Promise<{ appId: string; detail?: AppDetail }> {
176
+ if (opts.appId !== undefined) return { appId: opts.appId };
177
+ const manifest = await readManifest(opts.manifestPath);
178
+ const name = opts.name ?? manifest?.name;
179
+ if (name === undefined || name.trim() === '') {
180
+ throw new Error(
181
+ 'No app to publish to. Pass --name to create one, or --app / BRASS_APP_ID / a .brass/project.json for an existing app.',
182
+ );
183
+ }
184
+ // A `client_token` makes create idempotent, so a repeated create-from-
185
+ // scratch (ephemeral CI has no persisted project.json) resolves the same
186
+ // app rather than minting a duplicate.
187
+ const clientToken = opts.clientToken ?? manifest?.client_token;
188
+ const organizationId = await resolveCreateOrganizationId(ctx, opts.organizationId);
189
+ const body: { name: string; organization_id?: string; client_token?: string } = {
190
+ name: name.trim(),
191
+ };
192
+ if (organizationId !== undefined) body.organization_id = organizationId;
193
+ if (typeof clientToken === 'string' && clientToken.trim() !== '') {
194
+ body.client_token = clientToken.trim();
195
+ }
196
+ const app = await ctx.api.post<AppDetail>('/apps', body);
197
+ await writeProjectAppId(ctx.cwd, ctx.profile, app.app_id);
198
+ // With a key the call resolves-or-creates, so avoid asserting it was new.
199
+ ctx.log.info(
200
+ `${body.client_token !== undefined ? 'Using' : 'Created'} app ${app.app_id} (saved to .brass/project.json)`,
201
+ );
202
+ return { appId: app.app_id, detail: app };
203
+ }
204
+
205
+ // Set the app's visibility when `--visibility` asks for one it does not
206
+ // already hold. `current` is the value from the create/resolve response when
207
+ // known; otherwise the app is read to avoid a redundant write (and the audit
208
+ // event a no-op change would emit) on every re-publish.
209
+ async function ensureVisibility(
210
+ ctx: CommandContext,
211
+ appId: string,
212
+ want: AppVisibility,
213
+ current: AppVisibility | undefined,
214
+ ): Promise<void> {
215
+ let have = current;
216
+ if (have === undefined) {
217
+ const detail = await ctx.api.get<AppDetail>(`/apps/${encodeURIComponent(appId)}`);
218
+ have = detail.visibility;
219
+ }
220
+ if (have === want) return;
221
+ await ctx.api.patch<AppDetail>(`/apps/${encodeURIComponent(appId)}`, { visibility: want });
222
+ ctx.log.info(`Set visibility to ${want}.`);
223
+ }
224
+
225
+ // 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.)
234
+ async function ensureGate(
235
+ ctx: CommandContext,
236
+ appId: string,
237
+ want: boolean,
238
+ status: HostingStatus,
239
+ ): Promise<void> {
240
+ const changed = (status.require_access === true) !== want;
241
+ await ctx.api.patch<HostingStatus>(`/apps/${encodeURIComponent(appId)}/hosting`, {
242
+ require_access: want,
243
+ });
244
+ if (changed) {
245
+ ctx.log.info(
246
+ want ? 'Enabled the load gate.' : 'Disabled the load gate (bundle is world-loadable).',
247
+ );
248
+ } else {
249
+ ctx.log.info(want ? 'Load gate is on.' : 'Load gate is off (bundle is world-loadable).');
250
+ }
251
+ }
252
+
253
+ async function ensureHosting(
254
+ ctx: CommandContext,
255
+ appId: string,
256
+ slug?: string,
257
+ ): Promise<HostingStatus> {
258
+ const status = await ctx.api.get<HostingStatus>(`/apps/${encodeURIComponent(appId)}/hosting`);
259
+ if (status.enabled) return status;
260
+ const body: { slug?: string } = {};
261
+ if (slug !== undefined) body.slug = slug;
262
+ const enabled = await ctx.api.post<HostingStatus>(
263
+ `/apps/${encodeURIComponent(appId)}/hosting`,
264
+ body,
265
+ );
266
+ if (enabled.slug) ctx.log.info(`Enabled hosting at ${enabled.slug}`);
267
+ return enabled;
268
+ }
269
+
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.
275
+ async function activeVersionMatches(
276
+ ctx: CommandContext,
277
+ appId: string,
278
+ hash: string,
279
+ ): Promise<HostingVersion | null> {
280
+ const { versions } = await ctx.api.get<{ versions: HostingVersion[] }>(
281
+ `/apps/${encodeURIComponent(appId)}/hosting/versions`,
282
+ );
283
+ const active = versions.find((v) => v.active);
284
+ if (active && active.status === 'ready' && active.content_hash === hash) return active;
285
+ return null;
286
+ }
287
+
288
+ // Poll until the version reaches a KNOWN terminal state (`ready` / `failed`),
289
+ // 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
292
+ // a future server status (a finer-grained non-terminal like `unpacking`, or a
293
+ // new terminal-failure like `rejected`) is not mistaken for "done": an
294
+ // unrecognized non-terminal keeps polling, and an unrecognized terminal
295
+ // failure ends as a timeout (a non-zero exit) instead of a false success.
296
+ function isTerminalVersionStatus(status: HostingVersionStatus): boolean {
297
+ return status === 'ready' || status === 'failed';
298
+ }
299
+
300
+ async function waitForVersion(
301
+ ctx: CommandContext,
302
+ appId: string,
303
+ completed: HostingVersion,
304
+ opts: PublishOptions,
305
+ ): Promise<HostingVersion> {
306
+ if (isTerminalVersionStatus(completed.status)) return completed;
307
+ const attempts = opts.pollAttempts ?? DEFAULT_POLL_ATTEMPTS;
308
+ const intervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
309
+ const sleep = opts.sleep ?? realSleep;
310
+ for (let i = 0; i < attempts; i++) {
311
+ await sleep(intervalMs);
312
+ const { versions } = await ctx.api.get<{ versions: HostingVersion[] }>(
313
+ `/apps/${encodeURIComponent(appId)}/hosting/versions`,
314
+ );
315
+ const current = versions.find((v) => v.version_id === completed.version_id);
316
+ if (current && isTerminalVersionStatus(current.status)) return current;
317
+ }
318
+ throw new Error('Timed out waiting for the deploy to finish unpacking.');
319
+ }
320
+
321
+ async function refreshCapabilities(
322
+ ctx: CommandContext,
323
+ appId: string,
324
+ ): Promise<string[]> {
325
+ try {
326
+ const refreshed = await ctx.api.post<RefreshCapabilitiesResponse>(
327
+ `/apps/${encodeURIComponent(appId)}/capabilities/refresh`,
328
+ {},
329
+ );
330
+ const warnings = refreshed.warnings ?? [];
331
+ for (const warning of warnings) ctx.log.warn(warning);
332
+ return warnings;
333
+ } catch (err) {
334
+ // A capability refresh is a convenience read on top of the deploy; the
335
+ // platform re-crawls the manifest regardless, so a transient failure
336
+ // here must not fail an otherwise-successful publish.
337
+ const detail = err instanceof BrassApiError ? err.message : String(err);
338
+ const warning = `Could not read capabilities yet: ${detail}`;
339
+ ctx.log.warn(warning);
340
+ return [warning];
341
+ }
342
+ }
343
+
344
+ export interface SchemaPullOptions {
345
+ docId: string;
346
+ // Manifest to write the pulled `schema` into (created if absent).
347
+ outPath: string;
348
+ }
349
+
350
+ export interface SchemaPullResult {
351
+ family: string;
352
+ out_path: string;
353
+ }
354
+
355
+ export async function schemaPull(
356
+ ctx: CommandContext,
357
+ opts: SchemaPullOptions,
358
+ ): Promise<SchemaPullResult> {
359
+ const docPath = `/documents/${encodeURIComponent(opts.docId)}`;
360
+ const [held, summary] = await Promise.all([
361
+ ctx.api.get<DocumentStreams>(`${docPath}/schema`),
362
+ ctx.api.get<DocumentTypeSummary>(docPath),
363
+ ]);
364
+ // An app manifest is a CLAIM the developer is about to author: a family
365
+ // plus a body per stream. The document holds no such thing, only streams,
366
+ // so the manifest is ASSEMBLED here rather than read off the document.
367
+ // Each published body goes in verbatim; the family is the document's own
368
+ // type, the same token open-routing matches on. A stream the document
369
+ // holds with no published contract is left out: a manifest entry is a
370
+ // shape the app declares it works with, and there is no shape to declare.
371
+ const family = summary.schema_type ?? '';
372
+ if (family === '') {
373
+ throw new Error(
374
+ `Document ${opts.docId} carries no type, so there is no family to declare. Pull from a document an importer or app produced.`,
375
+ );
376
+ }
377
+ const streams: Record<string, Record<string, unknown>> = {};
378
+ for (const entry of held.streams) {
379
+ if (entry.schema !== undefined) streams[entry.name] = entry.schema;
380
+ }
381
+ const manifest: AppManifest = (await readManifest(opts.outPath)) ?? {};
382
+ await writeManifest(
383
+ opts.outPath,
384
+ mergeSchemaIntoManifest(manifest, { family, streams }),
385
+ );
386
+ ctx.log.success(`Wrote the "${family}" schema to ${opts.outPath}`);
387
+ return { family, out_path: opts.outPath };
388
+ }
389
+
390
+ export interface AgentsPullOptions {
391
+ // The org whose instructions to pull. Optional: when the caller belongs to
392
+ // exactly one org it is resolved automatically; otherwise it is required.
393
+ organizationId?: string;
394
+ // File to write the instructions to (default `AGENTS.md`). Ignored when
395
+ // `stdout` is set.
396
+ outPath: string;
397
+ // Print the body to stdout instead of writing a file: the always-fetch
398
+ // path, where an agent reads the instructions inline and caches nothing.
399
+ // Status still goes to stderr, so stdout carries only the instructions.
400
+ stdout?: boolean;
401
+ }
402
+
403
+ export interface AgentsPullResult {
404
+ organization_id: string;
405
+ // The file written, or null in `--stdout` mode (nothing was written).
406
+ out_path: string | null;
407
+ bytes: number;
408
+ // True when the org has no instructions set: nothing is written / printed.
409
+ empty: boolean;
410
+ }
411
+
412
+ // Pull an organization's agentic-coding instructions (its AGENTS.md /
413
+ // CLAUDE.md body). Needs a human sign-in (`brass login`): the instructions
414
+ // read is org-membership gated, which a service token does not carry.
415
+ // Resolves the org from the caller's single membership when `--org` is
416
+ // omitted. The body is emitted verbatim, so it round-trips byte-for-byte with
417
+ // what the dashboard stored, either to a file (default) or to stdout
418
+ // (`--stdout`, for an agent that reads it inline and caches nothing).
419
+ export async function agentsPull(
420
+ ctx: CommandContext,
421
+ opts: AgentsPullOptions,
422
+ ): Promise<AgentsPullResult> {
423
+ const organizationId =
424
+ opts.organizationId ?? (await resolveSingleOrganization(ctx));
425
+ const instructions = await ctx.api.get<AgentInstructionsResponse>(
426
+ `/organizations/${encodeURIComponent(organizationId)}/agent-instructions`,
427
+ );
428
+ const bytes = Buffer.byteLength(instructions.content, 'utf8');
429
+ const empty = instructions.content === '';
430
+
431
+ if (opts.stdout) {
432
+ // Body straight to stdout (verbatim); an empty org prints nothing so a
433
+ // consumer reads an empty stream, with the reason on stderr.
434
+ if (empty) ctx.log.warn(`No agent instructions set for ${organizationId}.`);
435
+ else ctx.log.write(instructions.content);
436
+ return { organization_id: organizationId, out_path: null, bytes, empty };
437
+ }
438
+
439
+ if (empty) {
440
+ ctx.log.warn(
441
+ `No agent instructions set for ${organizationId}. Nothing written.`,
442
+ );
443
+ return { organization_id: organizationId, out_path: opts.outPath, bytes, empty: true };
444
+ }
445
+ await writeTextFile(opts.outPath, instructions.content);
446
+ ctx.log.success(`Wrote agent instructions to ${opts.outPath} (${bytes} bytes)`);
447
+ // Claude Code reads CLAUDE.md, not AGENTS.md (the cross-agent default this
448
+ // writes). Point the user at the official one-line bridge so the pulled
449
+ // instructions are actually picked up there; skip it when they already
450
+ // pulled straight to a CLAUDE.md.
451
+ const outName = basename(opts.outPath);
452
+ if (outName.toLowerCase() !== 'claude.md') {
453
+ ctx.log.info(
454
+ `Claude Code reads CLAUDE.md, not ${outName}. To use these there, add a CLAUDE.md containing "@${outName}".`,
455
+ );
456
+ }
457
+ return {
458
+ organization_id: organizationId,
459
+ out_path: opts.outPath,
460
+ bytes,
461
+ empty: false,
462
+ };
463
+ }
464
+
465
+ async function listCallerOrganizations(
466
+ ctx: CommandContext,
467
+ ): Promise<OrganizationSummary[]> {
468
+ const { organizations } = await ctx.api.get<{
469
+ organizations: OrganizationSummary[];
470
+ }>('/organizations');
471
+ return organizations;
472
+ }
473
+
474
+ // Resolve the caller's single organization, or throw guidance to pass
475
+ // `--org`. A caller with zero orgs (or a service-token credential, which
476
+ // lists none) also gets a clear message rather than an opaque request error.
477
+ async function resolveSingleOrganization(ctx: CommandContext): Promise<string> {
478
+ const organizations = await listCallerOrganizations(ctx);
479
+ if (organizations.length === 1 && organizations[0] !== undefined) {
480
+ return organizations[0].organization_id;
481
+ }
482
+ if (organizations.length === 0) {
483
+ throw new Error(
484
+ 'No organizations for this credential. Pass --org <organizationId>, and sign in with `brass login` (agent instructions need a member sign-in, not a service token).',
485
+ );
486
+ }
487
+ const names = organizations
488
+ .map((o) => `${o.organization_id} (${o.name})`)
489
+ .join(', ');
490
+ throw new Error(
491
+ `Multiple organizations; pass --org <organizationId>. Available: ${names}`,
492
+ );
493
+ }
494
+
495
+ // The owning org for a first `publish` create. Every app is owned by an org.
496
+ // An explicit `--org` wins. A signed-in human resolves to their single
497
+ // membership (asked to choose when they belong to several, or to create/join
498
+ // one when they have none). A service token returns undefined: the API binds
499
+ // the app to the token's own org, and the token lists no memberships anyway.
500
+ async function resolveCreateOrganizationId(
501
+ ctx: CommandContext,
502
+ explicit: string | undefined,
503
+ ): Promise<string | undefined> {
504
+ if (explicit !== undefined) return explicit;
505
+ if (ctx.credentialKind !== 'session') return undefined;
506
+ const organizations = await listCallerOrganizations(ctx);
507
+ if (organizations.length === 1 && organizations[0] !== undefined) {
508
+ const orgId = organizations[0].organization_id;
509
+ ctx.log.info(
510
+ `Owning organization ${orgId} (your only one; pass --org to choose another).`,
511
+ );
512
+ return orgId;
513
+ }
514
+ if (organizations.length === 0) {
515
+ throw new Error(
516
+ 'You do not belong to any organization, so there is nothing to own the app. Create or join one in the dashboard, then publish.',
517
+ );
518
+ }
519
+ const names = organizations
520
+ .map((o) => `${o.organization_id} (${o.name})`)
521
+ .join(', ');
522
+ throw new Error(
523
+ `You belong to multiple organizations; pass --org <organizationId> to choose which one owns the app. Available: ${names}`,
524
+ );
525
+ }
526
+
527
+ export interface WhoamiResult {
528
+ authenticated: true;
529
+ }
530
+
531
+ // Confirm the resolved credential authenticates, over the `apps:write`-gated
532
+ // probe (`GET /health`) both credential kinds can reach: a service token is an
533
+ // org-scoped identity that enumerates no membership, so it has no org id to
534
+ // address an org-scoped read with. A 401 (rejected credential) or 403 (a token
535
+ // without the publish capability) surfaces as a thrown BrassApiError the CLI
536
+ // turns into a non-zero exit.
537
+ export async function whoami(ctx: CommandContext): Promise<WhoamiResult> {
538
+ await ctx.api.get<{ ok: true }>('/health');
539
+ ctx.log.success('Authenticated. This credential can publish.');
540
+ return { authenticated: true };
541
+ }
542
+
543
+ export type CredentialKind = 'none' | 'service' | 'session';
544
+
545
+ export interface StatusInputs {
546
+ // The API client for the resolved credential, or null when there is no
547
+ // credential to probe with (status still reports "no credential" then).
548
+ api: BrassApi | null;
549
+ profile: Profile;
550
+ log: Logger;
551
+ credential: ResolvedCredential | null;
552
+ // The app id `publish` would target (resolved from --app / BRASS_APP_ID /
553
+ // .brass/project.json), or null when a first publish would create one.
554
+ appId: string | null;
555
+ // A create name available for a first publish (from brass-app.json), so the
556
+ // next-step command can name what it will create.
557
+ manifestName: string | null;
558
+ // The directory `publish` would upload, for the emitted next-step command.
559
+ publishDir: string;
560
+ // A device grant `brass login --start` minted that no one has approved yet.
561
+ // Reporting it (and how long it has left) is what lets a caller tell a
562
+ // sign-in that is still relayable from one it has to restart, which is the
563
+ // difference between waiting and asking the human for a second approval.
564
+ pendingLogin: { userCode: string; verificationUrl: string; expiresAt: number } | null;
565
+ }
566
+
567
+ export interface StatusResult {
568
+ profile: string;
569
+ signed_in: boolean;
570
+ credential: CredentialKind;
571
+ // True once a probe confirms the credential authenticates; false when it is
572
+ // rejected; null when the probe could not settle the question (there was no
573
+ // credential to probe, or the probe itself failed).
574
+ authenticated: boolean | null;
575
+ // What stopped the probe from settling it, when `authenticated` is null and
576
+ // a credential was present. Kept separate from `authenticated` so a reader
577
+ // acts on the actual failure rather than on a guess at its cause.
578
+ unverified_reason?: string;
579
+ email: string | null;
580
+ // The sign-in awaiting approval, if one is in flight, with the seconds it
581
+ // has left. A caller polls `brass login --check` against this rather than
582
+ // starting a second sign-in.
583
+ pending_login: {
584
+ user_code: string;
585
+ verification_url: string;
586
+ expires_in_seconds: number;
587
+ } | null;
588
+ app_id: string | null;
589
+ hosting: { enabled: boolean; deployed: boolean; url: string | null } | null;
590
+ // Whether the state is such that a `publish` would proceed (a credential
591
+ // that authenticates, and either a resolvable app or a create name).
592
+ ready_to_publish: boolean;
593
+ // The single imperative next action, as a command where one applies. This is
594
+ // the field an agent acts on: it names the next step rather than describing
595
+ // how publishing works.
596
+ next: string;
597
+ }
598
+
599
+ // Report the publish readiness of the current directory + credential and,
600
+ // crucially, the one command to run next. `whoami` answers "does my token
601
+ // work"; `status` answers "what do I do now", so an agent follows its output
602
+ // instead of re-deriving the publish flow from prose under friction. It never
603
+ // throws on a missing or rejected credential: those are first-class states it
604
+ // reports and turns into a next step, not errors.
605
+ export async function status(inp: StatusInputs): Promise<StatusResult> {
606
+ const publishCmd = `brass publish ./${inp.publishDir}`;
607
+ const kind: CredentialKind = inp.credential === null ? 'none' : inp.credential.kind;
608
+
609
+ // No credential yet. Only the human can approve, but starting the sign-in,
610
+ // relaying the code, and polling it through are the caller's steps, so the
611
+ // next line names the command to run rather than an instruction to pass on.
612
+ if (inp.api === null || inp.credential === null) {
613
+ const pending = inp.pendingLogin;
614
+ return finish(inp, {
615
+ signed_in: false,
616
+ credential: 'none',
617
+ authenticated: null,
618
+ email: null,
619
+ hosting: null,
620
+ ready_to_publish: false,
621
+ next:
622
+ pending !== null
623
+ ? `a sign-in for ${inp.profile} is waiting for approval: give the user ` +
624
+ `${pending.verificationUrl} and the code ${pending.userCode}, then run ` +
625
+ `'brass login --check --wait'. It polls until they approve and renews ` +
626
+ `the code if it lapses, so there is no need to start a second sign-in.`
627
+ : `no credential for ${inp.profile}. Run 'brass login --start' to begin a ` +
628
+ `sign-in, give the user the URL and short code it prints, then run ` +
629
+ `'brass login --check --wait' until it reports approved (or set ` +
630
+ `BRASS_SERVICE_TOKEN). The session feeds the build steps ` +
631
+ `('brass agents pull' for the organization's instructions, ` +
632
+ `'brass schema pull' for your example document's schema) as well as ` +
633
+ `the eventual '${publishCmd}'.`,
634
+ });
635
+ }
636
+
637
+ // Probe the credential against the live API (the same read a publish makes),
638
+ // so "signed in" means verified, not just "a token is present".
639
+ const probe = await probeAuth(inp.api);
640
+ if (probe.state === 'unreachable' || probe.state === 'fault') {
641
+ return finish(inp, {
642
+ signed_in: false,
643
+ credential: kind,
644
+ authenticated: null,
645
+ unverified_reason: probe.error,
646
+ email: null,
647
+ hosting: null,
648
+ ready_to_publish: false,
649
+ next:
650
+ probe.state === 'unreachable'
651
+ ? `could not reach the Brass API (${probe.error}). Check connectivity, ` +
652
+ `then run 'brass status' again.`
653
+ : `the Brass API answered, but the credential could not be verified ` +
654
+ `(${probe.error}). The credential was not rejected and the API is ` +
655
+ `reachable, so run 'brass status' again.`,
656
+ });
657
+ }
658
+ if (probe.state === 'rejected') {
659
+ return finish(inp, {
660
+ signed_in: false,
661
+ credential: kind,
662
+ authenticated: false,
663
+ email: null,
664
+ hosting: null,
665
+ ready_to_publish: false,
666
+ next:
667
+ kind === 'session'
668
+ ? `the stored sign-in was rejected. Run 'brass login --start' to begin a ` +
669
+ `new one, relay the printed URL + code, then 'brass login --check --wait'.`
670
+ : `the service token was rejected. Check BRASS_SERVICE_TOKEN or --token.`,
671
+ });
672
+ }
673
+
674
+ const email = kind === 'session' ? await probeEmail(inp.api) : null;
675
+
676
+ // Verified. What would `publish` target? A service token's create binds to
677
+ // the token's own org by default, so no `--org` is needed to stand one up.
678
+ if (inp.appId === null) {
679
+ const next =
680
+ inp.manifestName !== null
681
+ ? `signed in. No app yet; run '${publishCmd}' to create ` +
682
+ `"${inp.manifestName}" and deploy it.`
683
+ : `signed in. No app yet; run '${publishCmd} --name "<app name>"' ` +
684
+ `to create and deploy one (or add a "name" to brass-app.json).`;
685
+ return finish(inp, {
686
+ signed_in: true,
687
+ credential: kind,
688
+ authenticated: true,
689
+ email,
690
+ hosting: null,
691
+ ready_to_publish: true,
692
+ next,
693
+ });
694
+ }
695
+
696
+ const hostingProbe = await probeHosting(inp.api, inp.appId);
697
+ if (hostingProbe.missing) {
698
+ return finish(inp, {
699
+ signed_in: true,
700
+ credential: kind,
701
+ authenticated: true,
702
+ email,
703
+ hosting: null,
704
+ ready_to_publish: false,
705
+ next:
706
+ `signed in, but the saved app id ${inp.appId} is not one this credential ` +
707
+ `can publish to. Remove .brass/project.json (or pass --app), then run ` +
708
+ `'${publishCmd}' to create a fresh app.`,
709
+ });
710
+ }
711
+ const h = hostingProbe.hosting;
712
+ const hosting =
713
+ h === null ? null : { enabled: h.enabled, deployed: h.deployed, url: h.url };
714
+ if (h !== null && h.deployed && h.url !== null) {
715
+ return finish(inp, {
716
+ signed_in: true,
717
+ credential: kind,
718
+ authenticated: true,
719
+ email,
720
+ hosting,
721
+ ready_to_publish: true,
722
+ next: `your app is live at ${h.url}. Open it and confirm sign-in works there.`,
723
+ });
724
+ }
725
+ return finish(inp, {
726
+ signed_in: true,
727
+ credential: kind,
728
+ authenticated: true,
729
+ email,
730
+ hosting,
731
+ ready_to_publish: true,
732
+ next: `signed in with app ${inp.appId} ready. Run '${publishCmd}' to deploy.`,
733
+ });
734
+ }
735
+
736
+ // How the credential probe ended. Each outcome has a different fix, so they
737
+ // stay distinct: a rejected credential needs a new sign-in, an unreachable
738
+ // API needs connectivity, and a fault needs a retry or a bug report.
739
+ type AuthProbe =
740
+ | { state: 'ok' }
741
+ | { state: 'rejected' }
742
+ | { state: 'unreachable'; error: string }
743
+ | { state: 'fault'; error: string };
744
+
745
+ // Auth check shared by every credential kind: `GET /health` is the
746
+ // `apps:write`-gated probe a session and a service token can both reach with
747
+ // nothing but their own credential (see `whoami`).
748
+ //
749
+ // Classify by what the probe observed. A 401/403 is a rejected credential,
750
+ // from the API or from the session refresh that runs first. Only status 0
751
+ // means the request never completed, which is the one state that says
752
+ // anything about reachability. Every other status is a fault the server
753
+ // answered, so it is not a bad credential either. The distinction is
754
+ // load-bearing: labelling a status the server returned "unreachable" points
755
+ // the next step at connectivity, and a reader acting on that goes looking
756
+ // for blocked egress instead of signing in.
757
+ async function probeAuth(api: BrassApi): Promise<AuthProbe> {
758
+ try {
759
+ await api.get<{ ok: true }>('/health');
760
+ return { state: 'ok' };
761
+ } catch (err) {
762
+ if (err instanceof BrassApiError) {
763
+ if (err.status === 401 || err.status === 403) return { state: 'rejected' };
764
+ if (err.status === 0) return { state: 'unreachable', error: err.message };
765
+ return { state: 'fault', error: err.message };
766
+ }
767
+ return { state: 'fault', error: err instanceof Error ? err.message : String(err) };
768
+ }
769
+ }
770
+
771
+ // The signed-in email for the friendly "signed in as ..." line, best-effort.
772
+ // `/users/me` carries an identity only for a session; a failure (a service
773
+ // token, or any error) just omits the email.
774
+ async function probeEmail(api: BrassApi): Promise<string | null> {
775
+ try {
776
+ const me = await api.get<{ email?: string }>('/users/me');
777
+ return typeof me.email === 'string' && me.email.trim() !== '' ? me.email : null;
778
+ } catch {
779
+ return null;
780
+ }
781
+ }
782
+
783
+ // Read an app's hosting so the next step is exact (deploy vs. open the live
784
+ // URL). A 404/403 means the saved app id is stale or not this credential's, a
785
+ // state worth surfacing; any other error is swallowed so it never blocks.
786
+ async function probeHosting(
787
+ api: BrassApi,
788
+ appId: string,
789
+ ): Promise<{ missing: boolean; hosting: HostingStatus | null }> {
790
+ try {
791
+ const hosting = await api.get<HostingStatus>(`/apps/${encodeURIComponent(appId)}/hosting`);
792
+ return { missing: false, hosting };
793
+ } catch (err) {
794
+ if (err instanceof BrassApiError && (err.status === 404 || err.status === 403)) {
795
+ return { missing: true, hosting: null };
796
+ }
797
+ return { missing: false, hosting: null };
798
+ }
799
+ }
800
+
801
+ // Assemble the result, render the human checklist, and return the structured
802
+ // payload (also emitted verbatim under `--json`).
803
+ // Report an in-flight sign-in only while it is still approvable: a lapsed
804
+ // grant is not a state anyone acts on, and reporting one would send a caller
805
+ // to relay a code the approval page refuses.
806
+ function pendingLoginReport(inp: StatusInputs): StatusResult['pending_login'] {
807
+ const pending = inp.pendingLogin;
808
+ if (pending === null) return null;
809
+ const remaining = Math.round((pending.expiresAt - Date.now()) / 1000);
810
+ if (remaining <= 0) return null;
811
+ return {
812
+ user_code: pending.userCode,
813
+ verification_url: pending.verificationUrl,
814
+ expires_in_seconds: remaining,
815
+ };
816
+ }
817
+
818
+ function finish(
819
+ inp: StatusInputs,
820
+ rest: Omit<StatusResult, 'profile' | 'app_id' | 'pending_login'>,
821
+ ): StatusResult {
822
+ const result: StatusResult = {
823
+ profile: inp.profile,
824
+ app_id: inp.appId,
825
+ pending_login: pendingLoginReport(inp),
826
+ ...rest,
827
+ };
828
+ const cred =
829
+ result.credential === 'none'
830
+ ? 'none'
831
+ : result.authenticated === false
832
+ ? `${credentialLabel(result.credential)} (rejected)`
833
+ : result.authenticated === null
834
+ ? `${credentialLabel(result.credential)} (unverified: ${result.unverified_reason ?? 'not probed'})`
835
+ : result.email !== null
836
+ ? `signed in as ${result.email}`
837
+ : `signed in (${credentialLabel(result.credential)})`;
838
+ inp.log.info(`Brass status (${result.profile})`);
839
+ inp.log.info(` credential: ${cred}`);
840
+ if (result.pending_login !== null) {
841
+ const p = result.pending_login;
842
+ inp.log.info(
843
+ ` sign-in: awaiting approval, code ${p.user_code} at ${p.verification_url} ` +
844
+ `(${Math.ceil(p.expires_in_seconds / 60)} min left)`,
845
+ );
846
+ }
847
+ inp.log.info(` app: ${result.app_id ?? 'none (a first publish creates one)'}`);
848
+ if (result.hosting !== null) {
849
+ const h = result.hosting;
850
+ const hostingLine = h.url
851
+ ? `${h.url}${h.deployed ? ' (deployed)' : ''}`
852
+ : h.enabled
853
+ ? 'enabled, not deployed'
854
+ : 'not enabled';
855
+ inp.log.info(` hosting: ${hostingLine}`);
856
+ }
857
+ inp.log.info('');
858
+ inp.log.success(`Next: ${result.next}`);
859
+ return result;
860
+ }
861
+
862
+ function credentialLabel(kind: CredentialKind): string {
863
+ return kind === 'service' ? 'service token' : kind === 'session' ? 'session' : 'none';
864
+ }