@brass-build/cli 0.1.0 → 0.3.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 +35 -32
- package/CHANGELOG.md +32 -0
- package/README.md +26 -21
- package/dist/api.d.ts +3 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +28 -8
- package/dist/api.js.map +1 -1
- package/dist/args.d.ts +4 -0
- package/dist/args.d.ts.map +1 -1
- package/dist/args.js +92 -6
- package/dist/args.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +166 -61
- package/dist/cli.js.map +1 -1
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +49 -19
- package/dist/commands.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/login.d.ts +1 -0
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +26 -12
- package/dist/login.js.map +1 -1
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +34 -5
- package/dist/project.js.map +1 -1
- package/dist/session.d.ts +2 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +91 -20
- package/dist/session.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/api.ts +38 -8
- package/src/args.ts +102 -6
- package/src/cli.ts +198 -60
- package/src/commands.ts +66 -19
- package/src/config.ts +5 -1
- package/src/login.ts +30 -12
- package/src/project.ts +39 -5
- package/src/session.ts +106 -21
- package/src/version.ts +1 -1
package/src/api.ts
CHANGED
|
@@ -34,6 +34,10 @@ export interface HostingStatus {
|
|
|
34
34
|
// Whether the edge gates the bundle behind the app's audience. A public
|
|
35
35
|
// showcase turns this off so the bundle is world-loadable.
|
|
36
36
|
require_access?: boolean;
|
|
37
|
+
// Whether the platform has registered this slot to serve. False means the
|
|
38
|
+
// hosted URL answers 404 however the rest of this body reads. Optional: an
|
|
39
|
+
// older api omits it, and an absent field is not a failure to report.
|
|
40
|
+
gate_settled?: boolean;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
export interface HostingUploadUrl {
|
|
@@ -110,6 +114,28 @@ export class BrassApiError extends Error {
|
|
|
110
114
|
}
|
|
111
115
|
}
|
|
112
116
|
|
|
117
|
+
// `fetch` carries no timeout of its own, so a host that accepts the connection
|
|
118
|
+
// and then answers nothing holds the command open for as long as the caller
|
|
119
|
+
// leaves it running. In CI that is a job that never finishes rather than a
|
|
120
|
+
// failure anyone can read, so every request the CLI makes is bounded.
|
|
121
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
122
|
+
// The upload carries the whole bundle, so it is bounded far more loosely than
|
|
123
|
+
// a request that carries a few hundred bytes.
|
|
124
|
+
const UPLOAD_TIMEOUT_MS = 300_000;
|
|
125
|
+
|
|
126
|
+
/** A failed fetch as the caller should read it, naming a timeout as one. */
|
|
127
|
+
export function networkError(what: string, cause: unknown): BrassApiError {
|
|
128
|
+
const timedOut =
|
|
129
|
+
cause instanceof Error &&
|
|
130
|
+
(cause.name === 'TimeoutError' || cause.name === 'AbortError');
|
|
131
|
+
return new BrassApiError(
|
|
132
|
+
0,
|
|
133
|
+
timedOut
|
|
134
|
+
? `Timed out ${what}.`
|
|
135
|
+
: `Network error ${what}: ${String(cause)}`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
113
139
|
export class BrassApi {
|
|
114
140
|
private readonly apiBaseUrl: string;
|
|
115
141
|
private readonly auth: AuthProvider;
|
|
@@ -132,7 +158,7 @@ export class BrassApi {
|
|
|
132
158
|
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
133
159
|
const headers: Record<string, string> = {
|
|
134
160
|
...(await this.auth.headers()),
|
|
135
|
-
// Version telemetry; the
|
|
161
|
+
// Version telemetry; the platform logs it so pinned CI copies
|
|
136
162
|
// stay visible in the deployed-version distribution.
|
|
137
163
|
'x-brass-client': CLI_CLIENT_ID,
|
|
138
164
|
};
|
|
@@ -143,9 +169,12 @@ export class BrassApi {
|
|
|
143
169
|
}
|
|
144
170
|
let response: Response;
|
|
145
171
|
try {
|
|
146
|
-
response = await fetch(`${this.apiBaseUrl}${path}`,
|
|
172
|
+
response = await fetch(`${this.apiBaseUrl}${path}`, {
|
|
173
|
+
...init,
|
|
174
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
175
|
+
});
|
|
147
176
|
} catch (cause) {
|
|
148
|
-
throw
|
|
177
|
+
throw networkError(`reaching ${this.apiBaseUrl}`, cause);
|
|
149
178
|
}
|
|
150
179
|
if (!response.ok) {
|
|
151
180
|
throw new BrassApiError(response.status, await errorMessage(response));
|
|
@@ -155,10 +184,10 @@ export class BrassApi {
|
|
|
155
184
|
}
|
|
156
185
|
}
|
|
157
186
|
|
|
158
|
-
// Upload bytes to
|
|
159
|
-
// and no bearer: the presigned
|
|
160
|
-
//
|
|
161
|
-
//
|
|
187
|
+
// Upload bytes to the presigned storage URL the platform handed back.
|
|
188
|
+
// Deliberately carries NO Brass headers and no bearer: the presigned
|
|
189
|
+
// signature pins an exact header set, so a stray `authorization` header
|
|
190
|
+
// invalidates it and the upload is refused.
|
|
162
191
|
export async function putPresigned(
|
|
163
192
|
url: string,
|
|
164
193
|
bytes: Uint8Array,
|
|
@@ -173,9 +202,10 @@ export async function putPresigned(
|
|
|
173
202
|
// `ArrayBufferLike` is sound and satisfies fetch's `BufferSource`
|
|
174
203
|
// (which pins `ArrayBufferView<ArrayBuffer>`) across lib configs.
|
|
175
204
|
body: bytes as Uint8Array<ArrayBuffer>,
|
|
205
|
+
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
|
|
176
206
|
});
|
|
177
207
|
} catch (cause) {
|
|
178
|
-
throw
|
|
208
|
+
throw networkError('uploading the bundle', cause);
|
|
179
209
|
}
|
|
180
210
|
if (!response.ok) {
|
|
181
211
|
throw new BrassApiError(response.status, `Bundle upload failed (${response.status})`);
|
package/src/args.ts
CHANGED
|
@@ -7,13 +7,16 @@ export interface ParsedArgs {
|
|
|
7
7
|
// A flag present with no value (`--json`) stores `true`; a valued flag
|
|
8
8
|
// (`--doc abc` / `--doc=abc`) stores the string.
|
|
9
9
|
flags: Record<string, string | true>;
|
|
10
|
+
// Boolean flags written with a value (`--json=false`). A boolean flag is on
|
|
11
|
+
// by being present, so any value behind one contradicts the flag itself, and
|
|
12
|
+
// the reading a caller expects is the opposite of the one it would get.
|
|
13
|
+
valuedBooleans: string[];
|
|
10
14
|
}
|
|
11
15
|
|
|
12
|
-
// Flags that never take a value, so `brass
|
|
13
|
-
// `./
|
|
16
|
+
// Flags that never take a value, so `brass agents pull --stdout ./dir` parses
|
|
17
|
+
// `./dir` as a positional rather than the value of `--stdout`.
|
|
14
18
|
const BOOLEAN_FLAGS = new Set([
|
|
15
19
|
'json',
|
|
16
|
-
'yes',
|
|
17
20
|
'help',
|
|
18
21
|
'version',
|
|
19
22
|
'stdout',
|
|
@@ -52,18 +55,102 @@ export function unknownFlags(parsed: ParsedArgs): string[] {
|
|
|
52
55
|
return Object.keys(parsed.flags).filter((name) => !KNOWN_FLAGS.has(name));
|
|
53
56
|
}
|
|
54
57
|
|
|
58
|
+
// The flags every command reads, whichever it is: where to talk to and what
|
|
59
|
+
// to print.
|
|
60
|
+
const COMMON_FLAGS = [
|
|
61
|
+
'api-url',
|
|
62
|
+
'auth-url',
|
|
63
|
+
'dashboard-url',
|
|
64
|
+
'help',
|
|
65
|
+
'json',
|
|
66
|
+
'version',
|
|
67
|
+
] as const;
|
|
68
|
+
|
|
69
|
+
// What each command reads beyond those, and how many positionals it takes
|
|
70
|
+
// (the command word included, so `publish [dir]` is 2). A flag one command
|
|
71
|
+
// reads is not thereby a flag of the next: `--wait` is the sign-in poll's
|
|
72
|
+
// deadline and `publish` has its own, so `brass publish --wait 600` waits
|
|
73
|
+
// exactly as long as it would have without the flag. Refusing it names the
|
|
74
|
+
// mistake, where accepting it leaves the caller believing they set something.
|
|
75
|
+
const COMMAND_ARGS: Record<string, { flags: readonly string[]; positionals: number }> = {
|
|
76
|
+
login: { flags: ['check', 'new', 'start', 'wait'], positionals: 1 },
|
|
77
|
+
logout: { flags: [], positionals: 1 },
|
|
78
|
+
status: { flags: ['app', 'manifest', 'token'], positionals: 2 },
|
|
79
|
+
publish: {
|
|
80
|
+
flags: [
|
|
81
|
+
'app',
|
|
82
|
+
'client-token',
|
|
83
|
+
'gate',
|
|
84
|
+
'manifest',
|
|
85
|
+
'name',
|
|
86
|
+
'org',
|
|
87
|
+
'slug',
|
|
88
|
+
'token',
|
|
89
|
+
'visibility',
|
|
90
|
+
],
|
|
91
|
+
positionals: 2,
|
|
92
|
+
},
|
|
93
|
+
schema: { flags: ['doc', 'out', 'token'], positionals: 2 },
|
|
94
|
+
agents: { flags: ['org', 'out', 'stdout', 'token'], positionals: 2 },
|
|
95
|
+
whoami: { flags: ['token'], positionals: 1 },
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// The flags `command` does not read, out of the ones this CLI knows. A name
|
|
99
|
+
// the CLI knows nowhere is `unknownFlags`' answer and stays there, so a
|
|
100
|
+
// misspelling is reported as one rather than as a flag of another command.
|
|
101
|
+
// A command this CLI does not have answers for itself.
|
|
102
|
+
export function flagsNotReadBy(parsed: ParsedArgs, command: string): string[] {
|
|
103
|
+
const spec = COMMAND_ARGS[command];
|
|
104
|
+
if (spec === undefined) return [];
|
|
105
|
+
const reads = new Set<string>([...COMMON_FLAGS, ...spec.flags]);
|
|
106
|
+
return Object.keys(parsed.flags).filter(
|
|
107
|
+
(name) => KNOWN_FLAGS.has(name) && !reads.has(name),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The positionals past the ones `command` reads. An ignored one is the same
|
|
112
|
+
// silence a flag no command reads leaves: `brass publish out dist` publishes
|
|
113
|
+
// `out`, and the caller who meant `dist` is told nothing.
|
|
114
|
+
export function extraPositionals(parsed: ParsedArgs, command: string): string[] {
|
|
115
|
+
const spec = COMMAND_ARGS[command];
|
|
116
|
+
if (spec === undefined) return [];
|
|
117
|
+
return parsed.positionals.slice(spec.positionals);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The flags of `command` that carry a value, so a bare one is the caller
|
|
121
|
+
// naming something the run then resolves a default for. `--wait` is absent
|
|
122
|
+
// here because a bare `--wait` is its own answer (the default deadline).
|
|
123
|
+
export function valueFlagsOf(command: string): string[] {
|
|
124
|
+
const spec = COMMAND_ARGS[command];
|
|
125
|
+
if (spec === undefined) return [];
|
|
126
|
+
return [...COMMON_FLAGS, ...spec.flags].filter(
|
|
127
|
+
(name) => !BOOLEAN_FLAGS.has(name) && name !== 'wait',
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
55
131
|
// Which of `names` were given without a value (`--api-url --json`, or
|
|
56
132
|
// `--api-url` last on the line). The parser stores those as `true` and
|
|
57
133
|
// `stringFlag` reads that as absent, so an origin flag in this state resolves
|
|
58
134
|
// the production default while the command line names another stack. That is
|
|
59
135
|
// the same silent retarget `unknownFlags` catches for a misspelled name.
|
|
136
|
+
//
|
|
137
|
+
// An EMPTY value counts, and it is the shape a caller actually reaches: a
|
|
138
|
+
// script writing `--slug "$SLUG"` against an unset variable passes the flag
|
|
139
|
+
// with nothing behind it. That value is not absent the way a bare flag is, so
|
|
140
|
+
// it resolves no default and travels to the server as an empty string, where
|
|
141
|
+
// the mistake is reported (if at all) in the server's vocabulary rather than
|
|
142
|
+
// as the missing value it is.
|
|
60
143
|
export function valuelessFlags(parsed: ParsedArgs, names: readonly string[]): string[] {
|
|
61
|
-
return names.filter((name) =>
|
|
144
|
+
return names.filter((name) => {
|
|
145
|
+
const value = parsed.flags[name];
|
|
146
|
+
return value === true || value === '';
|
|
147
|
+
});
|
|
62
148
|
}
|
|
63
149
|
|
|
64
150
|
export function parseArgs(argv: readonly string[]): ParsedArgs {
|
|
65
151
|
const positionals: string[] = [];
|
|
66
152
|
const flags: Record<string, string | true> = {};
|
|
153
|
+
const valuedBooleans: string[] = [];
|
|
67
154
|
for (let i = 0; i < argv.length; i++) {
|
|
68
155
|
const arg = argv[i];
|
|
69
156
|
if (arg === undefined) continue;
|
|
@@ -71,7 +158,16 @@ export function parseArgs(argv: readonly string[]): ParsedArgs {
|
|
|
71
158
|
const body = arg.slice(2);
|
|
72
159
|
const eq = body.indexOf('=');
|
|
73
160
|
if (eq !== -1) {
|
|
74
|
-
|
|
161
|
+
const name = body.slice(0, eq);
|
|
162
|
+
if (BOOLEAN_FLAGS.has(name)) {
|
|
163
|
+
// Recorded rather than read: `--json=false` reads as ON, which is
|
|
164
|
+
// the reverse of what the caller wrote, and `--stdout=false` would
|
|
165
|
+
// send a file's contents to stdout and write nothing.
|
|
166
|
+
valuedBooleans.push(name);
|
|
167
|
+
flags[name] = true;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
flags[name] = body.slice(eq + 1);
|
|
75
171
|
continue;
|
|
76
172
|
}
|
|
77
173
|
if (BOOLEAN_FLAGS.has(body)) {
|
|
@@ -89,7 +185,7 @@ export function parseArgs(argv: readonly string[]): ParsedArgs {
|
|
|
89
185
|
positionals.push(arg);
|
|
90
186
|
}
|
|
91
187
|
}
|
|
92
|
-
return { positionals, flags };
|
|
188
|
+
return { positionals, flags, valuedBooleans };
|
|
93
189
|
}
|
|
94
190
|
|
|
95
191
|
// Read a flag expected to carry a string value; a bare boolean flag (no
|
package/src/cli.ts
CHANGED
|
@@ -6,7 +6,10 @@ import {
|
|
|
6
6
|
parseArgs,
|
|
7
7
|
stringFlag,
|
|
8
8
|
boolFlag,
|
|
9
|
+
extraPositionals,
|
|
10
|
+
flagsNotReadBy,
|
|
9
11
|
unknownFlags,
|
|
12
|
+
valueFlagsOf,
|
|
10
13
|
valuelessFlags,
|
|
11
14
|
type ParsedArgs,
|
|
12
15
|
} from './args.js';
|
|
@@ -18,10 +21,15 @@ import {
|
|
|
18
21
|
type Origins,
|
|
19
22
|
type Profile,
|
|
20
23
|
} from './config.js';
|
|
21
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
readCredentialsFile,
|
|
26
|
+
readPendingLogin,
|
|
27
|
+
writePendingLogin,
|
|
28
|
+
writeStoredCredential,
|
|
29
|
+
} from './store.js';
|
|
22
30
|
import { BrassApi, BrassApiError, type AppVisibility } from './api.js';
|
|
23
31
|
import { serviceTokenAuth, type AuthProvider } from './auth.js';
|
|
24
|
-
import { loginDevice, sessionAuth } from './session.js';
|
|
32
|
+
import { loginDevice, postDeviceCancel, postSignOut, sessionAuth } from './session.js';
|
|
25
33
|
import { loginStart, loginCheck } from './login.js';
|
|
26
34
|
import { createLogger, type Logger } from './log.js';
|
|
27
35
|
import {
|
|
@@ -31,6 +39,7 @@ import {
|
|
|
31
39
|
whoami,
|
|
32
40
|
status,
|
|
33
41
|
type CommandContext,
|
|
42
|
+
type CredentialKind,
|
|
34
43
|
} from './commands.js';
|
|
35
44
|
import { readProjectState, resolveAppId, readManifest } from './project.js';
|
|
36
45
|
|
|
@@ -47,7 +56,7 @@ Usage:
|
|
|
47
56
|
brass login --check Check a started sign-in once; stores the session when approved.
|
|
48
57
|
--wait [seconds] polls until approved (default 120s), renewing
|
|
49
58
|
an expired code in place and printing the new one.
|
|
50
|
-
brass logout
|
|
59
|
+
brass logout End the stored sign-in for this environment, here and on the server.
|
|
51
60
|
brass status [dir] Report the credential + app state and the one command to run next.
|
|
52
61
|
brass publish [dir] Build output in [dir] (default: dist) is deployed to the app's hosting.
|
|
53
62
|
brass schema pull --doc <docId> [--out brass-app.json]
|
|
@@ -82,8 +91,15 @@ Publish flags:
|
|
|
82
91
|
export async function run(argv: readonly string[]): Promise<number> {
|
|
83
92
|
const parsed = parseArgs(argv);
|
|
84
93
|
const command = parsed.positionals[0];
|
|
85
|
-
|
|
86
|
-
|
|
94
|
+
// Which credential the invocation resolved, once it has. A 401 is reported
|
|
95
|
+
// in that credential's own vocabulary, so it stays undefined until
|
|
96
|
+
// `buildContext` settles it and a failure before then carries no guess.
|
|
97
|
+
let credentialKind: CredentialKind | undefined;
|
|
98
|
+
|
|
99
|
+
// Answered whichever command follows, like `--help` beside it: both are
|
|
100
|
+
// declared flags of every command (`COMMON_FLAGS`), so a caller who asks
|
|
101
|
+
// which version is installed gets the answer rather than a publish.
|
|
102
|
+
if (boolFlag(parsed, 'version')) {
|
|
87
103
|
process.stdout.write(`${VERSION}\n`);
|
|
88
104
|
return 0;
|
|
89
105
|
}
|
|
@@ -103,6 +119,41 @@ export async function run(argv: readonly string[]): Promise<number> {
|
|
|
103
119
|
return 1;
|
|
104
120
|
}
|
|
105
121
|
|
|
122
|
+
// Then what THIS command reads, which is the same check one scope in: a
|
|
123
|
+
// flag or a positional the command ignores leaves the run differing from
|
|
124
|
+
// what the command line asked for, with nothing said. Judged from argv
|
|
125
|
+
// alone, so it answers a caller who has not signed in.
|
|
126
|
+
const misplaced = flagsNotReadBy(parsed, command);
|
|
127
|
+
if (misplaced.length > 0) {
|
|
128
|
+
const names = misplaced.map((n) => `--${n}`).join(', ');
|
|
129
|
+
process.stderr.write(
|
|
130
|
+
`error: ${names} ${misplaced.length === 1 ? 'is not a flag' : 'are not flags'} of \`brass ${command}\`\n\n${USAGE}`,
|
|
131
|
+
);
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
const extra = extraPositionals(parsed, command);
|
|
135
|
+
if (extra.length > 0) {
|
|
136
|
+
const args = extra.map((p) => JSON.stringify(p)).join(', ');
|
|
137
|
+
process.stderr.write(
|
|
138
|
+
`error: unexpected ${extra.length === 1 ? 'argument' : 'arguments'} ${args} for \`brass ${command}\`\n\n${USAGE}`,
|
|
139
|
+
);
|
|
140
|
+
return 1;
|
|
141
|
+
}
|
|
142
|
+
if (parsed.valuedBooleans.length > 0) {
|
|
143
|
+
const names = parsed.valuedBooleans.map((n) => `--${n}`).join(', ');
|
|
144
|
+
process.stderr.write(
|
|
145
|
+
`error: ${names} ${parsed.valuedBooleans.length === 1 ? 'takes' : 'take'} no value\n`,
|
|
146
|
+
);
|
|
147
|
+
return 1;
|
|
148
|
+
}
|
|
149
|
+
const bare = valuelessFlags(parsed, valueFlagsOf(command));
|
|
150
|
+
if (bare.length > 0) {
|
|
151
|
+
process.stderr.write(
|
|
152
|
+
`error: Missing value for ${bare.map((n) => `--${n}`).join(', ')}\n`,
|
|
153
|
+
);
|
|
154
|
+
return 1;
|
|
155
|
+
}
|
|
156
|
+
|
|
106
157
|
const json = boolFlag(parsed, 'json');
|
|
107
158
|
const log = createLogger(json);
|
|
108
159
|
|
|
@@ -115,32 +166,63 @@ export async function run(argv: readonly string[]): Promise<number> {
|
|
|
115
166
|
// (and the next step to obtain one) is a first-class outcome, not an error.
|
|
116
167
|
if (command === 'status') return await runStatus(parsed, log);
|
|
117
168
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
return 0;
|
|
129
|
-
case 'whoami':
|
|
130
|
-
log.result(await whoami(ctx));
|
|
131
|
-
return 0;
|
|
132
|
-
default:
|
|
133
|
-
process.stderr.write(`Unknown command "${command}".\n\n${USAGE}`);
|
|
134
|
-
return 1;
|
|
169
|
+
// What the caller asked for is decided from argv alone, so it is decided
|
|
170
|
+
// BEFORE a credential is resolved. A misspelt command, a missing
|
|
171
|
+
// subcommand and an invalid flag value are all answerable without one, and
|
|
172
|
+
// resolving the credential first answers every one of them with "No
|
|
173
|
+
// credential. Run `brass login`": the caller re-authenticates over a typo,
|
|
174
|
+
// and only a caller who already has a credential is ever shown the message
|
|
175
|
+
// naming the real mistake.
|
|
176
|
+
if (!isCredentialedCommand(command)) {
|
|
177
|
+
process.stderr.write(`Unknown command "${command}".\n\n${USAGE}`);
|
|
178
|
+
return 1;
|
|
135
179
|
}
|
|
180
|
+
const plan = planCommand(command, parsed);
|
|
181
|
+
|
|
182
|
+
const ctx = await buildContext(parsed);
|
|
183
|
+
credentialKind = ctx.credentialKind;
|
|
184
|
+
log.result(await plan(ctx));
|
|
185
|
+
return 0;
|
|
136
186
|
} catch (err) {
|
|
137
|
-
process.stderr.write(`${formatError(err)}\n`);
|
|
187
|
+
process.stderr.write(`${formatError(err, credentialKind)}\n`);
|
|
138
188
|
return 1;
|
|
139
189
|
}
|
|
140
190
|
}
|
|
141
191
|
|
|
142
192
|
type Context = CommandContext;
|
|
143
193
|
|
|
194
|
+
// The commands that need a resolved credential; `login` / `logout` / `status`
|
|
195
|
+
// are answered above without one.
|
|
196
|
+
const CREDENTIALED_COMMANDS = ['publish', 'schema', 'agents', 'whoami'] as const;
|
|
197
|
+
type CredentialedCommand = (typeof CREDENTIALED_COMMANDS)[number];
|
|
198
|
+
|
|
199
|
+
function isCredentialedCommand(value: string): value is CredentialedCommand {
|
|
200
|
+
return (CREDENTIALED_COMMANDS as readonly string[]).includes(value);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// The work a command will do once it has a credential.
|
|
204
|
+
type CommandPlan = (ctx: Context) => Promise<unknown>;
|
|
205
|
+
|
|
206
|
+
// Resolve argv to that work, validating everything argv alone decides and
|
|
207
|
+
// throwing on a shape the caller got wrong. Splitting the plan from the run is
|
|
208
|
+
// what lets the shape be judged before a credential is resolved.
|
|
209
|
+
function planCommand(command: CredentialedCommand, parsed: ParsedArgs): CommandPlan {
|
|
210
|
+
switch (command) {
|
|
211
|
+
case 'publish':
|
|
212
|
+
return planPublish(parsed);
|
|
213
|
+
case 'schema':
|
|
214
|
+
return planSchema(parsed);
|
|
215
|
+
case 'agents':
|
|
216
|
+
return planAgents(parsed);
|
|
217
|
+
case 'whoami':
|
|
218
|
+
return (ctx): Promise<unknown> => whoami(ctx);
|
|
219
|
+
default: {
|
|
220
|
+
const exhaustive: never = command;
|
|
221
|
+
throw new Error(`Unhandled command "${String(exhaustive)}"`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
144
226
|
interface Base {
|
|
145
227
|
origins: Origins;
|
|
146
228
|
profile: Profile;
|
|
@@ -226,6 +308,7 @@ async function runLogin(parsed: ParsedArgs, log: Logger): Promise<number> {
|
|
|
226
308
|
profile,
|
|
227
309
|
log,
|
|
228
310
|
...(boolFlag(parsed, 'new') ? { force: true } : {}),
|
|
311
|
+
...(waitSeconds !== undefined ? { resultFollows: true } : {}),
|
|
229
312
|
});
|
|
230
313
|
// `--start --wait` is the whole sign-in in one command: relay the code it
|
|
231
314
|
// prints, then it holds for the approval up to the deadline.
|
|
@@ -238,18 +321,55 @@ async function runLogin(parsed: ParsedArgs, log: Logger): Promise<number> {
|
|
|
238
321
|
// The RFC 8628 device grant: open the approval page (code prefilled), print
|
|
239
322
|
// the URL + code as a fallback for a headless box, and poll until approval.
|
|
240
323
|
const result = await loginDevice({ authBaseUrl: origins.authBaseUrl });
|
|
241
|
-
await writeStoredCredential(profile, {
|
|
324
|
+
await writeStoredCredential(profile, {
|
|
325
|
+
session: { sid: result.sessionToken, authBaseUrl: origins.authBaseUrl },
|
|
326
|
+
});
|
|
242
327
|
log.success(result.email ? `Signed in as ${result.email}.` : 'Signed in.');
|
|
243
328
|
log.result({ signed_in: true, ...(result.email !== undefined ? { email: result.email } : {}) });
|
|
244
329
|
return 0;
|
|
245
330
|
}
|
|
246
331
|
|
|
247
332
|
async function runLogout(parsed: ParsedArgs, log: Logger): Promise<number> {
|
|
248
|
-
const { profile } = resolveBase(parsed);
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
333
|
+
const { origins, profile } = resolveBase(parsed);
|
|
334
|
+
|
|
335
|
+
// Revoke on the server first, while the pointer is still readable. A
|
|
336
|
+
// service token belongs to an organization and is revoked in the dashboard,
|
|
337
|
+
// so only a stored session has anything to end here. The session is revoked
|
|
338
|
+
// on the auth origin it was minted on: profiles key on the api host, so the
|
|
339
|
+
// invocation's own auth flag can name a different stack, where an unknown
|
|
340
|
+
// pointer reads as already-signed-out and the real session stays live.
|
|
341
|
+
const file = await readCredentialsFile();
|
|
342
|
+
const session = file?.credentials[profile]?.session;
|
|
343
|
+
const signedOut =
|
|
344
|
+
session === undefined
|
|
345
|
+
? true
|
|
346
|
+
: await postSignOut(session.authBaseUrl ?? origins.authBaseUrl, session.sid);
|
|
347
|
+
|
|
348
|
+
// A started sign-in is redeemable by whoever holds the device code, and a
|
|
349
|
+
// human may still approve it after this command returns, so cancelling it on
|
|
350
|
+
// the server is what a sign-out owes. The grant, too, is cancelled on the
|
|
351
|
+
// auth origin that minted it.
|
|
352
|
+
const pending = await readPendingLogin(profile);
|
|
353
|
+
const cancelled =
|
|
354
|
+
pending === null ? true : await postDeviceCancel(pending.authBaseUrl, pending.deviceCode);
|
|
355
|
+
|
|
356
|
+
// Each record is dropped only once its server side is revoked. The record
|
|
357
|
+
// is the one handle that can name the session or grant to the server, so a
|
|
358
|
+
// failed delivery keeps it, which is what makes the retry this command
|
|
359
|
+
// recommends able to revoke anything.
|
|
360
|
+
if (signedOut) await writeStoredCredential(profile, null);
|
|
361
|
+
if (cancelled) await writePendingLogin(profile, null);
|
|
362
|
+
const revoked = signedOut && cancelled;
|
|
363
|
+
|
|
364
|
+
if (revoked) {
|
|
365
|
+
log.success('Signed out.');
|
|
366
|
+
} else {
|
|
367
|
+
log.warn(
|
|
368
|
+
'Brass could not be reached to revoke the sign-in, so it is kept on this machine. Run `brass logout` again when Brass is reachable.',
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
log.result({ signed_out: revoked, revoked });
|
|
372
|
+
return revoked ? 0 : 1;
|
|
253
373
|
}
|
|
254
374
|
|
|
255
375
|
async function runStatus(parsed: ParsedArgs, log: Logger): Promise<number> {
|
|
@@ -317,7 +437,7 @@ async function runStatus(parsed: ParsedArgs, log: Logger): Promise<number> {
|
|
|
317
437
|
return 0;
|
|
318
438
|
}
|
|
319
439
|
|
|
320
|
-
|
|
440
|
+
function planPublish(parsed: ParsedArgs): CommandPlan {
|
|
321
441
|
const dir = parsed.positionals[1] ?? 'dist';
|
|
322
442
|
const flagApp = stringFlag(parsed, 'app');
|
|
323
443
|
const envApp = process.env['BRASS_APP_ID'];
|
|
@@ -325,26 +445,29 @@ async function runPublish(ctx: Context, parsed: ParsedArgs): Promise<unknown> {
|
|
|
325
445
|
const org = stringFlag(parsed, 'org');
|
|
326
446
|
const slug = stringFlag(parsed, 'slug');
|
|
327
447
|
const clientToken = stringFlag(parsed, 'client-token');
|
|
448
|
+
const manifestPath = stringFlag(parsed, 'manifest') ?? 'brass-app.json';
|
|
328
449
|
const visibility = parseVisibilityFlag(stringFlag(parsed, 'visibility'));
|
|
329
450
|
const requireAccess = parseGateFlag(stringFlag(parsed, 'gate'));
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
451
|
+
return async (ctx): Promise<unknown> => {
|
|
452
|
+
const state = await readProjectState(ctx.cwd);
|
|
453
|
+
const appId = resolveAppId({
|
|
454
|
+
...(flagApp !== undefined ? { flagApp } : {}),
|
|
455
|
+
...(envApp !== undefined ? { envApp } : {}),
|
|
456
|
+
state,
|
|
457
|
+
profile: ctx.profile,
|
|
458
|
+
});
|
|
459
|
+
return publish(ctx, {
|
|
460
|
+
dir,
|
|
461
|
+
manifestPath,
|
|
462
|
+
...(appId !== null ? { appId } : {}),
|
|
463
|
+
...(name !== undefined ? { name } : {}),
|
|
464
|
+
...(org !== undefined ? { organizationId: org } : {}),
|
|
465
|
+
...(clientToken !== undefined ? { clientToken } : {}),
|
|
466
|
+
...(slug !== undefined ? { slug } : {}),
|
|
467
|
+
...(visibility !== undefined ? { visibility } : {}),
|
|
468
|
+
...(requireAccess !== undefined ? { requireAccess } : {}),
|
|
469
|
+
});
|
|
470
|
+
};
|
|
348
471
|
}
|
|
349
472
|
|
|
350
473
|
// How long `--wait` holds for the approval: bare `--wait` takes the default,
|
|
@@ -386,33 +509,48 @@ function parseGateFlag(value: string | undefined): boolean | undefined {
|
|
|
386
509
|
return value === 'on';
|
|
387
510
|
}
|
|
388
511
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
const docId = stringFlag(parsed, 'doc');
|
|
393
|
-
if (docId === undefined) throw new Error('brass schema pull requires --doc <docId>');
|
|
394
|
-
return schemaPull(ctx, { docId, outPath: stringFlag(parsed, 'out') ?? 'brass-app.json' });
|
|
512
|
+
function planSchema(parsed: ParsedArgs): CommandPlan {
|
|
513
|
+
if (parsed.positionals[1] !== 'pull') {
|
|
514
|
+
throw new Error('Usage: brass schema pull --doc <docId> [--out brass-app.json]');
|
|
395
515
|
}
|
|
396
|
-
|
|
516
|
+
const docId = stringFlag(parsed, 'doc');
|
|
517
|
+
if (docId === undefined) throw new Error('brass schema pull requires --doc <docId>');
|
|
518
|
+
const outPath = stringFlag(parsed, 'out') ?? 'brass-app.json';
|
|
519
|
+
return (ctx): Promise<unknown> => schemaPull(ctx, { docId, outPath });
|
|
397
520
|
}
|
|
398
521
|
|
|
399
|
-
|
|
522
|
+
function planAgents(parsed: ParsedArgs): CommandPlan {
|
|
400
523
|
if (parsed.positionals[1] !== 'pull') {
|
|
401
524
|
throw new Error(
|
|
402
525
|
'Usage: brass agents pull [--out AGENTS.md | --stdout] [--org <organizationId>]',
|
|
403
526
|
);
|
|
404
527
|
}
|
|
528
|
+
// Both flags claim stdout: `--stdout` puts the instructions there verbatim,
|
|
529
|
+
// `--json` the result object. Together they interleave two payloads on one
|
|
530
|
+
// stream, so a caller parsing either reads the other's bytes as part of it.
|
|
531
|
+
if (boolFlag(parsed, 'stdout') && boolFlag(parsed, 'json')) {
|
|
532
|
+
throw new Error(
|
|
533
|
+
'Pass one of --stdout or --json: both write to stdout, so together neither is parseable.',
|
|
534
|
+
);
|
|
535
|
+
}
|
|
405
536
|
const org = stringFlag(parsed, 'org');
|
|
406
|
-
|
|
537
|
+
const options = {
|
|
407
538
|
outPath: stringFlag(parsed, 'out') ?? 'AGENTS.md',
|
|
408
539
|
...(boolFlag(parsed, 'stdout') ? { stdout: true } : {}),
|
|
409
540
|
...(org !== undefined ? { organizationId: org } : {}),
|
|
410
|
-
}
|
|
541
|
+
};
|
|
542
|
+
return (ctx): Promise<unknown> => agentsPull(ctx, options);
|
|
411
543
|
}
|
|
412
544
|
|
|
413
|
-
|
|
545
|
+
// Render a failure for the terminal, naming the fix for a 401 in the
|
|
546
|
+
// vocabulary of the credential that was actually rejected. A session's
|
|
547
|
+
// refusal already arrives carrying `brass login` (the refresh authors it), so
|
|
548
|
+
// appending a service-token hint there tells the caller to check an
|
|
549
|
+
// environment variable they never set, next to the sentence naming the real
|
|
550
|
+
// fix. `status` classifies the same 401 the same way.
|
|
551
|
+
function formatError(err: unknown, credentialKind?: CredentialKind): string {
|
|
414
552
|
if (err instanceof BrassApiError) {
|
|
415
|
-
if (err.status === 401) {
|
|
553
|
+
if (err.status === 401 && credentialKind !== 'session') {
|
|
416
554
|
return `error: ${err.message} (the credential was rejected; check BRASS_SERVICE_TOKEN or --token)`;
|
|
417
555
|
}
|
|
418
556
|
return `error: ${err.message}`;
|