@delegance/claude-autopilot 6.2.2 → 7.2.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/CHANGELOG.md +886 -0
- package/README.md +10 -1
- package/bin/_launcher.js +38 -23
- package/dist/src/cli/autopilot.d.ts +4 -0
- package/dist/src/cli/autopilot.js +15 -0
- package/dist/src/cli/dashboard/index.d.ts +5 -0
- package/dist/src/cli/dashboard/index.js +49 -0
- package/dist/src/cli/dashboard/login.d.ts +22 -0
- package/dist/src/cli/dashboard/login.js +260 -0
- package/dist/src/cli/dashboard/logout.d.ts +12 -0
- package/dist/src/cli/dashboard/logout.js +45 -0
- package/dist/src/cli/dashboard/status.d.ts +30 -0
- package/dist/src/cli/dashboard/status.js +65 -0
- package/dist/src/cli/dashboard/upload.d.ts +16 -0
- package/dist/src/cli/dashboard/upload.js +48 -0
- package/dist/src/cli/engine-flag-deprecation.d.ts +14 -0
- package/dist/src/cli/engine-flag-deprecation.js +20 -0
- package/dist/src/cli/help-text.d.ts +1 -1
- package/dist/src/cli/help-text.js +44 -28
- package/dist/src/cli/index.d.ts +2 -1
- package/dist/src/cli/index.js +72 -17
- package/dist/src/cli/scaffold.d.ts +39 -0
- package/dist/src/cli/scaffold.js +287 -0
- package/dist/src/cli/setup.d.ts +30 -0
- package/dist/src/cli/setup.js +137 -0
- package/dist/src/core/run-state/events.js +10 -2
- package/dist/src/core/run-state/resolve-engine.d.ts +26 -81
- package/dist/src/core/run-state/resolve-engine.js +39 -155
- package/dist/src/core/run-state/run-phase-with-lifecycle.d.ts +5 -9
- package/dist/src/core/run-state/run-phase-with-lifecycle.js +26 -19
- package/dist/src/core/run-state/state.d.ts +1 -1
- package/dist/src/core/run-state/types.d.ts +8 -2
- package/dist/src/core/run-state/types.js +8 -2
- package/dist/src/dashboard/auto-upload.d.ts +26 -0
- package/dist/src/dashboard/auto-upload.js +107 -0
- package/dist/src/dashboard/config.d.ts +22 -0
- package/dist/src/dashboard/config.js +109 -0
- package/dist/src/dashboard/upload/canonical.d.ts +3 -0
- package/dist/src/dashboard/upload/canonical.js +16 -0
- package/dist/src/dashboard/upload/chain.d.ts +9 -0
- package/dist/src/dashboard/upload/chain.js +27 -0
- package/dist/src/dashboard/upload/snapshot.d.ts +23 -0
- package/dist/src/dashboard/upload/snapshot.js +66 -0
- package/dist/src/dashboard/upload/uploader.d.ts +54 -0
- package/dist/src/dashboard/upload/uploader.js +330 -0
- package/package.json +18 -3
- package/scripts/test-runner.mjs +4 -0
package/README.md
CHANGED
|
@@ -4,7 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
**Autonomous development pipeline for Claude Code. Brainstorm → spec → plan → implement → migrate → validate → PR → review → merge — all from your terminal, on your codebase, with your test suite.**
|
|
6
6
|
|
|
7
|
-
**Open source, MIT-licensed, runs on your machine with your API keys.** No hosted agent, no per-seat subscription — `npm install -g @delegance/claude-autopilot` and you're done.
|
|
7
|
+
**Open source, MIT-licensed, runs on your machine with your API keys.** No hosted agent, no per-seat subscription — `npm install -g @delegance/claude-autopilot@latest` and you're done.
|
|
8
|
+
|
|
9
|
+
## Hosted product (v7)
|
|
10
|
+
|
|
11
|
+
A hosted dashboard at **[autopilot.dev](https://autopilot.dev)** complements the self-hosted CLI. The CLI keeps doing all the work locally on your machine — running models, writing code, opening PRs — and optionally uploads each completed run's state + cost summary to the hosted dashboard so your team can see what's been shipped, audit cost, and manage memberships from the browser.
|
|
12
|
+
|
|
13
|
+
- **CLI (this package)** — local-first, no telemetry by default, your machine + your API keys. Pipeline stays the same.
|
|
14
|
+
- **Dashboard ([autopilot.dev](https://autopilot.dev))** — opt-in. After `claude-autopilot dashboard login` mints a personal API key via a loopback OAuth flow, every engine-on autopilot run uploads its `state.json` + `events.ndjson` + cost roll-up at run.complete. Org admin (members, billing, SSO) lives there.
|
|
15
|
+
|
|
16
|
+
The CLI works offline; the dashboard is purely additive. See [docs/v7/runbook.md](./docs/v7/runbook.md) for operating the hosted product and [docs/v7/breaking-changes.md](./docs/v7/breaking-changes.md) for the v6→v7 migration checklist.
|
|
8
17
|
|
|
9
18
|
```bash
|
|
10
19
|
claude-autopilot brainstorm "add SSO with SAML for enterprise tenants"
|
package/bin/_launcher.js
CHANGED
|
@@ -31,44 +31,52 @@ function findTsx() {
|
|
|
31
31
|
return 'tsx';
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
// v7.1.7 — Per-calendar-day deprecation dedup, keyed in the user's home dir.
|
|
35
|
+
//
|
|
36
|
+
// The previous (v6.3+) implementation used a temp file keyed by `process.ppid
|
|
37
|
+
// + stderr.isTTY` to dedup once per "terminal session." That worked in
|
|
38
|
+
// interactive shells but FAILED for the most common deprecation trigger —
|
|
39
|
+
// the pre-commit/pre-push git hooks. Git spawns a fresh shell for each hook
|
|
40
|
+
// invocation, so the parent PID is fresh on every commit, the stamp file
|
|
41
|
+
// path is unique each time, and the notice printed on every single commit.
|
|
42
|
+
// The v7.1.6 blank-repo benchmark agent surfaced this as the #1 paper cut.
|
|
43
|
+
//
|
|
44
|
+
// New strategy: stamp at `~/.claude-autopilot/.deprecation-shown`, contents =
|
|
45
|
+
// `YYYY-MM-DD` (UTC). Show at most once per day per machine. Operator gets a
|
|
46
|
+
// daily reminder of the rename without per-commit spam. Override env vars
|
|
47
|
+
// (`CLAUDE_AUTOPILOT_DEPRECATION=always|never`) preserved.
|
|
48
|
+
const DEPRECATION_STAMP_PATH = path.join(os.homedir(), '.claude-autopilot', '.deprecation-shown');
|
|
49
|
+
function todayUtc() {
|
|
50
|
+
return new Date().toISOString().slice(0, 10);
|
|
51
|
+
}
|
|
52
|
+
function hasShownDeprecationToday() {
|
|
39
53
|
try {
|
|
40
|
-
if (!fs.existsSync(
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
const key = `${process.ppid}-${process.stderr.isTTY ? 'tty' : 'pipe'}.stamp`;
|
|
44
|
-
const stampPath = path.join(DEPRECATION_STAMP_DIR, key);
|
|
45
|
-
if (fs.existsSync(stampPath)) return true;
|
|
46
|
-
fs.writeFileSync(stampPath, String(Date.now()));
|
|
47
|
-
// Best-effort cleanup of stamps older than 1h to keep tmpdir tidy.
|
|
48
|
-
const cutoff = Date.now() - 60 * 60 * 1000;
|
|
49
|
-
for (const f of fs.readdirSync(DEPRECATION_STAMP_DIR)) {
|
|
50
|
-
const p = path.join(DEPRECATION_STAMP_DIR, f);
|
|
51
|
-
try {
|
|
52
|
-
if (fs.statSync(p).mtimeMs < cutoff) fs.unlinkSync(p);
|
|
53
|
-
} catch { /* ignore */ }
|
|
54
|
-
}
|
|
55
|
-
return false;
|
|
54
|
+
if (!fs.existsSync(DEPRECATION_STAMP_PATH)) return false;
|
|
55
|
+
return fs.readFileSync(DEPRECATION_STAMP_PATH, 'utf8').trim() === todayUtc();
|
|
56
56
|
} catch {
|
|
57
|
+
// Stamp unreadable — show notice (better than silently swallowing).
|
|
57
58
|
return false;
|
|
58
59
|
}
|
|
59
60
|
}
|
|
61
|
+
function markDeprecationShown() {
|
|
62
|
+
try {
|
|
63
|
+
const dir = path.dirname(DEPRECATION_STAMP_PATH);
|
|
64
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
65
|
+
fs.writeFileSync(DEPRECATION_STAMP_PATH, todayUtc());
|
|
66
|
+
} catch { /* best-effort; missing stamp re-prints next invocation */ }
|
|
67
|
+
}
|
|
60
68
|
|
|
61
69
|
/**
|
|
62
70
|
* Decide whether to emit the deprecation notice. Order:
|
|
63
71
|
* CLAUDE_AUTOPILOT_DEPRECATION=never → never emit (CI/automation)
|
|
64
72
|
* CLAUDE_AUTOPILOT_DEPRECATION=always → always emit (deterministic testing)
|
|
65
|
-
* otherwise → once per
|
|
73
|
+
* otherwise → at most once per UTC day
|
|
66
74
|
*/
|
|
67
75
|
function shouldEmitDeprecation() {
|
|
68
76
|
const override = process.env.CLAUDE_AUTOPILOT_DEPRECATION;
|
|
69
77
|
if (override === 'never') return false;
|
|
70
78
|
if (override === 'always') return true;
|
|
71
|
-
return !
|
|
79
|
+
return !hasShownDeprecationToday();
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
/**
|
|
@@ -83,6 +91,13 @@ export function launch(opts) {
|
|
|
83
91
|
'Migration guide: https://github.com/axledbetter/claude-autopilot/blob/master/docs/migration/v4-to-v5.md\n' +
|
|
84
92
|
'Silence: set CLAUDE_AUTOPILOT_DEPRECATION=never\n',
|
|
85
93
|
);
|
|
94
|
+
// v7.1.7 — mark stamp AFTER successful emission so a write-failure on
|
|
95
|
+
// stderr still results in the next invocation re-trying. Skip when
|
|
96
|
+
// CLAUDE_AUTOPILOT_DEPRECATION=always (deterministic-testing override
|
|
97
|
+
// shouldn't write the stamp).
|
|
98
|
+
if (process.env.CLAUDE_AUTOPILOT_DEPRECATION !== 'always') {
|
|
99
|
+
markDeprecationShown();
|
|
100
|
+
}
|
|
86
101
|
}
|
|
87
102
|
|
|
88
103
|
const entry = resolveEntry();
|
|
@@ -23,6 +23,10 @@ export interface AutopilotOptions {
|
|
|
23
23
|
/** Test seam — keep stdout banners suppressed. Production callers MUST
|
|
24
24
|
* NOT pass this; the dispatcher does not surface a flag for it. */
|
|
25
25
|
__silent?: boolean;
|
|
26
|
+
/** v7.0 Phase 2.3 — opt out of auto-upload at run.complete. Surfaced by
|
|
27
|
+
* the CLI as `--no-upload`. Equivalent to `CLAUDE_AUTOPILOT_UPLOAD=off`
|
|
28
|
+
* but scoped per-invocation. */
|
|
29
|
+
noUpload?: boolean;
|
|
26
30
|
}
|
|
27
31
|
export interface AutopilotPhaseSummary {
|
|
28
32
|
name: string;
|
|
@@ -350,6 +350,21 @@ export async function runAutopilot(options = {}) {
|
|
|
350
350
|
process.stdout.write(fmt(ANSI_DIM, ` resume: claude-autopilot run resume ${created.runId}\n`));
|
|
351
351
|
}
|
|
352
352
|
}
|
|
353
|
+
// --- v7.0 Phase 2.3 — auto-upload at run.complete ----------------
|
|
354
|
+
// Non-fatal: never overrides the run's exitCode. Failure prints a
|
|
355
|
+
// resume command via the helper itself. Skipped silently when the
|
|
356
|
+
// user isn't logged in or has opted out.
|
|
357
|
+
try {
|
|
358
|
+
const { autoUploadAtComplete } = await import("../dashboard/auto-upload.js");
|
|
359
|
+
await autoUploadAtComplete(created.runId, created.runDir, {
|
|
360
|
+
...(options.noUpload ? { disabled: true } : {}),
|
|
361
|
+
silent,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
// Hosted dashboard is optional. Any unhandled error here MUST NOT
|
|
366
|
+
// fail the run (spec: "Auto-upload NEVER fails the run").
|
|
367
|
+
}
|
|
353
368
|
return {
|
|
354
369
|
runId: created.runId,
|
|
355
370
|
runDir: created.runDir,
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// `claude-autopilot dashboard <verb>` — umbrella dispatcher.
|
|
2
|
+
//
|
|
3
|
+
// Verbs: login | logout | status | upload <runId>
|
|
4
|
+
import { runDashboardLogin } from "./login.js";
|
|
5
|
+
import { runDashboardLogout } from "./logout.js";
|
|
6
|
+
import { runDashboardStatus } from "./status.js";
|
|
7
|
+
import { runDashboardUpload } from "./upload.js";
|
|
8
|
+
export async function runDashboardVerb(args) {
|
|
9
|
+
const [verb, ...rest] = args.argv;
|
|
10
|
+
switch (verb) {
|
|
11
|
+
case 'login': {
|
|
12
|
+
try {
|
|
13
|
+
await runDashboardLogin();
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
|
16
|
+
catch (err) {
|
|
17
|
+
process.stderr.write(`[autopilot] login failed: ${err.message}\n`);
|
|
18
|
+
return 1;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
case 'logout': {
|
|
22
|
+
await runDashboardLogout();
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
case 'status': {
|
|
26
|
+
await runDashboardStatus();
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
case 'upload': {
|
|
30
|
+
const runId = rest[0];
|
|
31
|
+
if (!runId) {
|
|
32
|
+
process.stderr.write(`[autopilot] usage: claude-autopilot dashboard upload <runId>\n`);
|
|
33
|
+
return 2;
|
|
34
|
+
}
|
|
35
|
+
const result = await runDashboardUpload({ runId });
|
|
36
|
+
if (result.ok)
|
|
37
|
+
return 0;
|
|
38
|
+
if (result.notLoggedIn || result.runDirMissing)
|
|
39
|
+
return 2;
|
|
40
|
+
return 1;
|
|
41
|
+
}
|
|
42
|
+
default: {
|
|
43
|
+
process.stderr.write(`[autopilot] unknown dashboard verb: ${verb ?? '(none)'}\n`);
|
|
44
|
+
process.stderr.write(` valid verbs: login, logout, status, upload <runId>\n`);
|
|
45
|
+
return 2;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type DashboardConfig } from '../../dashboard/config.ts';
|
|
2
|
+
export interface LoginOptions {
|
|
3
|
+
/** Override base URL for tests / staging. */
|
|
4
|
+
baseUrl?: string;
|
|
5
|
+
/** Override browser launch — useful in tests + headless CI. */
|
|
6
|
+
openBrowser?: (url: string) => void | Promise<void>;
|
|
7
|
+
/** Manual mode: print URL, accept paste of (apiKey, fingerprint, email) on stdin instead of loopback. */
|
|
8
|
+
manual?: boolean;
|
|
9
|
+
/** Test seam — let tests force a specific port range start to avoid collisions. */
|
|
10
|
+
portRangeStart?: number;
|
|
11
|
+
/** Test seam — abort the listener after a fixed timeout. */
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
/** Test seam — silence stdout/stderr writes. */
|
|
14
|
+
silent?: boolean;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
}
|
|
17
|
+
export interface LoginResult {
|
|
18
|
+
config: DashboardConfig;
|
|
19
|
+
port: number;
|
|
20
|
+
}
|
|
21
|
+
export declare function runDashboardLogin(opts?: LoginOptions): Promise<LoginResult>;
|
|
22
|
+
//# sourceMappingURL=login.d.ts.map
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// `claude-autopilot dashboard login` — nonce-bound loopback OAuth-ish flow.
|
|
2
|
+
//
|
|
3
|
+
// 1. Generate 128-bit nonce.
|
|
4
|
+
// 2. Bind a node:http listener on the first available port in 56000-56050.
|
|
5
|
+
// 3. Open https://autopilot.dev/cli-auth?cb=<callback>&nonce=<nonce> in the
|
|
6
|
+
// user's browser. (User signs in, web page POSTs back to the callback
|
|
7
|
+
// with { apiKey, fingerprint, accountEmail, nonce }.)
|
|
8
|
+
// 4. Validate nonce with crypto.timingSafeEqual; reject mismatches.
|
|
9
|
+
// 5. Atomically write ~/.claude-autopilot/dashboard.json with mode 0600.
|
|
10
|
+
// 6. Respond 200 to the browser; close listener; print success.
|
|
11
|
+
//
|
|
12
|
+
// The web `/cli-auth` page is operator-deferred to Phase 4 dashboard UI;
|
|
13
|
+
// for now tests use a mock browser handler that simulates the full flow.
|
|
14
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
15
|
+
import { createServer } from 'node:http';
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
import { writeConfig, getAutopilotBaseUrl, } from "../../dashboard/config.js";
|
|
18
|
+
const PORT_START = 56000;
|
|
19
|
+
const PORT_END = 56050;
|
|
20
|
+
const NONCE_BYTES = 16; // 128-bit
|
|
21
|
+
const TIMEOUT_MS = 5 * 60 * 1000;
|
|
22
|
+
const MAX_BODY_BYTES = 4096;
|
|
23
|
+
const KEY_RE = /^clp_[0-9a-f]{64}$/;
|
|
24
|
+
function nonceMatch(expected, candidate) {
|
|
25
|
+
// timingSafeEqual requires equal-length buffers.
|
|
26
|
+
const a = Buffer.from(expected, 'utf-8');
|
|
27
|
+
const b = Buffer.from(candidate, 'utf-8');
|
|
28
|
+
if (a.length !== b.length)
|
|
29
|
+
return false;
|
|
30
|
+
return timingSafeEqual(a, b);
|
|
31
|
+
}
|
|
32
|
+
async function tryListen(port) {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const server = createServer();
|
|
35
|
+
const onError = () => {
|
|
36
|
+
server.removeListener('listening', onListening);
|
|
37
|
+
resolve(null);
|
|
38
|
+
};
|
|
39
|
+
const onListening = () => {
|
|
40
|
+
server.removeListener('error', onError);
|
|
41
|
+
resolve({ server, port });
|
|
42
|
+
};
|
|
43
|
+
server.once('error', onError);
|
|
44
|
+
server.once('listening', onListening);
|
|
45
|
+
server.listen(port, '127.0.0.1');
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
async function bindFirstPort(start, end) {
|
|
49
|
+
for (let p = start; p <= end; p++) {
|
|
50
|
+
const r = await tryListen(p);
|
|
51
|
+
if (r)
|
|
52
|
+
return r;
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`could not bind any port in ${start}-${end}`);
|
|
55
|
+
}
|
|
56
|
+
function readJsonBody(req) {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
let bytes = 0;
|
|
59
|
+
const chunks = [];
|
|
60
|
+
req.on('data', (c) => {
|
|
61
|
+
bytes += c.length;
|
|
62
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
63
|
+
req.destroy();
|
|
64
|
+
reject(new Error('body too large'));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
chunks.push(c);
|
|
68
|
+
});
|
|
69
|
+
req.on('end', () => {
|
|
70
|
+
try {
|
|
71
|
+
const buf = Buffer.concat(chunks);
|
|
72
|
+
resolve(JSON.parse(buf.toString('utf-8')));
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
reject(err);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
req.on('error', reject);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
function openInBrowser(url) {
|
|
82
|
+
// Best-effort cross-platform; ignored on test/headless paths.
|
|
83
|
+
const platform = process.platform;
|
|
84
|
+
let cmd;
|
|
85
|
+
let args;
|
|
86
|
+
if (platform === 'darwin') {
|
|
87
|
+
cmd = 'open';
|
|
88
|
+
args = [url];
|
|
89
|
+
}
|
|
90
|
+
else if (platform === 'win32') {
|
|
91
|
+
cmd = 'cmd';
|
|
92
|
+
args = ['/c', 'start', '""', url];
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
cmd = 'xdg-open';
|
|
96
|
+
args = [url];
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* noop */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export async function runDashboardLogin(opts = {}) {
|
|
106
|
+
// Phase 4 — unified env name. AUTOPILOT_PUBLIC_BASE_URL is canonical
|
|
107
|
+
// (matches apps/web). AUTOPILOT_DASHBOARD_BASE_URL is the deprecated
|
|
108
|
+
// Phase 2.3 alias and triggers a one-time warning.
|
|
109
|
+
const baseUrl = opts.baseUrl ?? getAutopilotBaseUrl();
|
|
110
|
+
const timeoutMs = opts.timeoutMs ?? TIMEOUT_MS;
|
|
111
|
+
const portStart = opts.portRangeStart ?? PORT_START;
|
|
112
|
+
const portEnd = portStart + (PORT_END - PORT_START);
|
|
113
|
+
const nonce = randomBytes(NONCE_BYTES).toString('hex');
|
|
114
|
+
const { server, port } = await bindFirstPort(portStart, portEnd);
|
|
115
|
+
const cb = `http://127.0.0.1:${port}/cli-callback`;
|
|
116
|
+
const authUrl = `${baseUrl}/cli-auth?cb=${encodeURIComponent(cb)}&nonce=${encodeURIComponent(nonce)}`;
|
|
117
|
+
let resolved = null;
|
|
118
|
+
let rejected = null;
|
|
119
|
+
const result = new Promise((resolve, reject) => {
|
|
120
|
+
resolved = resolve;
|
|
121
|
+
rejected = reject;
|
|
122
|
+
});
|
|
123
|
+
let settled = false;
|
|
124
|
+
const settle = (fn) => {
|
|
125
|
+
if (settled)
|
|
126
|
+
return;
|
|
127
|
+
settled = true;
|
|
128
|
+
fn();
|
|
129
|
+
// Force-close active connections so the event loop drains immediately
|
|
130
|
+
// (matters for tests; production callers exit the process anyway).
|
|
131
|
+
try {
|
|
132
|
+
server.closeAllConnections?.();
|
|
133
|
+
}
|
|
134
|
+
catch { /* noop */ }
|
|
135
|
+
server.close();
|
|
136
|
+
};
|
|
137
|
+
const timer = setTimeout(() => {
|
|
138
|
+
settle(() => rejected?.(new Error(`login timed out after ${timeoutMs}ms`)));
|
|
139
|
+
}, timeoutMs);
|
|
140
|
+
// Don't keep the event loop alive solely for the timeout watchdog —
|
|
141
|
+
// matters in tests when the suite has finished but timers linger.
|
|
142
|
+
timer.unref?.();
|
|
143
|
+
if (opts.signal) {
|
|
144
|
+
if (opts.signal.aborted) {
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
server.close();
|
|
147
|
+
throw new Error('aborted');
|
|
148
|
+
}
|
|
149
|
+
opts.signal.addEventListener('abort', () => {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
settle(() => rejected?.(new Error('aborted')));
|
|
152
|
+
}, { once: true });
|
|
153
|
+
}
|
|
154
|
+
// Phase 4 CORS — the /cli-auth page POSTs to this loopback with
|
|
155
|
+
// mode: 'cors'. Without OPTIONS preflight + Access-Control-Allow-Origin
|
|
156
|
+
// matching the configured public base URL, the browser fetch fails
|
|
157
|
+
// silently (opaque response) and the user sees "loopback failed" with
|
|
158
|
+
// no signal here.
|
|
159
|
+
const allowedOrigin = baseUrl;
|
|
160
|
+
server.on('request', (req, res) => {
|
|
161
|
+
void (async () => {
|
|
162
|
+
try {
|
|
163
|
+
// OPTIONS preflight — no body, no auth, just CORS headers.
|
|
164
|
+
if (req.method === 'OPTIONS') {
|
|
165
|
+
res.writeHead(204, {
|
|
166
|
+
'Access-Control-Allow-Origin': allowedOrigin,
|
|
167
|
+
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
|
168
|
+
'Access-Control-Allow-Headers': 'content-type',
|
|
169
|
+
'Access-Control-Max-Age': '60',
|
|
170
|
+
Vary: 'Origin',
|
|
171
|
+
});
|
|
172
|
+
res.end();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (req.method !== 'POST' || req.url !== '/cli-callback') {
|
|
176
|
+
res.statusCode = 404;
|
|
177
|
+
res.end('not found');
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const ct = (req.headers['content-type'] ?? '').toString();
|
|
181
|
+
if (!ct.includes('application/json')) {
|
|
182
|
+
res.statusCode = 415;
|
|
183
|
+
res.end('unsupported media type');
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const body = await readJsonBody(req);
|
|
187
|
+
// Codex NOTE — CORS header on POST response so the browser can
|
|
188
|
+
// read the JSON body under mode: 'cors'.
|
|
189
|
+
const corsHeaders = {
|
|
190
|
+
'Access-Control-Allow-Origin': allowedOrigin,
|
|
191
|
+
Vary: 'Origin',
|
|
192
|
+
};
|
|
193
|
+
if (typeof body.nonce !== 'string' || !nonceMatch(nonce, body.nonce)) {
|
|
194
|
+
res.writeHead(403, { ...corsHeaders, 'content-type': 'text/plain' });
|
|
195
|
+
res.end('nonce mismatch');
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
settle(() => rejected?.(new Error('nonce mismatch')));
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (typeof body.apiKey !== 'string' || !KEY_RE.test(body.apiKey)) {
|
|
201
|
+
res.writeHead(422, { ...corsHeaders, 'content-type': 'text/plain' });
|
|
202
|
+
res.end('invalid apiKey');
|
|
203
|
+
clearTimeout(timer);
|
|
204
|
+
settle(() => rejected?.(new Error('invalid apiKey from callback')));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (typeof body.fingerprint !== 'string' || !/^clp_[0-9a-f]{12}$/.test(body.fingerprint)) {
|
|
208
|
+
res.writeHead(422, { ...corsHeaders, 'content-type': 'text/plain' });
|
|
209
|
+
res.end('invalid fingerprint');
|
|
210
|
+
clearTimeout(timer);
|
|
211
|
+
settle(() => rejected?.(new Error('invalid fingerprint from callback')));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (typeof body.accountEmail !== 'string') {
|
|
215
|
+
res.writeHead(422, { ...corsHeaders, 'content-type': 'text/plain' });
|
|
216
|
+
res.end('invalid accountEmail');
|
|
217
|
+
clearTimeout(timer);
|
|
218
|
+
settle(() => rejected?.(new Error('invalid accountEmail from callback')));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const cfg = {
|
|
222
|
+
schemaVersion: 1,
|
|
223
|
+
apiKey: body.apiKey,
|
|
224
|
+
fingerprint: body.fingerprint,
|
|
225
|
+
accountEmail: body.accountEmail,
|
|
226
|
+
loggedInAt: new Date().toISOString(),
|
|
227
|
+
lastUploadAt: null,
|
|
228
|
+
};
|
|
229
|
+
await writeConfig(cfg);
|
|
230
|
+
res.writeHead(200, {
|
|
231
|
+
...corsHeaders,
|
|
232
|
+
'content-type': 'application/json',
|
|
233
|
+
});
|
|
234
|
+
res.end(JSON.stringify({ ok: true, nonce }));
|
|
235
|
+
clearTimeout(timer);
|
|
236
|
+
settle(() => resolved?.({ config: cfg, port }));
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
res.statusCode = 500;
|
|
240
|
+
res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
|
|
241
|
+
res.end(err.message ?? 'error');
|
|
242
|
+
clearTimeout(timer);
|
|
243
|
+
settle(() => rejected?.(err instanceof Error ? err : new Error(String(err))));
|
|
244
|
+
}
|
|
245
|
+
})();
|
|
246
|
+
});
|
|
247
|
+
// Print + open after the server is listening.
|
|
248
|
+
if (!opts.silent) {
|
|
249
|
+
process.stdout.write(`[autopilot] sign in here: ${authUrl}\n`);
|
|
250
|
+
process.stdout.write(` (a browser window will open; loopback callback on port ${port})\n`);
|
|
251
|
+
}
|
|
252
|
+
if (opts.openBrowser) {
|
|
253
|
+
await opts.openBrowser(authUrl);
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
openInBrowser(authUrl);
|
|
257
|
+
}
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
//# sourceMappingURL=login.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface LogoutOptions {
|
|
2
|
+
baseUrl?: string;
|
|
3
|
+
fetchImpl?: typeof fetch;
|
|
4
|
+
silent?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export interface LogoutResult {
|
|
7
|
+
hadConfig: boolean;
|
|
8
|
+
serverRevoked: boolean;
|
|
9
|
+
serverStatus: number | null;
|
|
10
|
+
}
|
|
11
|
+
export declare function runDashboardLogout(opts?: LogoutOptions): Promise<LogoutResult>;
|
|
12
|
+
//# sourceMappingURL=logout.d.ts.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// `claude-autopilot dashboard logout` — revoke server-side, delete config locally.
|
|
2
|
+
//
|
|
3
|
+
// Idempotent: missing config or HTTP failure both still result in the
|
|
4
|
+
// local file being deleted. Server-side revocation is best-effort but we
|
|
5
|
+
// surface the status code on stdout for transparency.
|
|
6
|
+
import { readConfig, deleteConfig } from "../../dashboard/config.js";
|
|
7
|
+
export async function runDashboardLogout(opts = {}) {
|
|
8
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
9
|
+
const baseUrl = opts.baseUrl ?? process.env.AUTOPILOT_DASHBOARD_BASE_URL ?? 'https://autopilot.dev';
|
|
10
|
+
const cfg = await readConfig();
|
|
11
|
+
if (!cfg) {
|
|
12
|
+
if (!opts.silent)
|
|
13
|
+
process.stdout.write(`[autopilot] not logged in.\n`);
|
|
14
|
+
await deleteConfig();
|
|
15
|
+
return { hadConfig: false, serverRevoked: false, serverStatus: null };
|
|
16
|
+
}
|
|
17
|
+
let status = null;
|
|
18
|
+
let revoked = false;
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetchImpl(`${baseUrl}/api/dashboard/api-keys/revoke`, {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: {
|
|
23
|
+
authorization: `Bearer ${cfg.apiKey}`,
|
|
24
|
+
'content-type': 'application/json',
|
|
25
|
+
},
|
|
26
|
+
body: JSON.stringify({ apiKey: cfg.apiKey }),
|
|
27
|
+
});
|
|
28
|
+
status = res.status;
|
|
29
|
+
revoked = res.ok;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* network error — local delete still proceeds */
|
|
33
|
+
}
|
|
34
|
+
await deleteConfig();
|
|
35
|
+
if (!opts.silent) {
|
|
36
|
+
if (revoked) {
|
|
37
|
+
process.stdout.write(`[autopilot] logged out (key ${cfg.fingerprint} revoked).\n`);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
process.stdout.write(`[autopilot] local config deleted; server revocation status=${status ?? 'network error'}.\n`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { hadConfig: true, serverRevoked: revoked, serverStatus: status };
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=logout.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface StatusOptions {
|
|
2
|
+
baseUrl?: string;
|
|
3
|
+
fetchImpl?: typeof fetch;
|
|
4
|
+
silent?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export interface MeResponse {
|
|
7
|
+
email: string | null;
|
|
8
|
+
fingerprint: string | null;
|
|
9
|
+
organizations: Array<{
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
role: string;
|
|
13
|
+
}>;
|
|
14
|
+
lastUploadAt: string | null;
|
|
15
|
+
}
|
|
16
|
+
export interface StatusResult {
|
|
17
|
+
loggedIn: boolean;
|
|
18
|
+
fingerprint: string | null;
|
|
19
|
+
email: string | null;
|
|
20
|
+
serverOk: boolean;
|
|
21
|
+
organizations: Array<{
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
role: string;
|
|
25
|
+
}>;
|
|
26
|
+
lastUploadAt: string | null;
|
|
27
|
+
permissiveWarning: string | null;
|
|
28
|
+
}
|
|
29
|
+
export declare function runDashboardStatus(opts?: StatusOptions): Promise<StatusResult>;
|
|
30
|
+
//# sourceMappingURL=status.d.ts.map
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// `claude-autopilot dashboard status` — read config + call /me + print.
|
|
2
|
+
import { readConfig, warnIfPermissive, getConfigPath, } from "../../dashboard/config.js";
|
|
3
|
+
export async function runDashboardStatus(opts = {}) {
|
|
4
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
5
|
+
const baseUrl = opts.baseUrl ?? process.env.AUTOPILOT_DASHBOARD_BASE_URL ?? 'https://autopilot.dev';
|
|
6
|
+
const cfg = await readConfig();
|
|
7
|
+
const permissive = await warnIfPermissive();
|
|
8
|
+
if (!cfg) {
|
|
9
|
+
if (!opts.silent) {
|
|
10
|
+
process.stdout.write(`[autopilot] not logged in. Run: claude-autopilot dashboard login\n`);
|
|
11
|
+
process.stdout.write(` (config path: ${getConfigPath()})\n`);
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
loggedIn: false,
|
|
15
|
+
fingerprint: null,
|
|
16
|
+
email: null,
|
|
17
|
+
serverOk: false,
|
|
18
|
+
organizations: [],
|
|
19
|
+
lastUploadAt: null,
|
|
20
|
+
permissiveWarning: permissive,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
let me = null;
|
|
24
|
+
let serverOk = false;
|
|
25
|
+
try {
|
|
26
|
+
const res = await fetchImpl(`${baseUrl}/api/dashboard/me`, {
|
|
27
|
+
method: 'GET',
|
|
28
|
+
headers: { authorization: `Bearer ${cfg.apiKey}` },
|
|
29
|
+
});
|
|
30
|
+
if (res.ok) {
|
|
31
|
+
me = await res.json();
|
|
32
|
+
serverOk = true;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
/* network error — fall through with serverOk=false */
|
|
37
|
+
}
|
|
38
|
+
if (!opts.silent) {
|
|
39
|
+
process.stdout.write(`[autopilot] logged in as ${cfg.accountEmail} (${cfg.fingerprint}).\n`);
|
|
40
|
+
if (serverOk && me) {
|
|
41
|
+
if (me.organizations.length > 0) {
|
|
42
|
+
process.stdout.write(` organizations: ${me.organizations.map((o) => `${o.name} (${o.role})`).join(', ')}\n`);
|
|
43
|
+
}
|
|
44
|
+
if (me.lastUploadAt) {
|
|
45
|
+
process.stdout.write(` last upload: ${me.lastUploadAt}\n`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
process.stdout.write(` (server unreachable; using cached config)\n`);
|
|
50
|
+
}
|
|
51
|
+
if (permissive) {
|
|
52
|
+
process.stderr.write(`${permissive}\n`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
loggedIn: true,
|
|
57
|
+
fingerprint: cfg.fingerprint,
|
|
58
|
+
email: cfg.accountEmail,
|
|
59
|
+
serverOk,
|
|
60
|
+
organizations: me?.organizations ?? [],
|
|
61
|
+
lastUploadAt: me?.lastUploadAt ?? null,
|
|
62
|
+
permissiveWarning: permissive,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=status.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type UploadResult } from '../../dashboard/upload/uploader.ts';
|
|
2
|
+
export interface ManualUploadOptions {
|
|
3
|
+
runId: string;
|
|
4
|
+
runsDir?: string;
|
|
5
|
+
baseUrl?: string;
|
|
6
|
+
fetchImpl?: typeof fetch;
|
|
7
|
+
silent?: boolean;
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
}
|
|
10
|
+
export interface ManualUploadResult extends UploadResult {
|
|
11
|
+
notLoggedIn?: boolean;
|
|
12
|
+
runDirMissing?: boolean;
|
|
13
|
+
runDir?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function runDashboardUpload(opts: ManualUploadOptions): Promise<ManualUploadResult>;
|
|
16
|
+
//# sourceMappingURL=upload.d.ts.map
|