firedrill-run 3.0.0 → 3.0.1
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/README.md +7 -9
- package/dist/assembly.json +1 -1
- package/dist/cli/src/auth.js +41 -0
- package/dist/cli/src/main.js +7 -2
- package/dist/cli/src/simple/init.js +37 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,19 +9,17 @@ Use the CLI yourself, or let a coding agent operate it for you. Local execution
|
|
|
9
9
|
is not an operating-system sandbox. Approved commands run with your own user
|
|
10
10
|
permissions.
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
the intended commands are:
|
|
12
|
+
## Get started
|
|
13
|
+
|
|
14
|
+
Use this version explicitly while the unversioned package still points to the
|
|
15
|
+
previous platform:
|
|
17
16
|
|
|
18
17
|
```sh
|
|
19
|
-
npx
|
|
20
|
-
npx
|
|
18
|
+
npx firedrill-run@3.0.1 init
|
|
19
|
+
npx firedrill-run@3.0.1 --help
|
|
21
20
|
```
|
|
22
21
|
|
|
23
|
-
|
|
24
|
-
`firedrill --help` of that exact installed candidate is authoritative.
|
|
22
|
+
For automation, `firedrill --help` and `--json` describe the installed version.
|
|
25
23
|
|
|
26
24
|
## Requirements
|
|
27
25
|
|
package/dist/assembly.json
CHANGED
package/dist/cli/src/auth.js
CHANGED
|
@@ -130,6 +130,15 @@ export async function login(options) {
|
|
|
130
130
|
throw new CliError('authorization_exchange_uncertain', 'The original token exchange was interrupted and cannot be safely repeated. Review Account access in the browser. Clear the local attempt with logout --local-only before starting another.', 2);
|
|
131
131
|
if (!pending) {
|
|
132
132
|
const grant = await jsonRequest(new URL(config.device_authorization_endpoint), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: config.client_id }), signal: options.signal });
|
|
133
|
+
if (grant.status === 429) {
|
|
134
|
+
// No grant exists yet. Report a definitive refusal without creating a
|
|
135
|
+
// second grant or replaying an unknown device-authorization POST.
|
|
136
|
+
const wait = grant.retryAfterMs;
|
|
137
|
+
const advice = wait !== undefined && Number.isFinite(wait) && wait <= 3_600_000
|
|
138
|
+
? ` Wait at least ${Math.max(1, Math.ceil(wait / 1000))} seconds before starting sign-in again.`
|
|
139
|
+
: ' Wait before starting sign-in again; do not repeatedly restart it.';
|
|
140
|
+
throw new CliError('device_authorization_rate_limited', 'Browser sign-in is temporarily busy.' + advice, 2);
|
|
141
|
+
}
|
|
133
142
|
if (grant.status !== 200)
|
|
134
143
|
throw new CliError('device_authorization_failed', 'Browser sign-in could not be started.');
|
|
135
144
|
const value = grant.body;
|
|
@@ -166,6 +175,7 @@ export async function login(options) {
|
|
|
166
175
|
: 'Run the same command to resume if interrupted.'}\n`);
|
|
167
176
|
if (launchBrowser)
|
|
168
177
|
await openBrowser(pending.verification_url);
|
|
178
|
+
let rateRefusals = 0;
|
|
169
179
|
while (Date.now() < pending.expires_at * 1000) {
|
|
170
180
|
await sleep(Math.min(Math.max(0, pending.next_poll_at - Date.now()), Math.max(0, pending.expires_at * 1000 - Date.now())), undefined, { signal: options.signal });
|
|
171
181
|
options.signal.throwIfAborted();
|
|
@@ -183,7 +193,38 @@ export async function login(options) {
|
|
|
183
193
|
return finishAuthorization(saved);
|
|
184
194
|
}
|
|
185
195
|
const error = result.body && typeof result.body === 'object' ? result.body.error : undefined;
|
|
196
|
+
if (result.status === 429) {
|
|
197
|
+
// A complete, parsed HTTP refusal is distinct from transport/parse
|
|
198
|
+
// failure or a response that may contain exchanged credentials. Those
|
|
199
|
+
// uncertain cases keep the polling marker and are never replayed.
|
|
200
|
+
const body = result.body;
|
|
201
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)
|
|
202
|
+
|| ['access_token', 'refresh_token', 'id_token', 'user'].some(key => key in body))
|
|
203
|
+
throw new CliError('authorization_exchange_uncertain', 'The authorization response was not confirmed. Review Account access before clearing the original local attempt. No exchange was retried.', 2);
|
|
204
|
+
rateRefusals += 1;
|
|
205
|
+
const interval = error === 'slow_down' ? Math.min(3600, pending.interval + 5) : pending.interval;
|
|
206
|
+
const backoff = Math.min(60_000, 5_000 * 2 ** Math.min(rateRefusals - 1, 4));
|
|
207
|
+
const minimumWait = Math.max(interval * 1000, result.retryAfterMs ?? backoff);
|
|
208
|
+
// Never retry earlier than Retry-After or extend the original grant.
|
|
209
|
+
// An advertised wait beyond that lifetime reaches expiry, not a retry.
|
|
210
|
+
const deadline = pending.expires_at * 1000;
|
|
211
|
+
const nextPollAt = Math.min(deadline, Date.now() + minimumWait + Math.floor(Math.random() * 1000));
|
|
212
|
+
pending = { ...pending, state: 'waiting', interval, next_poll_at: nextPollAt };
|
|
213
|
+
await savePendingDevice(pending, options.signal);
|
|
214
|
+
const waitSeconds = Math.max(1, Math.ceil((nextPollAt - Date.now()) / 1000));
|
|
215
|
+
if (options.json)
|
|
216
|
+
process.stdout.write(JSON.stringify({ type: 'authorization_waiting', authorization_id: pending.id,
|
|
217
|
+
reason: 'rate_limited', retry_in_seconds: nextPollAt < deadline ? waitSeconds : null,
|
|
218
|
+
expires_in: Math.max(0, Math.ceil((deadline - Date.now()) / 1000)),
|
|
219
|
+
same_authorization: true, new_device_grant: false }) + '\n');
|
|
220
|
+
else
|
|
221
|
+
process.stderr.write(nextPollAt < deadline
|
|
222
|
+
? `Sign-in is temporarily busy. Waiting ${waitSeconds} seconds before checking the same authorization again.\n`
|
|
223
|
+
: 'Sign-in is temporarily busy. Its required wait reaches the original authorization expiry; no new authorization will be started automatically.\n');
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
186
226
|
if (error === 'authorization_pending' || error === 'slow_down') {
|
|
227
|
+
rateRefusals = 0;
|
|
187
228
|
// RFC 8628: slow_down permanently adds 5 seconds to the polling interval.
|
|
188
229
|
const interval = error === 'slow_down' ? Math.min(3600, pending.interval + 5) : pending.interval;
|
|
189
230
|
pending = { ...pending, state: 'waiting', interval, next_poll_at: Date.now() + interval * 1000 };
|
package/dist/cli/src/main.js
CHANGED
|
@@ -745,12 +745,17 @@ export async function main() {
|
|
|
745
745
|
if (args[0] === 'init') {
|
|
746
746
|
if (Object.keys(parsed.values).some(key => !['json', 'help', 'workspace', 'no-browser', 'open'].includes(key)))
|
|
747
747
|
throw new CliError('arguments_invalid', 'Use firedrill init with optional --session-only or --persistent, --workspace <id>, --no-browser, --open and --json.', 2);
|
|
748
|
-
await initCommand(args.slice(1), { json, signal: controller.signal, workspace: parsed.values.workspace,
|
|
748
|
+
const initialization = await initCommand(args.slice(1), { json, signal: controller.signal, workspace: parsed.values.workspace,
|
|
749
749
|
openBrowser: !parsed.values['no-browser'], explicitOpen, mode: credentialMode.mode }, emit);
|
|
750
750
|
if (companion) {
|
|
751
751
|
// Session-only credentials live only in this process: keep serving until
|
|
752
752
|
// a signal or logout (companion shutdown) stops it.
|
|
753
|
-
emit('companion_running', { endpoint_ready: true, mode: 'session'
|
|
753
|
+
emit('companion_running', { endpoint_ready: true, mode: 'session', initialization_state: initialization.state,
|
|
754
|
+
initialization_complete: initialization.state === 'ready',
|
|
755
|
+
command_phase_complete: true,
|
|
756
|
+
workspace_id: initialization.state === 'ready' ? initialization.workspace_id : null,
|
|
757
|
+
process_lifetime: 'until_logout_or_signal',
|
|
758
|
+
next_command: initialization.state === 'workspace_pending' ? 'firedrill workspace list --session-only --json' : null }, 'Session-only companion running. Keep this terminal open; other firedrill commands use it. Stop it with Ctrl-C or firedrill logout.');
|
|
754
759
|
if (!controller.signal.aborted)
|
|
755
760
|
await once(controller.signal, 'abort');
|
|
756
761
|
emit('companion_stopping', { reason: signalled ? 'signal' : 'shutdown' }, 'Stopping the companion. In-memory credentials are erased on close.');
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { api, login } from '../auth.js';
|
|
2
|
-
import { CliError } from '../http.js';
|
|
2
|
+
import { APP_ORIGIN, CliError } from '../http.js';
|
|
3
3
|
import { terminalText } from '../prompts.js';
|
|
4
4
|
import { selectedWorkspace } from '../world-access.js';
|
|
5
5
|
import { machineReport, machineText } from './status.js';
|
|
@@ -40,7 +40,41 @@ export async function initCommand(args, options, emit) {
|
|
|
40
40
|
throw error;
|
|
41
41
|
session = await login({ json: options.json, open: options.openBrowser, explicitOpen: options.explicitOpen, signal: options.signal });
|
|
42
42
|
}
|
|
43
|
-
|
|
43
|
+
let scope;
|
|
44
|
+
try {
|
|
45
|
+
scope = await selectedWorkspace({ workspace: options.workspace, interactive, signal: options.signal, openBrowser });
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
// Device authorization already succeeded. A durable workspace discovery or
|
|
49
|
+
// explicit setup step must not erase the only copy of that signed-in session.
|
|
50
|
+
// Only session-only init owns a companion lifetime; persistent commands and
|
|
51
|
+
// authorization/transport/cancellation failures retain their normal errors.
|
|
52
|
+
if (mode !== 'session' || options.signal.aborted || !(error instanceof CliError)
|
|
53
|
+
|| !['workspace_discovery_pending', 'workspace_setup_required'].includes(error.code))
|
|
54
|
+
throw error;
|
|
55
|
+
const reason = error.code === 'workspace_discovery_pending' ? 'workspace_discovery_pending' : 'workspace_setup_required';
|
|
56
|
+
emit('init_pending', {
|
|
57
|
+
state: 'workspace_pending', reason, authenticated: true, initialization_complete: false,
|
|
58
|
+
account: { id: session.user.id, email: session.user.email }, workspace_id: null, credential_mode: 'session',
|
|
59
|
+
companion_required: true, process_lifetime: 'until_logout_or_signal',
|
|
60
|
+
next_command: 'firedrill workspace list --session-only --json',
|
|
61
|
+
select_command_template: 'firedrill workspace use <workspace-id> --session-only --json',
|
|
62
|
+
status_command: 'firedrill status --session-only --json',
|
|
63
|
+
...(reason === 'workspace_setup_required' ? { browser_url: new URL('/onboarding', APP_ORIGIN).href,
|
|
64
|
+
action_required: 'create_or_select_workspace_with_person_approval' } : {}),
|
|
65
|
+
uploaded: false, model_called: false,
|
|
66
|
+
}, [
|
|
67
|
+
`Signed in as ${terminalText(session.user.email)}.`,
|
|
68
|
+
reason === 'workspace_discovery_pending' ? 'Workspace access is still being checked.' : 'A workspace still needs to be created or selected.',
|
|
69
|
+
'Initialization is not complete. Keep this terminal open to retain the session-only sign-in.',
|
|
70
|
+
'In another terminal, run firedrill workspace list --session-only, then firedrill workspace use <workspace-id> --session-only.',
|
|
71
|
+
'Use only a workspace returned for this account. Normal organization authorization and person approvals still apply.',
|
|
72
|
+
...(reason === 'workspace_setup_required' ? [`Workspace setup: ${new URL('/onboarding', APP_ORIGIN).href}`] : []),
|
|
73
|
+
'After selecting it, firedrill status --session-only shows the current account, workspace and machine.',
|
|
74
|
+
'Do not start another init while this companion is running. Stop with Ctrl-C or firedrill logout.',
|
|
75
|
+
].join('\n'));
|
|
76
|
+
return { state: 'workspace_pending', reason };
|
|
77
|
+
}
|
|
44
78
|
const machine = await machineReport(options.signal);
|
|
45
79
|
emit('init', {
|
|
46
80
|
account: { id: session.user.id, email: session.user.email }, workspace_id: scope.workspace, credential_mode: mode, machine,
|
|
@@ -58,4 +92,5 @@ export async function initCommand(args, options, emit) {
|
|
|
58
92
|
...NEXT.map((step, index) => ` ${index + 1}. ${step.command}\n ${step.note}`),
|
|
59
93
|
'Nothing was uploaded and no model was called.',
|
|
60
94
|
].join('\n'));
|
|
95
|
+
return { state: 'ready', workspace_id: scope.workspace };
|
|
61
96
|
}
|