@brass-build/cli 0.1.0 → 0.2.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 +21 -0
- package/README.md +23 -20
- 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 +153 -58
- 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/login.d.ts +1 -0
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +10 -8
- 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 +186 -57
- package/src/commands.ts +66 -19
- package/src/login.ts +14 -8
- package/src/project.ts +39 -5
- package/src/session.ts +106 -21
- package/src/version.ts +1 -1
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.
|
|
@@ -245,10 +328,38 @@ async function runLogin(parsed: ParsedArgs, log: Logger): Promise<number> {
|
|
|
245
328
|
}
|
|
246
329
|
|
|
247
330
|
async function runLogout(parsed: ParsedArgs, log: Logger): Promise<number> {
|
|
248
|
-
const { profile } = resolveBase(parsed);
|
|
331
|
+
const { origins, profile } = resolveBase(parsed);
|
|
332
|
+
|
|
333
|
+
// Revoke on the server first, while the pointer is still readable. A
|
|
334
|
+
// service token belongs to an organization and is revoked in the dashboard,
|
|
335
|
+
// so only a stored session has anything to end here.
|
|
336
|
+
const file = await readCredentialsFile();
|
|
337
|
+
const stored = file?.credentials[profile]?.session?.sid;
|
|
338
|
+
const signedOut = stored === undefined ? true : await postSignOut(origins.authBaseUrl, stored);
|
|
339
|
+
|
|
340
|
+
// A started sign-in is redeemable by whoever holds the device code, and a
|
|
341
|
+
// human may still approve it after this command returns, so cancelling it on
|
|
342
|
+
// the server is what a sign-out owes. Dropping the local record alone would
|
|
343
|
+
// leave the grant live and this machine unable to name it.
|
|
344
|
+
const pending = await readPendingLogin(profile);
|
|
345
|
+
const cancelled =
|
|
346
|
+
pending === null ? true : await postDeviceCancel(origins.authBaseUrl, pending.deviceCode);
|
|
347
|
+
|
|
249
348
|
await writeStoredCredential(profile, null);
|
|
250
|
-
|
|
251
|
-
|
|
349
|
+
await writePendingLogin(profile, null);
|
|
350
|
+
const revoked = signedOut && cancelled;
|
|
351
|
+
|
|
352
|
+
// The local credential is gone either way, so say so, and name the part
|
|
353
|
+
// that did not happen rather than reporting a clean sign-out over a
|
|
354
|
+
// credential that still works.
|
|
355
|
+
if (revoked) {
|
|
356
|
+
log.success('Signed out.');
|
|
357
|
+
} else {
|
|
358
|
+
log.success(
|
|
359
|
+
'Signed out on this machine. Brass could not be reached to revoke the sign-in, so run `brass logout` again when it is.',
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
log.result({ signed_out: true, revoked });
|
|
252
363
|
return 0;
|
|
253
364
|
}
|
|
254
365
|
|
|
@@ -317,7 +428,7 @@ async function runStatus(parsed: ParsedArgs, log: Logger): Promise<number> {
|
|
|
317
428
|
return 0;
|
|
318
429
|
}
|
|
319
430
|
|
|
320
|
-
|
|
431
|
+
function planPublish(parsed: ParsedArgs): CommandPlan {
|
|
321
432
|
const dir = parsed.positionals[1] ?? 'dist';
|
|
322
433
|
const flagApp = stringFlag(parsed, 'app');
|
|
323
434
|
const envApp = process.env['BRASS_APP_ID'];
|
|
@@ -325,26 +436,29 @@ async function runPublish(ctx: Context, parsed: ParsedArgs): Promise<unknown> {
|
|
|
325
436
|
const org = stringFlag(parsed, 'org');
|
|
326
437
|
const slug = stringFlag(parsed, 'slug');
|
|
327
438
|
const clientToken = stringFlag(parsed, 'client-token');
|
|
439
|
+
const manifestPath = stringFlag(parsed, 'manifest') ?? 'brass-app.json';
|
|
328
440
|
const visibility = parseVisibilityFlag(stringFlag(parsed, 'visibility'));
|
|
329
441
|
const requireAccess = parseGateFlag(stringFlag(parsed, 'gate'));
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
442
|
+
return async (ctx): Promise<unknown> => {
|
|
443
|
+
const state = await readProjectState(ctx.cwd);
|
|
444
|
+
const appId = resolveAppId({
|
|
445
|
+
...(flagApp !== undefined ? { flagApp } : {}),
|
|
446
|
+
...(envApp !== undefined ? { envApp } : {}),
|
|
447
|
+
state,
|
|
448
|
+
profile: ctx.profile,
|
|
449
|
+
});
|
|
450
|
+
return publish(ctx, {
|
|
451
|
+
dir,
|
|
452
|
+
manifestPath,
|
|
453
|
+
...(appId !== null ? { appId } : {}),
|
|
454
|
+
...(name !== undefined ? { name } : {}),
|
|
455
|
+
...(org !== undefined ? { organizationId: org } : {}),
|
|
456
|
+
...(clientToken !== undefined ? { clientToken } : {}),
|
|
457
|
+
...(slug !== undefined ? { slug } : {}),
|
|
458
|
+
...(visibility !== undefined ? { visibility } : {}),
|
|
459
|
+
...(requireAccess !== undefined ? { requireAccess } : {}),
|
|
460
|
+
});
|
|
461
|
+
};
|
|
348
462
|
}
|
|
349
463
|
|
|
350
464
|
// How long `--wait` holds for the approval: bare `--wait` takes the default,
|
|
@@ -386,33 +500,48 @@ function parseGateFlag(value: string | undefined): boolean | undefined {
|
|
|
386
500
|
return value === 'on';
|
|
387
501
|
}
|
|
388
502
|
|
|
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' });
|
|
503
|
+
function planSchema(parsed: ParsedArgs): CommandPlan {
|
|
504
|
+
if (parsed.positionals[1] !== 'pull') {
|
|
505
|
+
throw new Error('Usage: brass schema pull --doc <docId> [--out brass-app.json]');
|
|
395
506
|
}
|
|
396
|
-
|
|
507
|
+
const docId = stringFlag(parsed, 'doc');
|
|
508
|
+
if (docId === undefined) throw new Error('brass schema pull requires --doc <docId>');
|
|
509
|
+
const outPath = stringFlag(parsed, 'out') ?? 'brass-app.json';
|
|
510
|
+
return (ctx): Promise<unknown> => schemaPull(ctx, { docId, outPath });
|
|
397
511
|
}
|
|
398
512
|
|
|
399
|
-
|
|
513
|
+
function planAgents(parsed: ParsedArgs): CommandPlan {
|
|
400
514
|
if (parsed.positionals[1] !== 'pull') {
|
|
401
515
|
throw new Error(
|
|
402
516
|
'Usage: brass agents pull [--out AGENTS.md | --stdout] [--org <organizationId>]',
|
|
403
517
|
);
|
|
404
518
|
}
|
|
519
|
+
// Both flags claim stdout: `--stdout` puts the instructions there verbatim,
|
|
520
|
+
// `--json` the result object. Together they interleave two payloads on one
|
|
521
|
+
// stream, so a caller parsing either reads the other's bytes as part of it.
|
|
522
|
+
if (boolFlag(parsed, 'stdout') && boolFlag(parsed, 'json')) {
|
|
523
|
+
throw new Error(
|
|
524
|
+
'Pass one of --stdout or --json: both write to stdout, so together neither is parseable.',
|
|
525
|
+
);
|
|
526
|
+
}
|
|
405
527
|
const org = stringFlag(parsed, 'org');
|
|
406
|
-
|
|
528
|
+
const options = {
|
|
407
529
|
outPath: stringFlag(parsed, 'out') ?? 'AGENTS.md',
|
|
408
530
|
...(boolFlag(parsed, 'stdout') ? { stdout: true } : {}),
|
|
409
531
|
...(org !== undefined ? { organizationId: org } : {}),
|
|
410
|
-
}
|
|
532
|
+
};
|
|
533
|
+
return (ctx): Promise<unknown> => agentsPull(ctx, options);
|
|
411
534
|
}
|
|
412
535
|
|
|
413
|
-
|
|
536
|
+
// Render a failure for the terminal, naming the fix for a 401 in the
|
|
537
|
+
// vocabulary of the credential that was actually rejected. A session's
|
|
538
|
+
// refusal already arrives carrying `brass login` (the refresh authors it), so
|
|
539
|
+
// appending a service-token hint there tells the caller to check an
|
|
540
|
+
// environment variable they never set, next to the sentence naming the real
|
|
541
|
+
// fix. `status` classifies the same 401 the same way.
|
|
542
|
+
function formatError(err: unknown, credentialKind?: CredentialKind): string {
|
|
414
543
|
if (err instanceof BrassApiError) {
|
|
415
|
-
if (err.status === 401) {
|
|
544
|
+
if (err.status === 401 && credentialKind !== 'session') {
|
|
416
545
|
return `error: ${err.message} (the credential was rejected; check BRASS_SERVICE_TOKEN or --token)`;
|
|
417
546
|
}
|
|
418
547
|
return `error: ${err.message}`;
|
package/src/commands.ts
CHANGED
|
@@ -109,7 +109,7 @@ export async function publish(ctx: CommandContext, opts: PublishOptions): Promis
|
|
|
109
109
|
if (opts.visibility !== undefined) {
|
|
110
110
|
await ensureVisibility(ctx, appId, opts.visibility, resolved.detail?.visibility);
|
|
111
111
|
}
|
|
112
|
-
const hosting = await ensureHosting(ctx, appId, opts.slug);
|
|
112
|
+
const hosting = await ensureHosting(ctx, appId, opts.slug, opts.requireAccess);
|
|
113
113
|
if (opts.requireAccess !== undefined) {
|
|
114
114
|
await ensureGate(ctx, appId, opts.requireAccess, hosting);
|
|
115
115
|
}
|
|
@@ -126,7 +126,12 @@ export async function publish(ctx: CommandContext, opts: PublishOptions): Promis
|
|
|
126
126
|
// of a couple of reads instead of an upload and a poll loop.
|
|
127
127
|
const unchanged = await activeVersionMatches(ctx, appId, hash);
|
|
128
128
|
if (unchanged !== null) {
|
|
129
|
-
const status = await
|
|
129
|
+
const status = await awaitGateSettled(
|
|
130
|
+
ctx,
|
|
131
|
+
appId,
|
|
132
|
+
await ctx.api.get<HostingStatus>(`/apps/${encodeURIComponent(appId)}/hosting`),
|
|
133
|
+
opts.sleep ?? realSleep,
|
|
134
|
+
);
|
|
130
135
|
ctx.log.success(`Already up to date${status.url ? `: ${status.url}` : ''}`);
|
|
131
136
|
// Refreshed here too, so publishing twice says the same thing both times.
|
|
132
137
|
// A publisher acting on a warning re-runs publish to check, and a signal
|
|
@@ -164,7 +169,12 @@ export async function publish(ctx: CommandContext, opts: PublishOptions): Promis
|
|
|
164
169
|
// schema missing `family`) right here instead of silently later.
|
|
165
170
|
const warnings = await refreshCapabilities(ctx, appId);
|
|
166
171
|
|
|
167
|
-
const status = await
|
|
172
|
+
const status = await awaitGateSettled(
|
|
173
|
+
ctx,
|
|
174
|
+
appId,
|
|
175
|
+
await ctx.api.get<HostingStatus>(`/apps/${encodeURIComponent(appId)}/hosting`),
|
|
176
|
+
opts.sleep ?? realSleep,
|
|
177
|
+
);
|
|
168
178
|
if (status.url) ctx.log.success(`Deployed: ${status.url}`);
|
|
169
179
|
return { app_id: appId, version_id: version.version_id, url: status.url, warnings };
|
|
170
180
|
}
|
|
@@ -223,14 +233,13 @@ async function ensureVisibility(
|
|
|
223
233
|
}
|
|
224
234
|
|
|
225
235
|
// Converge the hosted load gate to `want` (`true` = gated to the audience,
|
|
226
|
-
// `false` = world-loadable). Always PATCH, even when the
|
|
227
|
-
// reads `want`: the PATCH is what
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
// the log line. (`require_access` absent === off.)
|
|
236
|
+
// `false` = world-loadable). Always PATCH, even when the reported state
|
|
237
|
+
// already reads `want`: the PATCH is what asks the platform to re-settle a
|
|
238
|
+
// gate whose reported and served states have drifted apart, which reading the
|
|
239
|
+
// reported one alone cannot detect. Skipping it on a match would leave such an
|
|
240
|
+
// app stuck, since every later publish would skip it too. `status` is the
|
|
241
|
+
// state `ensureHosting` just observed, used only to word the log line.
|
|
242
|
+
// (`require_access` absent === off.)
|
|
234
243
|
async function ensureGate(
|
|
235
244
|
ctx: CommandContext,
|
|
236
245
|
appId: string,
|
|
@@ -250,15 +259,22 @@ async function ensureGate(
|
|
|
250
259
|
}
|
|
251
260
|
}
|
|
252
261
|
|
|
262
|
+
// Enable hosting when the app is not hosted yet, at the load-gate state the
|
|
263
|
+
// publish wants. Stating the gate here rather than leaving it to the PATCH
|
|
264
|
+
// below settles it in one step: a new slot is gated by default, so a publish
|
|
265
|
+
// that wants a world-loadable one would otherwise turn the gate on and
|
|
266
|
+
// straight back off, doing twice the work to reach one state.
|
|
253
267
|
async function ensureHosting(
|
|
254
268
|
ctx: CommandContext,
|
|
255
269
|
appId: string,
|
|
256
270
|
slug?: string,
|
|
271
|
+
requireAccess?: boolean,
|
|
257
272
|
): Promise<HostingStatus> {
|
|
258
273
|
const status = await ctx.api.get<HostingStatus>(`/apps/${encodeURIComponent(appId)}/hosting`);
|
|
259
274
|
if (status.enabled) return status;
|
|
260
|
-
const body: { slug?: string } = {};
|
|
275
|
+
const body: { slug?: string; require_access?: boolean } = {};
|
|
261
276
|
if (slug !== undefined) body.slug = slug;
|
|
277
|
+
if (requireAccess !== undefined) body.require_access = requireAccess;
|
|
262
278
|
const enabled = await ctx.api.post<HostingStatus>(
|
|
263
279
|
`/apps/${encodeURIComponent(appId)}/hosting`,
|
|
264
280
|
body,
|
|
@@ -267,11 +283,42 @@ async function ensureHosting(
|
|
|
267
283
|
return enabled;
|
|
268
284
|
}
|
|
269
285
|
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
286
|
+
// A published bundle is not reachable until the platform has registered the
|
|
287
|
+
// slot to serve it, and that registration can lag or fail on its own. Reading
|
|
288
|
+
// the status is what asks the platform to settle it, so a slot that is not
|
|
289
|
+
// ready yet is re-read a few times before publish gives up. Publishing
|
|
290
|
+
// reports success only once the slot will actually serve, so an unreachable
|
|
291
|
+
// one is a failed publish rather than a URL that answers 404.
|
|
292
|
+
const GATE_SETTLE_ATTEMPTS = 3;
|
|
293
|
+
const GATE_SETTLE_GAP_MS = 2000;
|
|
294
|
+
|
|
295
|
+
async function awaitGateSettled(
|
|
296
|
+
ctx: CommandContext,
|
|
297
|
+
appId: string,
|
|
298
|
+
status: HostingStatus,
|
|
299
|
+
sleep: (ms: number) => Promise<void>,
|
|
300
|
+
): Promise<HostingStatus> {
|
|
301
|
+
// An api that does not report the field tells us nothing to act on.
|
|
302
|
+
if (status.gate_settled !== false) return status;
|
|
303
|
+
for (let attempt = 1; attempt < GATE_SETTLE_ATTEMPTS; attempt++) {
|
|
304
|
+
await sleep(GATE_SETTLE_GAP_MS);
|
|
305
|
+
const latest = await ctx.api.get<HostingStatus>(`/apps/${encodeURIComponent(appId)}/hosting`);
|
|
306
|
+
if (latest.gate_settled !== false) return latest;
|
|
307
|
+
}
|
|
308
|
+
// Says what is true of the SLOT, because both publish paths end here: the
|
|
309
|
+
// one that uploaded a new bundle and the one that found the app already
|
|
310
|
+
// serving this exact bundle and skipped the upload. A message naming an
|
|
311
|
+
// upload sends the second caller looking for one that never happened.
|
|
312
|
+
throw new Error(
|
|
313
|
+
'The hosted slot never registered, so it will not serve. ' +
|
|
314
|
+
'Run publish again to retry.',
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// The currently-served version when it is `ready` and already carries `hash`,
|
|
319
|
+
// else null. A null (no active version, a non-ready active version, or a hash
|
|
320
|
+
// mismatch) means `publish` must upload. A first deploy has no active version,
|
|
321
|
+
// so it always uploads.
|
|
275
322
|
async function activeVersionMatches(
|
|
276
323
|
ctx: CommandContext,
|
|
277
324
|
appId: string,
|
|
@@ -287,8 +334,8 @@ async function activeVersionMatches(
|
|
|
287
334
|
|
|
288
335
|
// Poll until the version reaches a KNOWN terminal state (`ready` / `failed`),
|
|
289
336
|
// then return it; time out otherwise. Deliberately loops while the status is
|
|
290
|
-
// anything other than a known terminal
|
|
291
|
-
// CLI does not recognize
|
|
337
|
+
// anything other than a known terminal (`pending`, or any status this pinned
|
|
338
|
+
// CLI does not recognize) rather than returning on `!== 'pending'`. That way
|
|
292
339
|
// a future server status (a finer-grained non-terminal like `unpacking`, or a
|
|
293
340
|
// new terminal-failure like `rejected`) is not mistaken for "done": an
|
|
294
341
|
// unrecognized non-terminal keeps polling, and an unrecognized terminal
|