@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/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
|
+
}
|