@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.
- package/AGENTS.md +170 -0
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/dist/api.d.ts +73 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +97 -0
- package/dist/api.js.map +1 -0
- package/dist/args.d.ts +10 -0
- package/dist/args.d.ts.map +1 -0
- package/dist/args.js +94 -0
- package/dist/args.js.map +1 -0
- package/dist/auth.d.ts +5 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +12 -0
- package/dist/auth.js.map +1 -0
- package/dist/bin/brass.d.ts +3 -0
- package/dist/bin/brass.d.ts.map +1 -0
- package/dist/bin/brass.js +11 -0
- package/dist/bin/brass.js.map +1 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +364 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands.d.ts +94 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +559 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.d.ts +40 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +76 -0
- package/dist/config.js.map +1 -0
- package/dist/log.d.ts +9 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +32 -0
- package/dist/log.js.map +1 -0
- package/dist/login.d.ts +21 -0
- package/dist/login.d.ts.map +1 -0
- package/dist/login.js +158 -0
- package/dist/login.js.map +1 -0
- package/dist/project.d.ts +32 -0
- package/dist/project.d.ts.map +1 -0
- package/dist/project.js +129 -0
- package/dist/project.js.map +1 -0
- package/dist/session.d.ts +47 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +224 -0
- package/dist/session.js.map +1 -0
- package/dist/store.d.ts +17 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +90 -0
- package/dist/store.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/package.json +42 -0
- package/src/api.ts +195 -0
- package/src/args.ts +107 -0
- package/src/auth.ts +16 -0
- package/src/bin/brass.ts +11 -0
- package/src/cli.ts +422 -0
- package/src/commands.ts +864 -0
- package/src/config.ts +132 -0
- package/src/log.ts +41 -0
- package/src/login.ts +211 -0
- package/src/project.ts +176 -0
- package/src/session.ts +319 -0
- package/src/store.ts +123 -0
- package/src/version.ts +8 -0
package/src/commands.ts
ADDED
|
@@ -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
|
+
}
|