@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
package/src/config.ts ADDED
@@ -0,0 +1,132 @@
1
+ // Target + credential resolution for the CLI. Pure functions over an
2
+ // explicit inputs bag (flags, env vars, an optional on-disk credentials
3
+ // file) so the resolution order is unit-testable without touching the real
4
+ // filesystem or `process`.
5
+
6
+ export interface Origins {
7
+ apiBaseUrl: string;
8
+ authBaseUrl: string;
9
+ dashboardBaseUrl: string;
10
+ }
11
+
12
+ // Production is the default, and the only origin set the package carries: a
13
+ // developer publishing needs no configuration to target the real platform,
14
+ // and every other stack is named by its URLs, so no deployment's hostname
15
+ // scheme is derivable from what ships. Matches the SDK's own defaults.
16
+ const PROD_ORIGINS: Origins = {
17
+ apiBaseUrl: 'https://api.brass.build',
18
+ authBaseUrl: 'https://auth.brass.build',
19
+ dashboardBaseUrl: 'https://app.brass.build',
20
+ };
21
+
22
+ export interface OriginOverrides {
23
+ apiBaseUrl?: string;
24
+ authBaseUrl?: string;
25
+ dashboardBaseUrl?: string;
26
+ }
27
+
28
+ // Resolve the three origins, each override winning over the production
29
+ // default for its own field. A trailing slash is trimmed, so an origin
30
+ // written either way reaches the same credential slot.
31
+ export function resolveOrigins(overrides: OriginOverrides): Origins {
32
+ return {
33
+ apiBaseUrl: trimTrailingSlash(overrides.apiBaseUrl ?? PROD_ORIGINS.apiBaseUrl),
34
+ authBaseUrl: trimTrailingSlash(overrides.authBaseUrl ?? PROD_ORIGINS.authBaseUrl),
35
+ dashboardBaseUrl: trimTrailingSlash(
36
+ overrides.dashboardBaseUrl ?? PROD_ORIGINS.dashboardBaseUrl,
37
+ ),
38
+ };
39
+ }
40
+
41
+ function trimTrailingSlash(url: string): string {
42
+ return url.endsWith('/') ? url.slice(0, -1) : url;
43
+ }
44
+
45
+ // The credential-file / project-state key: `prod`, or `origin:<host>` for a
46
+ // stack named by URL, so one machine holds a session per target at once
47
+ // without collision.
48
+ export type Profile = string;
49
+
50
+ // The on-disk credentials file shape, keyed by profile. `brass login` writes
51
+ // a `session` (the opaque platform-session pointer); a `token` slot lets a
52
+ // service token be stored too, though CI usually supplies that via
53
+ // `BRASS_SERVICE_TOKEN`.
54
+ export interface StoredCredential {
55
+ token?: string;
56
+ session?: { sid: string };
57
+ }
58
+ export interface CredentialsFile {
59
+ version: 1;
60
+ credentials: Record<Profile, StoredCredential>;
61
+ }
62
+
63
+ export interface CredentialInputs {
64
+ // `--token` on the command line.
65
+ flagToken?: string;
66
+ // `BRASS_SERVICE_TOKEN` in the environment (the CI path: a pipeline
67
+ // secret, no interactive login).
68
+ envToken?: string;
69
+ // The credential file parsed off disk, when present.
70
+ file?: CredentialsFile;
71
+ profile: Profile;
72
+ }
73
+
74
+ // A service token is a static bearer; a session is a stored login the CLI
75
+ // refreshes into short-lived access tokens.
76
+ export type ResolvedCredential =
77
+ | { kind: 'service'; token: string; source: 'flag' | 'env' | 'file' }
78
+ | { kind: 'session'; sid: string; source: 'file' };
79
+
80
+ // Resolve which credential to use, most-explicit first: an inline `--token`,
81
+ // then `BRASS_SERVICE_TOKEN`, then the stored login session, then a stored
82
+ // service token. Null when none is available (the caller turns that into the
83
+ // "run brass login or set BRASS_SERVICE_TOKEN" guidance).
84
+ export function resolveCredential(inputs: CredentialInputs): ResolvedCredential | null {
85
+ if (isNonEmpty(inputs.flagToken)) {
86
+ return { kind: 'service', token: inputs.flagToken.trim(), source: 'flag' };
87
+ }
88
+ if (isNonEmpty(inputs.envToken)) {
89
+ return { kind: 'service', token: inputs.envToken.trim(), source: 'env' };
90
+ }
91
+ const stored = inputs.file?.credentials[inputs.profile];
92
+ if (stored?.session && isNonEmpty(stored.session.sid)) {
93
+ return { kind: 'session', sid: stored.session.sid.trim(), source: 'file' };
94
+ }
95
+ if (isNonEmpty(stored?.token)) {
96
+ return { kind: 'service', token: stored.token.trim(), source: 'file' };
97
+ }
98
+ return null;
99
+ }
100
+
101
+ function isNonEmpty(v: string | undefined): v is string {
102
+ return typeof v === 'string' && v.trim() !== '';
103
+ }
104
+
105
+ // The profile (credential-file + project-state key) a resolved origin set
106
+ // belongs to. Derived from WHERE the invocation points, so a stack named by
107
+ // `--api-url` / `--auth-url` is confined to its own slot: keyed on the flags
108
+ // instead, every per-URL invocation would land on `prod`, where `brass login`
109
+ // overwrites the production session.
110
+ export function profileForOrigins(origins: Origins): Profile {
111
+ if (sameOrigins(origins, PROD_ORIGINS)) return 'prod';
112
+ // The data API names the deployment an app id and a session belong to.
113
+ return `origin:${hostOf(origins.apiBaseUrl)}`;
114
+ }
115
+
116
+ function sameOrigins(a: Origins, b: Origins): boolean {
117
+ return (
118
+ a.apiBaseUrl === b.apiBaseUrl &&
119
+ a.authBaseUrl === b.authBaseUrl &&
120
+ a.dashboardBaseUrl === b.dashboardBaseUrl
121
+ );
122
+ }
123
+
124
+ // The host (with port) of a base URL, so the key reads as the stack it names.
125
+ // An unparseable value keys on itself, which still gives it its own slot.
126
+ function hostOf(baseUrl: string): string {
127
+ try {
128
+ return new URL(baseUrl).host;
129
+ } catch {
130
+ return baseUrl;
131
+ }
132
+ }
package/src/log.ts ADDED
@@ -0,0 +1,41 @@
1
+ // A tiny logger the commands write through, so tests can capture output and
2
+ // a `--json` mode can suppress the human lines. Human status goes to stderr;
3
+ // stdout is reserved for machine-readable results (`--json`) so a pipeline
4
+ // can consume `brass ... --json | jq` without status noise on the same stream.
5
+
6
+ export interface Logger {
7
+ info(message: string): void;
8
+ warn(message: string): void;
9
+ success(message: string): void;
10
+ result(payload: unknown): void;
11
+ // Raw payload write to stdout, verbatim (no added newline, no `--json`
12
+ // gating). For a command whose primary output IS content a caller reads or
13
+ // pipes (e.g. `agents pull --stdout`); status stays on stderr so stdout
14
+ // carries only that content.
15
+ write(text: string): void;
16
+ }
17
+
18
+ export function createLogger(json: boolean): Logger {
19
+ return {
20
+ info(message) {
21
+ if (!json) process.stderr.write(`${message}\n`);
22
+ },
23
+ // Warnings survive `--json`, unlike status lines. `--json` reserves
24
+ // STDOUT for the result, which a warning on stderr never touches, and the
25
+ // caller most likely to act on one is a script or a coding agent running
26
+ // in that mode. Suppressing it there loses the signal on the only path
27
+ // that reads output mechanically.
28
+ warn(message) {
29
+ process.stderr.write(`warning: ${message}\n`);
30
+ },
31
+ success(message) {
32
+ if (!json) process.stderr.write(`${message}\n`);
33
+ },
34
+ result(payload) {
35
+ if (json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
36
+ },
37
+ write(text) {
38
+ process.stdout.write(text);
39
+ },
40
+ };
41
+ }
package/src/login.ts ADDED
@@ -0,0 +1,211 @@
1
+ // The two-phase `brass login --start` / `brass login --check` flow, the
2
+ // agent-shaped variant of the blocking device grant. `--start` mints the
3
+ // grant, prints the approval URL + code, persists the grant, and exits, so a
4
+ // harness that cannot hold a long-running command can relay the URL to the
5
+ // human. `--check` reloads the grant and polls it, once by default or until a
6
+ // bounded `--wait` deadline. Both share `pollDeviceTokenOnce`'s classification
7
+ // with the blocking flow.
8
+ //
9
+ // The flow is built around one fact: the human approving is not watching the
10
+ // terminal, so the wait is open-ended while the grant is not. Every phase
11
+ // therefore keeps a live grant alive on its own rather than handing the
12
+ // caller a recovery step: `--start` resumes a grant that is still good instead
13
+ // of superseding the code already relayed, and a lapsed grant is replaced in
14
+ // place with a fresh one whose code is printed. A caller that has to relay a
15
+ // second code because the first expired is the cost being avoided.
16
+
17
+ import { deviceAuthorize, pollDeviceTokenOnce, decodeEmail } from './session.js';
18
+ import {
19
+ readPendingLogin,
20
+ writePendingLogin,
21
+ writeStoredCredential,
22
+ type PendingLogin,
23
+ } from './store.js';
24
+ import type { Logger } from './log.js';
25
+ import type { Profile } from './config.js';
26
+
27
+ const realSleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
28
+
29
+ // A grant with less than this left is renewed rather than handed out, so a
30
+ // caller never relays a code that dies while the human is still reading it.
31
+ const MIN_USABLE_REMAINING_MS = 60_000;
32
+
33
+ export interface LoginStartOptions {
34
+ authBaseUrl: string;
35
+ profile: Profile;
36
+ log: Logger;
37
+ // Mint a fresh grant even when a usable one is pending (`--new`), for a
38
+ // human who lost the relayed code.
39
+ force?: boolean;
40
+ env?: NodeJS.ProcessEnv;
41
+ now?: () => number;
42
+ }
43
+
44
+ // Mint a device grant, persist it as the profile's pending sign-in, print
45
+ // the approval URL + user code, and exit without waiting. The browser is not
46
+ // opened: the human approving is often on a different machine, so the URL is
47
+ // printed for relaying.
48
+ export async function loginStart(options: LoginStartOptions): Promise<number> {
49
+ const now = options.now ?? ((): number => Date.now());
50
+ const env = options.env ?? process.env;
51
+ // Resume a grant with usable time left. A second `--start` otherwise mints a
52
+ // second code and forgets the first, so an approval the human is part-way
53
+ // through completes a grant the CLI can no longer redeem and they are asked
54
+ // to sign in again.
55
+ const existing = options.force === true ? null : await readPendingLogin(options.profile, env);
56
+ const resumed =
57
+ existing !== null && existing.expiresAt - now() > MIN_USABLE_REMAINING_MS ? existing : null;
58
+ const pending = resumed ?? (await mintPendingLogin(options.authBaseUrl, now(), options.profile, env));
59
+ promptFor(options.log, pending, {
60
+ lead: resumed === null ? null : 'A sign-in is already waiting for approval.',
61
+ });
62
+ options.log.result({
63
+ state: 'started',
64
+ resumed: resumed !== null,
65
+ verification_url: targetUrl(pending),
66
+ user_code: pending.userCode,
67
+ expires_at: pending.expiresAt,
68
+ expires_in_seconds: remainingSeconds(pending, now()),
69
+ });
70
+ return 0;
71
+ }
72
+
73
+ export interface LoginCheckOptions {
74
+ profile: Profile;
75
+ log: Logger;
76
+ // Poll until approval for up to this many seconds instead of once
77
+ // (`--wait`). A grant that lapses inside the window is renewed in place and
78
+ // the new code printed, so the wait outlives any single grant.
79
+ waitSeconds?: number;
80
+ env?: NodeJS.ProcessEnv;
81
+ now?: () => number;
82
+ sleep?: (ms: number) => Promise<void>;
83
+ }
84
+
85
+ // Poll the profile's pending grant. Approved stores the session exactly as the
86
+ // blocking flow does and clears the grant; denied clears it and exits nonzero.
87
+ // Expiry renews the grant in place and prints the new code. Without `--wait`
88
+ // this makes exactly one poll and reports what it saw; with it, it keeps
89
+ // polling until approval or the deadline, and a deadline reached with the
90
+ // grant still pending is a `pending` result, not a failure: the grant is
91
+ // alive and the next check resumes it.
92
+ export async function loginCheck(options: LoginCheckOptions): Promise<number> {
93
+ const env = options.env ?? process.env;
94
+ const now = options.now ?? ((): number => Date.now());
95
+ const sleep = options.sleep ?? realSleep;
96
+ const waitMs = Math.max(0, (options.waitSeconds ?? 0) * 1000);
97
+ const deadline = now() + waitMs;
98
+
99
+ let pending = await readPendingLogin(options.profile, env);
100
+ if (pending === null) {
101
+ options.log.info('No sign-in in progress. Run `brass login --start` first.');
102
+ options.log.result({ state: 'none' });
103
+ return 1;
104
+ }
105
+ let intervalSeconds = pending.intervalSeconds;
106
+ // Set once the loop renews a lapsed grant, so the reported state tells the
107
+ // caller a NEW code needs relaying rather than the one it already sent.
108
+ let renewed = false;
109
+ // Whether the grant's code has already been printed since the last poll, so
110
+ // a renewal that is immediately followed by the closing report prints the
111
+ // code once rather than twice.
112
+ let justPrompted = false;
113
+
114
+ for (;;) {
115
+ if (now() >= pending.expiresAt) {
116
+ pending = await mintPendingLogin(pending.authBaseUrl, now(), options.profile, env);
117
+ intervalSeconds = pending.intervalSeconds;
118
+ renewed = true;
119
+ promptFor(options.log, pending, {
120
+ lead: 'The previous code expired, so this sign-in has a new one.',
121
+ });
122
+ justPrompted = true;
123
+ }
124
+
125
+ const outcome = await pollDeviceTokenOnce(pending.authBaseUrl, pending.deviceCode, now());
126
+ if (outcome.state === 'approved') {
127
+ if (!outcome.tokens.sessionToken) {
128
+ throw new Error('Device sign-in did not return a session token.');
129
+ }
130
+ await writeStoredCredential(
131
+ options.profile,
132
+ { session: { sid: outcome.tokens.sessionToken } },
133
+ env,
134
+ );
135
+ await writePendingLogin(options.profile, null, env);
136
+ const email = decodeEmail(outcome.tokens.idToken);
137
+ options.log.success(email !== undefined ? `Signed in as ${email}.` : 'Signed in.');
138
+ options.log.result({ state: 'approved', ...(email !== undefined ? { email } : {}) });
139
+ return 0;
140
+ }
141
+ if (outcome.state === 'denied') {
142
+ await writePendingLogin(options.profile, null, env);
143
+ options.log.info('Sign-in was denied. Run `brass login --start` to begin a new one.');
144
+ options.log.result({ state: 'denied' });
145
+ return 1;
146
+ }
147
+ // RFC 8628 §3.5: polling too fast. Back off for the rest of this wait.
148
+ if (outcome.state === 'slow_down') intervalSeconds += 5;
149
+
150
+ const nextPollAt = now() + intervalSeconds * 1000;
151
+ if (nextPollAt > deadline) break;
152
+ await sleep(intervalSeconds * 1000);
153
+ justPrompted = false;
154
+ }
155
+
156
+ // Still pending: reprint the URL + code so the caller can relay them again.
157
+ if (!justPrompted) {
158
+ promptFor(options.log, pending, { lead: renewed ? null : 'Not approved yet.' });
159
+ }
160
+ options.log.result({
161
+ state: renewed ? 'renewed' : 'pending',
162
+ verification_url: targetUrl(pending),
163
+ user_code: pending.userCode,
164
+ expires_at: pending.expiresAt,
165
+ expires_in_seconds: remainingSeconds(pending, now()),
166
+ });
167
+ return 0;
168
+ }
169
+
170
+ async function mintPendingLogin(
171
+ authBaseUrl: string,
172
+ now: number,
173
+ profile: Profile,
174
+ env: NodeJS.ProcessEnv,
175
+ ): Promise<PendingLogin> {
176
+ const auth = await deviceAuthorize(authBaseUrl, now);
177
+ const pending: PendingLogin = {
178
+ authBaseUrl,
179
+ deviceCode: auth.deviceCode,
180
+ userCode: auth.userCode,
181
+ verificationUri: auth.verificationUri,
182
+ ...(auth.verificationUriComplete !== undefined
183
+ ? { verificationUriComplete: auth.verificationUriComplete }
184
+ : {}),
185
+ intervalSeconds: auth.intervalSeconds,
186
+ expiresAt: auth.expiresAt,
187
+ };
188
+ await writePendingLogin(profile, pending, env);
189
+ return pending;
190
+ }
191
+
192
+ function targetUrl(pending: PendingLogin): string {
193
+ return pending.verificationUriComplete ?? pending.verificationUri;
194
+ }
195
+
196
+ function remainingSeconds(pending: PendingLogin, now: number): number {
197
+ return Math.max(0, Math.round((pending.expiresAt - now) / 1000));
198
+ }
199
+
200
+ // The one rendering of "here is what to relay, and here is what to run next",
201
+ // so a resumed, renewed, and freshly minted grant all read the same to whoever
202
+ // is relaying it.
203
+ function promptFor(log: Logger, pending: PendingLogin, opts: { lead: string | null }): void {
204
+ log.info(
205
+ (opts.lead !== null ? `${opts.lead}\n` : '') +
206
+ 'To approve this sign-in, go to:\n' +
207
+ ` ${targetUrl(pending)}\n` +
208
+ `and confirm the code: ${pending.userCode}\n` +
209
+ '\nThen run `brass login --check --wait` to finish signing in.',
210
+ );
211
+ }
package/src/project.ts ADDED
@@ -0,0 +1,176 @@
1
+ import { readFile, writeFile, readdir, stat, mkdir } from 'node:fs/promises';
2
+ import { join, relative, sep, dirname } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { zipSync } from 'fflate';
5
+ import type { BrassSchemaManifest } from './api.js';
6
+
7
+ // Local state the CLI persists so a re-`publish` targets the same app
8
+ // instead of creating a duplicate. Committed to the app's repo (like
9
+ // Vercel's `.vercel/project.json`), or supplied out-of-band via `--app` /
10
+ // `BRASS_APP_ID` for a stateless pipeline. Keyed by profile (`prod` / `dev` /
11
+ // `origin:<host>`) so one checkout can hold an app id per target.
12
+ export interface ProjectState {
13
+ version: 1;
14
+ apps: Record<string, { app_id: string }>;
15
+ }
16
+
17
+ const PROJECT_DIR = '.brass';
18
+ const PROJECT_FILE = 'project.json';
19
+
20
+ export function projectStatePath(cwd: string): string {
21
+ return join(cwd, PROJECT_DIR, PROJECT_FILE);
22
+ }
23
+
24
+ export async function readProjectState(cwd: string): Promise<ProjectState | null> {
25
+ try {
26
+ const raw = await readFile(projectStatePath(cwd), 'utf8');
27
+ const parsed = JSON.parse(raw) as ProjectState;
28
+ if (parsed.version !== 1 || typeof parsed.apps !== 'object' || parsed.apps === null) {
29
+ return null;
30
+ }
31
+ return parsed;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ export async function writeProjectAppId(
38
+ cwd: string,
39
+ profile: string,
40
+ appId: string,
41
+ ): Promise<void> {
42
+ const existing = (await readProjectState(cwd)) ?? { version: 1 as const, apps: {} };
43
+ const next: ProjectState = {
44
+ version: 1,
45
+ apps: { ...existing.apps, [profile]: { app_id: appId } },
46
+ };
47
+ await mkdir(join(cwd, PROJECT_DIR), { recursive: true });
48
+ await writeFile(projectStatePath(cwd), `${JSON.stringify(next, null, 2)}\n`, 'utf8');
49
+ }
50
+
51
+ // Resolve which app to publish to, most-explicit first: an inline `--app`,
52
+ // then `BRASS_APP_ID`, then the per-profile project state on disk. Null means
53
+ // "no app known yet" and the caller creates one.
54
+ export function resolveAppId(inputs: {
55
+ flagApp?: string;
56
+ envApp?: string;
57
+ state: ProjectState | null;
58
+ profile: string;
59
+ }): string | null {
60
+ if (nonEmpty(inputs.flagApp)) return inputs.flagApp.trim();
61
+ if (nonEmpty(inputs.envApp)) return inputs.envApp.trim();
62
+ const stored = inputs.state?.apps[inputs.profile]?.app_id;
63
+ return nonEmpty(stored) ? stored.trim() : null;
64
+ }
65
+
66
+ function nonEmpty(v: string | undefined): v is string {
67
+ return typeof v === 'string' && v.trim() !== '';
68
+ }
69
+
70
+ // The served capability manifest an app authors (`brass-app.json` /
71
+ // `/.well-known/brass-app.json`). The CLI reads `name` for a first create
72
+ // (and `client_token` as its stable idempotency key), and writes `schema` on
73
+ // a `schema pull`; every other field is passed through untouched.
74
+ export interface AppManifest {
75
+ name?: string;
76
+ // A stable, caller-chosen key that makes a first `publish` from CI
77
+ // idempotent: with it, repeated create-from-scratch runs (ephemeral CI has
78
+ // no persisted `.brass/project.json`) resolve the same app instead of
79
+ // minting a duplicate. Committed in the repo, so it is the same across
80
+ // runs. Opaque and non-secret (org-scoped; creating with it still needs the
81
+ // caller's org authority).
82
+ client_token?: string;
83
+ schema?: BrassSchemaManifest;
84
+ [key: string]: unknown;
85
+ }
86
+
87
+ export async function readManifest(path: string): Promise<AppManifest | null> {
88
+ try {
89
+ const raw = await readFile(path, 'utf8');
90
+ const parsed = JSON.parse(raw) as unknown;
91
+ if (typeof parsed !== 'object' || parsed === null) return null;
92
+ return parsed as AppManifest;
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ export async function writeManifest(path: string, manifest: AppManifest): Promise<void> {
99
+ await mkdir(dirname(path), { recursive: true });
100
+ await writeFile(path, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
101
+ }
102
+
103
+ // Write raw UTF-8 text to a file, creating parent directories as needed.
104
+ // Content is written verbatim (no added trailing newline) so a pulled
105
+ // document round-trips byte-for-byte with what the server stored.
106
+ export async function writeTextFile(path: string, content: string): Promise<void> {
107
+ await mkdir(dirname(path), { recursive: true });
108
+ await writeFile(path, content, 'utf8');
109
+ }
110
+
111
+ // Set the manifest's `schema` to a pulled schema manifest, preserving every
112
+ // other field and their order. Pure so the "copy verbatim" guarantee is
113
+ // testable: the declaration goes in unchanged, no re-derivation. Returns a
114
+ // new object.
115
+ export function mergeSchemaIntoManifest(
116
+ manifest: AppManifest,
117
+ schema: BrassSchemaManifest,
118
+ ): AppManifest {
119
+ return { ...manifest, schema };
120
+ }
121
+
122
+ // Recursively collect a directory's files into fflate's zip input map, keyed
123
+ // by forward-slash relative path (zip entries never use the OS separator).
124
+ // Symlinks are followed via `stat`; empty directories are omitted (a static
125
+ // bundle has none that matter).
126
+ export async function collectZipEntries(root: string): Promise<Record<string, Uint8Array>> {
127
+ const entries: Record<string, Uint8Array> = {};
128
+ async function walk(dir: string): Promise<void> {
129
+ const names = await readdir(dir);
130
+ for (const name of names) {
131
+ const abs = join(dir, name);
132
+ const info = await stat(abs);
133
+ if (info.isDirectory()) {
134
+ await walk(abs);
135
+ } else if (info.isFile()) {
136
+ const rel = relative(root, abs).split(sep).join('/');
137
+ entries[rel] = new Uint8Array(await readFile(abs));
138
+ }
139
+ }
140
+ }
141
+ await walk(root);
142
+ return entries;
143
+ }
144
+
145
+ export function zipEntries(entries: Record<string, Uint8Array>): Uint8Array {
146
+ if (Object.keys(entries).length === 0) {
147
+ throw new Error('No files found to publish');
148
+ }
149
+ return zipSync(entries);
150
+ }
151
+
152
+ export async function zipDirectory(root: string): Promise<Uint8Array> {
153
+ return zipEntries(await collectZipEntries(root));
154
+ }
155
+
156
+ // A deterministic hash of a bundle's contents: sha256 over the sorted
157
+ // `path\0sha256(bytes)` line of every file. It is derived from the SAME file
158
+ // map `zipDirectory` uploads (`collectZipEntries`), so equal hashes mean the
159
+ // served bytes would be identical, and it hashes file contents rather than the
160
+ // zip bytes (a zip carries mtimes and so is not reproducible for identical
161
+ // input). `publish` records this on the version it uploads and skips the
162
+ // upload + unpack-wait when the app's active version already carries it.
163
+ export function contentHash(entries: Record<string, Uint8Array>): string {
164
+ const lines = Object.keys(entries)
165
+ .sort()
166
+ .map((path) => `${path}\0${createHash('sha256').update(entries[path]!).digest('hex')}`);
167
+ return createHash('sha256').update(lines.join('\n')).digest('hex');
168
+ }
169
+
170
+ export async function isDirectory(path: string): Promise<boolean> {
171
+ try {
172
+ return (await stat(path)).isDirectory();
173
+ } catch {
174
+ return false;
175
+ }
176
+ }