codiedev 0.9.79 → 0.9.80
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/assets/opencode-plugin.mjs +6 -0
- package/assets/session-key.mjs +48 -0
- package/dist/cli.js +66 -9
- package/dist/cliSession.d.ts +108 -0
- package/dist/cliSession.js +513 -0
- package/dist/codie.js +21 -1
- package/dist/configDir.d.ts +9 -0
- package/dist/configDir.js +25 -1
- package/dist/connect.js +45 -2
- package/dist/connectFlow.d.ts +21 -0
- package/dist/connectFlow.js +41 -0
- package/dist/hook.js +7 -0
- package/dist/launcher.d.ts +7 -2
- package/dist/launcher.js +36 -2
- package/dist/managedPi.js +18 -2
- package/dist/mcp.js +3 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +9 -7
- package/package.json +1 -1
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
trackWorkPath,
|
|
11
11
|
workDirBySession,
|
|
12
12
|
} from "./git-refs.mjs";
|
|
13
|
+
import { freshGatewayKey } from "./session-key.mjs";
|
|
13
14
|
|
|
14
15
|
const skillUseBySession = new Map();
|
|
15
16
|
|
|
@@ -60,6 +61,11 @@ export const CodiedevMetadata = async () => ({
|
|
|
60
61
|
});
|
|
61
62
|
},
|
|
62
63
|
"chat.headers": async (input, output) => {
|
|
64
|
+
// Browser sign-in: the current 24h token, refreshed as it nears expiry,
|
|
65
|
+
// so a session open for days keeps working (see session-key.mjs). Exact
|
|
66
|
+
// "Authorization" key: per-call headers win over the apiKey-derived one.
|
|
67
|
+
const key = await freshGatewayKey();
|
|
68
|
+
if (key) output.headers.Authorization = `Bearer ${key}`;
|
|
63
69
|
Object.assign(
|
|
64
70
|
output.headers,
|
|
65
71
|
buildAttributionHeaders({
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The gateway key for one model request, kept fresh for the whole session.
|
|
2
|
+
//
|
|
3
|
+
// With browser sign-in (`codiedev connect`), the key is a 24h access token.
|
|
4
|
+
// The engine (OpenCode, pi) would otherwise hold the one it was launched
|
|
5
|
+
// with until the session ends, so a session open for more than a day would
|
|
6
|
+
// start failing. `codie` sets these variables only for a browser sign-in:
|
|
7
|
+
// CODIEDEV_SESSION_CONFIG path to ~/.codiedev/config.json
|
|
8
|
+
// CODIEDEV_CLI_JS the CLI entry (dist/cli.js)
|
|
9
|
+
// CODIEDEV_NODE the node binary that runs it
|
|
10
|
+
// Each request reads the current token from config (other codiedev
|
|
11
|
+
// processes may have refreshed it). When it has under 30 minutes left, this
|
|
12
|
+
// runs `codiedev session-refresh`, which refreshes under the shared lock
|
|
13
|
+
// (cliSession.ts, specs/tla/CliSession.tla), then reads again.
|
|
14
|
+
// No variables (pasted API token, CI): returns null and the launch key stands.
|
|
15
|
+
|
|
16
|
+
import { execFile } from "node:child_process";
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
|
|
19
|
+
export const REFRESH_WHEN_LEFT_MS = 30 * 60_000;
|
|
20
|
+
|
|
21
|
+
function readSession(path) {
|
|
22
|
+
try {
|
|
23
|
+
const c = JSON.parse(readFileSync(path, "utf8"));
|
|
24
|
+
return c && c.auth && typeof c.token === "string" && c.token ? c : null;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function runRefresh(env) {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
execFile(env.CODIEDEV_NODE, [env.CODIEDEV_CLI_JS, "session-refresh"], { timeout: 30_000, env }, () => resolve());
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Fresh gateway key, or null when the session isn't a browser sign-in. */
|
|
37
|
+
export async function freshGatewayKey(env = process.env, now = Date.now, refresh = runRefresh) {
|
|
38
|
+
const path = env.CODIEDEV_SESSION_CONFIG;
|
|
39
|
+
if (!path || !env.CODIEDEV_CLI_JS || !env.CODIEDEV_NODE) return null;
|
|
40
|
+
let session = readSession(path);
|
|
41
|
+
if (!session) return null;
|
|
42
|
+
const left = Number(session.auth.accessTokenExpiresAt) - now();
|
|
43
|
+
if (!(left > REFRESH_WHEN_LEFT_MS)) {
|
|
44
|
+
await refresh(env);
|
|
45
|
+
session = readSession(path) ?? session;
|
|
46
|
+
}
|
|
47
|
+
return session.token;
|
|
48
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
*/
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
17
|
const connect_1 = require("./connect");
|
|
18
|
+
const cliSession_1 = require("./cliSession");
|
|
19
|
+
const utils_1 = require("./utils");
|
|
18
20
|
const push_1 = require("./commands/push");
|
|
19
21
|
const delete_1 = require("./commands/delete");
|
|
20
22
|
const pull_1 = require("./commands/pull");
|
|
@@ -45,8 +47,10 @@ Codiedev CLI
|
|
|
45
47
|
someone left off across sessions and providers.
|
|
46
48
|
|
|
47
49
|
Connect:
|
|
48
|
-
codiedev connect
|
|
50
|
+
codiedev connect Sign in with your browser and link
|
|
51
|
+
Claude Code / Codex / Cursor /
|
|
49
52
|
GitHub Copilot / pi to your org
|
|
53
|
+
codiedev logout Sign this machine out
|
|
50
54
|
codiedev doctor Verify your setup after connecting
|
|
51
55
|
|
|
52
56
|
Harness:
|
|
@@ -172,8 +176,9 @@ const COMMAND_HELP = {
|
|
|
172
176
|
connect: `
|
|
173
177
|
codiedev connect — link your agent CLI to Codiedev
|
|
174
178
|
|
|
175
|
-
|
|
176
|
-
|
|
179
|
+
Opens your browser to sign in (approve once, no API key to copy), then
|
|
180
|
+
installs:
|
|
181
|
+
- ~/.codiedev/config.json (sign-in + org + tracked repos)
|
|
177
182
|
- ~/.claude/settings.json hook (captures session transcripts)
|
|
178
183
|
- ~/.claude/CLAUDE.md instructions (teaches Claude the commands)
|
|
179
184
|
- ~/.codex/hooks.json hook (same, for Codex)
|
|
@@ -186,7 +191,27 @@ codiedev connect — link your agent CLI to Codiedev
|
|
|
186
191
|
|
|
187
192
|
Re-run anytime to swap accounts or refresh the instruction block.
|
|
188
193
|
|
|
189
|
-
|
|
194
|
+
Over SSH or on a machine with no display it shows a link and a short code
|
|
195
|
+
instead: open the link on your laptop or phone, check the code, approve.
|
|
196
|
+
Detected automatically.
|
|
197
|
+
|
|
198
|
+
Options:
|
|
199
|
+
--token Paste an API token instead of signing in with a browser
|
|
200
|
+
(CI, or a machine with no browser anywhere). Create one at
|
|
201
|
+
https://codiedev.com/portal/integrations/claude-code
|
|
202
|
+
--force, -f Sign in again even if this machine is already connected.
|
|
203
|
+
|
|
204
|
+
Browser sign-in stores a 24-hour access token and a refresh token that
|
|
205
|
+
rotates on every use; nothing long-lived is saved. Revoke a machine from
|
|
206
|
+
Integrations in the portal, or run 'codiedev logout' on it.
|
|
207
|
+
`.trim(),
|
|
208
|
+
logout: `
|
|
209
|
+
codiedev logout — sign this machine out
|
|
210
|
+
|
|
211
|
+
Ends this machine's browser sign-in on the server and removes it from
|
|
212
|
+
~/.codiedev/config.json. Device settings (ignored folders) are kept.
|
|
213
|
+
For a pasted API token, it's only forgotten locally: revoke it in the
|
|
214
|
+
portal under Integrations.
|
|
190
215
|
`.trim(),
|
|
191
216
|
push: `
|
|
192
217
|
codiedev push — author or update an artifact
|
|
@@ -405,13 +430,15 @@ Run 'codiedev help <command>' for each, or see the quick list:
|
|
|
405
430
|
|
|
406
431
|
codiedev help
|
|
407
432
|
|
|
408
|
-
##
|
|
433
|
+
## Signing in
|
|
409
434
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
3. Copy the cdv_... string and paste it into 'codiedev connect'
|
|
435
|
+
'codiedev connect' opens your browser; approve, and you're done. Nothing
|
|
436
|
+
long-lived is stored: a 24-hour access token plus a rotating refresh token.
|
|
413
437
|
|
|
414
|
-
|
|
438
|
+
Over SSH it shows a link and code to approve from your laptop or phone,
|
|
439
|
+
automatically. For CI, create a token at
|
|
440
|
+
https://codiedev.com/portal/integrations/claude-code and run
|
|
441
|
+
'codiedev connect --token'.
|
|
415
442
|
`.trim();
|
|
416
443
|
// Retired commands: say so instead of "Unknown command", since agent
|
|
417
444
|
// instructions written by older installs may still reference them.
|
|
@@ -474,11 +501,41 @@ async function main() {
|
|
|
474
501
|
console.log(`codiedev v${version_1.CLI_VERSION}`);
|
|
475
502
|
return;
|
|
476
503
|
}
|
|
504
|
+
// Browser sign-in: refresh the access token (under the shared lock) before
|
|
505
|
+
// the command reads config. No-op for pasted API tokens.
|
|
506
|
+
if (command !== "connect" && command !== "logout") {
|
|
507
|
+
const fresh = await (0, cliSession_1.ensureFreshToken)({ configPath: utils_1.CONFIG_PATH }).catch(() => null);
|
|
508
|
+
if (fresh?.status === "signed_out") {
|
|
509
|
+
console.error("\nYour Codiedev sign-in has ended (revoked or expired). Run `codiedev connect` to sign in again.\n");
|
|
510
|
+
}
|
|
511
|
+
}
|
|
477
512
|
try {
|
|
478
513
|
switch (command) {
|
|
479
514
|
case "connect":
|
|
480
515
|
await (0, connect_1.runConnect)(rest);
|
|
481
516
|
return;
|
|
517
|
+
case "session-refresh": {
|
|
518
|
+
// Internal: the Codie engine's per-request hook asks for a fresh
|
|
519
|
+
// token when the current one has under 30 minutes left. Refreshes
|
|
520
|
+
// under the shared lock; prints nothing.
|
|
521
|
+
await (0, cliSession_1.ensureFreshToken)({ configPath: utils_1.CONFIG_PATH }, 60 * 60_000).catch(() => null);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
case "logout": {
|
|
525
|
+
const out = await (0, cliSession_1.logout)({ configPath: utils_1.CONFIG_PATH });
|
|
526
|
+
if (!out.wasConnected) {
|
|
527
|
+
console.log("This machine isn't signed in.");
|
|
528
|
+
}
|
|
529
|
+
else if (out.hadBrowserAuth) {
|
|
530
|
+
console.log(out.serverRevoked
|
|
531
|
+
? "Signed out. This machine's sign-in is revoked."
|
|
532
|
+
: "Signed out on this machine. Couldn't reach Codiedev to revoke the sign-in; revoke it under Integrations in the portal.");
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
console.log("Removed the API token from this machine. It still works elsewhere until you revoke it under Integrations in the portal.");
|
|
536
|
+
}
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
482
539
|
case "doctor":
|
|
483
540
|
case "verify":
|
|
484
541
|
await (0, doctor_1.runDoctor)(rest);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { CodiedevConfig } from "./utils";
|
|
2
|
+
export interface BrowserAuth {
|
|
3
|
+
kind: "browser";
|
|
4
|
+
clientId: string;
|
|
5
|
+
refreshToken: string;
|
|
6
|
+
/** epoch ms when `token` (the access token) stops working */
|
|
7
|
+
accessTokenExpiresAt: number;
|
|
8
|
+
}
|
|
9
|
+
export interface TokenSet {
|
|
10
|
+
accessToken: string;
|
|
11
|
+
refreshToken: string;
|
|
12
|
+
expiresAt: number;
|
|
13
|
+
clientId: string;
|
|
14
|
+
}
|
|
15
|
+
/** Default: refresh when less than 10 minutes remain. */
|
|
16
|
+
export declare const DEFAULT_MIN_VALIDITY_MS: number;
|
|
17
|
+
/** A lock older than this was left by a crashed process. Refresh calls time
|
|
18
|
+
* out well before it (REFRESH_TIMEOUT_MS), so a live holder never looks stale. */
|
|
19
|
+
export declare const LOCK_STALE_MS = 30000;
|
|
20
|
+
export interface SessionDeps {
|
|
21
|
+
configPath: string;
|
|
22
|
+
fetchImpl?: typeof fetch;
|
|
23
|
+
now?: () => number;
|
|
24
|
+
sleep?: (ms: number) => Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
export declare function readConfigAt(configPath: string): CodiedevConfig | null;
|
|
27
|
+
/** rename(), retrying the transient Windows sharing errors. Exported for tests. */
|
|
28
|
+
export declare function renameWithRetry(from: string, to: string, rename?: (a: string, b: string) => void, sleep?: (ms: number) => void): void;
|
|
29
|
+
/**
|
|
30
|
+
* Write via temp file + rename so a concurrent reader never sees a torn file.
|
|
31
|
+
* If the rename still fails after ~1.6s of retries (a handle held that long),
|
|
32
|
+
* fall back to writing in place: a rare torn read beats failing `connect`.
|
|
33
|
+
*/
|
|
34
|
+
export declare function writeConfigAtomic(configPath: string, config: CodiedevConfig): void;
|
|
35
|
+
export type FreshResult = {
|
|
36
|
+
status: "fresh";
|
|
37
|
+
config: CodiedevConfig;
|
|
38
|
+
} | {
|
|
39
|
+
status: "refreshed";
|
|
40
|
+
config: CodiedevConfig;
|
|
41
|
+
} | {
|
|
42
|
+
status: "adopted";
|
|
43
|
+
config: CodiedevConfig;
|
|
44
|
+
} | {
|
|
45
|
+
status: "unavailable";
|
|
46
|
+
config: CodiedevConfig;
|
|
47
|
+
} | {
|
|
48
|
+
status: "signed_out";
|
|
49
|
+
config: CodiedevConfig;
|
|
50
|
+
} | {
|
|
51
|
+
status: "none";
|
|
52
|
+
config: CodiedevConfig | null;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Make sure config.token is a usable access token for at least
|
|
56
|
+
* `minValidityMs`. Never throws: auth problems surface as the next request's
|
|
57
|
+
* 401, which every command already explains.
|
|
58
|
+
*/
|
|
59
|
+
export declare function ensureFreshToken(deps: SessionDeps, minValidityMs?: number): Promise<FreshResult>;
|
|
60
|
+
export declare function pkcePair(): {
|
|
61
|
+
verifier: string;
|
|
62
|
+
challenge: string;
|
|
63
|
+
};
|
|
64
|
+
export declare function openBrowser(url: string): boolean;
|
|
65
|
+
export interface LoginOptions {
|
|
66
|
+
backendUrl: string;
|
|
67
|
+
clientName?: string;
|
|
68
|
+
fetchImpl?: typeof fetch;
|
|
69
|
+
/** Test hook: called with the authorize URL instead of opening a browser. */
|
|
70
|
+
onAuthorizeUrl?: (url: string) => void;
|
|
71
|
+
log?: (line: string) => void;
|
|
72
|
+
now?: () => number;
|
|
73
|
+
}
|
|
74
|
+
/** Parse `code`/`state` (or `error`) out of a loopback callback URL. */
|
|
75
|
+
export declare function parseCallback(input: string): {
|
|
76
|
+
code?: string;
|
|
77
|
+
state?: string;
|
|
78
|
+
error?: string;
|
|
79
|
+
};
|
|
80
|
+
export declare function browserLogin(opts: LoginOptions): Promise<TokenSet>;
|
|
81
|
+
export declare const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
82
|
+
/**
|
|
83
|
+
* True when a browser opened here can't reach the user: an SSH session, or a
|
|
84
|
+
* Linux box with no display. Then `connect` uses the device flow (a code the
|
|
85
|
+
* user approves on any device) instead of a loopback redirect, with no flag.
|
|
86
|
+
*/
|
|
87
|
+
export declare function isHeadless(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
|
|
88
|
+
export interface DeviceLoginOptions {
|
|
89
|
+
backendUrl: string;
|
|
90
|
+
clientName?: string;
|
|
91
|
+
fetchImpl?: typeof fetch;
|
|
92
|
+
log?: (line: string) => void;
|
|
93
|
+
now?: () => number;
|
|
94
|
+
sleep?: (ms: number) => Promise<void>;
|
|
95
|
+
/** Test hook: receives the code + link instead of only printing them. */
|
|
96
|
+
onCode?: (info: {
|
|
97
|
+
userCode: string;
|
|
98
|
+
verificationUri: string;
|
|
99
|
+
verificationUriComplete: string;
|
|
100
|
+
}) => void;
|
|
101
|
+
}
|
|
102
|
+
export declare function deviceLogin(opts: DeviceLoginOptions): Promise<TokenSet>;
|
|
103
|
+
/** `codiedev logout`: end a browser sign-in server-side, forget it locally. */
|
|
104
|
+
export declare function logout(deps: SessionDeps): Promise<{
|
|
105
|
+
wasConnected: boolean;
|
|
106
|
+
hadBrowserAuth: boolean;
|
|
107
|
+
serverRevoked: boolean;
|
|
108
|
+
}>;
|
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Browser sign-in for `codiedev connect`, and keeping its access token fresh.
|
|
3
|
+
//
|
|
4
|
+
// Sign-in is OAuth 2.1 authorization code + PKCE against the Codiedev
|
|
5
|
+
// backend with a loopback redirect (RFC 8252): the CLI listens on
|
|
6
|
+
// 127.0.0.1:<random port>, opens the portal's consent page, and exchanges the
|
|
7
|
+
// returned code for a 24h access token (cdv_at_) and a 30-day refresh token
|
|
8
|
+
// (cdv_rt_). No long-lived API key is saved.
|
|
9
|
+
//
|
|
10
|
+
// config.json keeps `token` = the current access token, so every existing
|
|
11
|
+
// reader keeps working. Each process entry point (codiedev, codiedev-hook,
|
|
12
|
+
// codie, the MCP server) calls ensureFreshToken() first; it refreshes when the
|
|
13
|
+
// token is about to expire.
|
|
14
|
+
//
|
|
15
|
+
// Refresh tokens are single-use and the server treats reuse of a spent one as
|
|
16
|
+
// theft (it revokes the whole sign-in). Many hooks run in parallel and share
|
|
17
|
+
// config.json, so a refresh goes:
|
|
18
|
+
// 1. read config, note the stale access token ("seen")
|
|
19
|
+
// 2. take ~/.codiedev/refresh.lock
|
|
20
|
+
// 3. re-read config; if the token is no longer "seen", another process
|
|
21
|
+
// already refreshed: use that, no network call
|
|
22
|
+
// 4. else refresh, atomically replace config.json, release
|
|
23
|
+
// specs/tla/CliSession.tla model-checks this: without step 2–3, two hooks can
|
|
24
|
+
// both present the same refresh token and sign the user out.
|
|
25
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
26
|
+
if (k2 === undefined) k2 = k;
|
|
27
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
28
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
29
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
30
|
+
}
|
|
31
|
+
Object.defineProperty(o, k2, desc);
|
|
32
|
+
}) : (function(o, m, k, k2) {
|
|
33
|
+
if (k2 === undefined) k2 = k;
|
|
34
|
+
o[k2] = m[k];
|
|
35
|
+
}));
|
|
36
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
37
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
38
|
+
}) : function(o, v) {
|
|
39
|
+
o["default"] = v;
|
|
40
|
+
});
|
|
41
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
42
|
+
var ownKeys = function(o) {
|
|
43
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
44
|
+
var ar = [];
|
|
45
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
46
|
+
return ar;
|
|
47
|
+
};
|
|
48
|
+
return ownKeys(o);
|
|
49
|
+
};
|
|
50
|
+
return function (mod) {
|
|
51
|
+
if (mod && mod.__esModule) return mod;
|
|
52
|
+
var result = {};
|
|
53
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
54
|
+
__setModuleDefault(result, mod);
|
|
55
|
+
return result;
|
|
56
|
+
};
|
|
57
|
+
})();
|
|
58
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
59
|
+
exports.DEVICE_CODE_GRANT = exports.LOCK_STALE_MS = exports.DEFAULT_MIN_VALIDITY_MS = void 0;
|
|
60
|
+
exports.readConfigAt = readConfigAt;
|
|
61
|
+
exports.renameWithRetry = renameWithRetry;
|
|
62
|
+
exports.writeConfigAtomic = writeConfigAtomic;
|
|
63
|
+
exports.ensureFreshToken = ensureFreshToken;
|
|
64
|
+
exports.pkcePair = pkcePair;
|
|
65
|
+
exports.openBrowser = openBrowser;
|
|
66
|
+
exports.parseCallback = parseCallback;
|
|
67
|
+
exports.browserLogin = browserLogin;
|
|
68
|
+
exports.isHeadless = isHeadless;
|
|
69
|
+
exports.deviceLogin = deviceLogin;
|
|
70
|
+
exports.logout = logout;
|
|
71
|
+
const crypto = __importStar(require("crypto"));
|
|
72
|
+
const fs = __importStar(require("fs"));
|
|
73
|
+
const http = __importStar(require("http"));
|
|
74
|
+
const os = __importStar(require("os"));
|
|
75
|
+
const path = __importStar(require("path"));
|
|
76
|
+
const child_process_1 = require("child_process");
|
|
77
|
+
/** Default: refresh when less than 10 minutes remain. */
|
|
78
|
+
exports.DEFAULT_MIN_VALIDITY_MS = 10 * 60_000;
|
|
79
|
+
/** A lock older than this was left by a crashed process. Refresh calls time
|
|
80
|
+
* out well before it (REFRESH_TIMEOUT_MS), so a live holder never looks stale. */
|
|
81
|
+
exports.LOCK_STALE_MS = 30_000;
|
|
82
|
+
const LOCK_WAIT_MS = 20_000;
|
|
83
|
+
const REFRESH_TIMEOUT_MS = 10_000;
|
|
84
|
+
const LOGIN_TIMEOUT_MS = 10 * 60_000;
|
|
85
|
+
// ─── config IO (atomic) ────────────────────────────────────────────────
|
|
86
|
+
function readConfigAt(configPath) {
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Windows refuses to replace a file another process holds without share-delete
|
|
95
|
+
// (antivirus, the search indexer; Node's own readers do share). Those holds
|
|
96
|
+
// last milliseconds, so retry like graceful-fs/write-file-atomic do.
|
|
97
|
+
const RENAME_RETRY_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
98
|
+
const RENAME_RETRY_MS = [10, 25, 50, 100, 200, 400, 800];
|
|
99
|
+
function sleepSync(ms) {
|
|
100
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
101
|
+
}
|
|
102
|
+
/** rename(), retrying the transient Windows sharing errors. Exported for tests. */
|
|
103
|
+
function renameWithRetry(from, to, rename = fs.renameSync, sleep = sleepSync) {
|
|
104
|
+
for (let attempt = 0;; attempt++) {
|
|
105
|
+
try {
|
|
106
|
+
rename(from, to);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
const code = err?.code ?? "";
|
|
111
|
+
if (!RENAME_RETRY_CODES.has(code) || attempt >= RENAME_RETRY_MS.length)
|
|
112
|
+
throw err;
|
|
113
|
+
sleep(RENAME_RETRY_MS[attempt]);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Write via temp file + rename so a concurrent reader never sees a torn file.
|
|
119
|
+
* If the rename still fails after ~1.6s of retries (a handle held that long),
|
|
120
|
+
* fall back to writing in place: a rare torn read beats failing `connect`.
|
|
121
|
+
*/
|
|
122
|
+
function writeConfigAtomic(configPath, config) {
|
|
123
|
+
const dir = path.dirname(configPath);
|
|
124
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
125
|
+
const data = JSON.stringify(config, null, 2);
|
|
126
|
+
const tmp = path.join(dir, `.config.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`);
|
|
127
|
+
fs.writeFileSync(tmp, data, { encoding: "utf8", mode: 0o600 });
|
|
128
|
+
try {
|
|
129
|
+
fs.chmodSync(tmp, 0o600);
|
|
130
|
+
renameWithRetry(tmp, configPath);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
try {
|
|
135
|
+
fs.unlinkSync(tmp);
|
|
136
|
+
}
|
|
137
|
+
catch { /* already gone */ }
|
|
138
|
+
if (!RENAME_RETRY_CODES.has(err?.code ?? ""))
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
fs.writeFileSync(configPath, data, { encoding: "utf8", mode: 0o600 });
|
|
142
|
+
fs.chmodSync(configPath, 0o600);
|
|
143
|
+
}
|
|
144
|
+
// ─── refresh lock ──────────────────────────────────────────────────────
|
|
145
|
+
function lockPathFor(configPath) {
|
|
146
|
+
return path.join(path.dirname(configPath), "refresh.lock");
|
|
147
|
+
}
|
|
148
|
+
function tryLock(lockPath, now) {
|
|
149
|
+
try {
|
|
150
|
+
const fd = fs.openSync(lockPath, "wx", 0o600);
|
|
151
|
+
fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: now }));
|
|
152
|
+
fs.closeSync(fd);
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
if (err?.code !== "EEXIST")
|
|
157
|
+
throw err;
|
|
158
|
+
// Break a lock left by a crashed process.
|
|
159
|
+
try {
|
|
160
|
+
const age = now - fs.statSync(lockPath).mtimeMs;
|
|
161
|
+
if (age > exports.LOCK_STALE_MS)
|
|
162
|
+
fs.unlinkSync(lockPath);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
/* raced with its owner releasing it: retry */
|
|
166
|
+
}
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function unlock(lockPath) {
|
|
171
|
+
try {
|
|
172
|
+
fs.unlinkSync(lockPath);
|
|
173
|
+
}
|
|
174
|
+
catch { /* already released */ }
|
|
175
|
+
}
|
|
176
|
+
function needsRefresh(config, now, minValidityMs) {
|
|
177
|
+
const auth = config.auth;
|
|
178
|
+
return !!auth && !!config.token && auth.accessTokenExpiresAt - now < minValidityMs;
|
|
179
|
+
}
|
|
180
|
+
async function postForm(fetchImpl, url, form, timeoutMs) {
|
|
181
|
+
const ctl = new AbortController();
|
|
182
|
+
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
|
183
|
+
try {
|
|
184
|
+
const res = await fetchImpl(url, {
|
|
185
|
+
method: "POST",
|
|
186
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
187
|
+
body: new URLSearchParams(form).toString(),
|
|
188
|
+
signal: ctl.signal,
|
|
189
|
+
});
|
|
190
|
+
return { status: res.status, body: (await res.json().catch(() => null)) };
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Make sure config.token is a usable access token for at least
|
|
198
|
+
* `minValidityMs`. Never throws: auth problems surface as the next request's
|
|
199
|
+
* 401, which every command already explains.
|
|
200
|
+
*/
|
|
201
|
+
async function ensureFreshToken(deps, minValidityMs = exports.DEFAULT_MIN_VALIDITY_MS) {
|
|
202
|
+
const now = deps.now ?? Date.now;
|
|
203
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
204
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
205
|
+
const first = readConfigAt(deps.configPath);
|
|
206
|
+
if (!first?.auth)
|
|
207
|
+
return { status: "none", config: first };
|
|
208
|
+
if (!needsRefresh(first, now(), minValidityMs))
|
|
209
|
+
return { status: "fresh", config: first };
|
|
210
|
+
const seen = first.token;
|
|
211
|
+
const lockPath = lockPathFor(deps.configPath);
|
|
212
|
+
const deadline = now() + LOCK_WAIT_MS;
|
|
213
|
+
let locked = false;
|
|
214
|
+
try {
|
|
215
|
+
while (!(locked = tryLock(lockPath, now()))) {
|
|
216
|
+
if (now() > deadline)
|
|
217
|
+
return { status: "unavailable", config: first };
|
|
218
|
+
await sleep(50 + Math.floor(Math.random() * 100));
|
|
219
|
+
}
|
|
220
|
+
// Step 3: re-read under the lock.
|
|
221
|
+
const current = readConfigAt(deps.configPath);
|
|
222
|
+
if (!current?.auth)
|
|
223
|
+
return { status: "none", config: current };
|
|
224
|
+
if (current.token !== seen)
|
|
225
|
+
return { status: "adopted", config: current };
|
|
226
|
+
let res;
|
|
227
|
+
try {
|
|
228
|
+
res = await postForm(fetchImpl, `${current.backendUrl}/oauth/token`, {
|
|
229
|
+
grant_type: "refresh_token",
|
|
230
|
+
client_id: current.auth.clientId,
|
|
231
|
+
refresh_token: current.auth.refreshToken,
|
|
232
|
+
}, REFRESH_TIMEOUT_MS);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return { status: "unavailable", config: current };
|
|
236
|
+
}
|
|
237
|
+
if (res.status === 200 && typeof res.body?.access_token === "string" && typeof res.body.refresh_token === "string") {
|
|
238
|
+
const next = {
|
|
239
|
+
...current,
|
|
240
|
+
token: res.body.access_token,
|
|
241
|
+
auth: {
|
|
242
|
+
...current.auth,
|
|
243
|
+
refreshToken: res.body.refresh_token,
|
|
244
|
+
accessTokenExpiresAt: now() + Number(res.body.expires_in ?? 0) * 1000,
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
writeConfigAtomic(deps.configPath, next);
|
|
248
|
+
return { status: "refreshed", config: next };
|
|
249
|
+
}
|
|
250
|
+
if (res.status === 400 && res.body?.error === "invalid_grant") {
|
|
251
|
+
// Revoked in the portal, signed out elsewhere, expired, or reuse was
|
|
252
|
+
// detected. Forget the credentials but keep device settings.
|
|
253
|
+
const out = { ...current, token: "" };
|
|
254
|
+
delete out.auth;
|
|
255
|
+
writeConfigAtomic(deps.configPath, out);
|
|
256
|
+
return { status: "signed_out", config: out };
|
|
257
|
+
}
|
|
258
|
+
return { status: "unavailable", config: current };
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
if (locked)
|
|
262
|
+
unlock(lockPath);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// ─── browser sign-in ───────────────────────────────────────────────────
|
|
266
|
+
function base64Url(buf) {
|
|
267
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
268
|
+
}
|
|
269
|
+
function pkcePair() {
|
|
270
|
+
const verifier = base64Url(crypto.randomBytes(32));
|
|
271
|
+
const challenge = base64Url(crypto.createHash("sha256").update(verifier).digest());
|
|
272
|
+
return { verifier, challenge };
|
|
273
|
+
}
|
|
274
|
+
function openBrowser(url) {
|
|
275
|
+
try {
|
|
276
|
+
const [cmd, args] = process.platform === "darwin"
|
|
277
|
+
? ["open", [url]]
|
|
278
|
+
: process.platform === "win32"
|
|
279
|
+
? ["rundll32", ["url.dll,FileProtocolHandler", url]]
|
|
280
|
+
: ["xdg-open", [url]];
|
|
281
|
+
const child = (0, child_process_1.spawn)(cmd, args, { detached: true, stdio: "ignore" });
|
|
282
|
+
child.on("error", () => { });
|
|
283
|
+
child.unref();
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const DONE_PAGE = (ok, msg) => `<!doctype html><html><head><meta charset="utf-8">
|
|
291
|
+
<title>Codiedev CLI</title><style>body{font-family:system-ui,sans-serif;display:grid;place-items:center;
|
|
292
|
+
height:100vh;margin:0;background:#fbfbfa;color:#1f1f1f}main{max-width:28rem;text-align:center}
|
|
293
|
+
h1{font-size:1.25rem}</style></head><body><main><h1>${ok ? "You're signed in" : "Sign-in didn't finish"}</h1>
|
|
294
|
+
<p>${msg}</p></main></body></html>`;
|
|
295
|
+
/** Parse `code`/`state` (or `error`) out of a loopback callback URL. */
|
|
296
|
+
function parseCallback(input) {
|
|
297
|
+
try {
|
|
298
|
+
const u = new URL(input.trim());
|
|
299
|
+
return {
|
|
300
|
+
code: u.searchParams.get("code") ?? undefined,
|
|
301
|
+
state: u.searchParams.get("state") ?? undefined,
|
|
302
|
+
error: u.searchParams.get("error_description") ?? u.searchParams.get("error") ?? undefined,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
return { error: "That isn't the address from the browser's address bar." };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
async function browserLogin(opts) {
|
|
310
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
311
|
+
const log = opts.log ?? ((line) => console.log(line));
|
|
312
|
+
const now = opts.now ?? Date.now;
|
|
313
|
+
const state = base64Url(crypto.randomBytes(16));
|
|
314
|
+
const { verifier, challenge } = pkcePair();
|
|
315
|
+
let settle;
|
|
316
|
+
const result = new Promise((r) => (settle = r));
|
|
317
|
+
const server = http.createServer((req, res) => {
|
|
318
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
319
|
+
if (url.pathname !== "/callback") {
|
|
320
|
+
res.writeHead(404).end();
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const cb = parseCallback(url.toString());
|
|
324
|
+
const ok = !cb.error && cb.state === state && !!cb.code;
|
|
325
|
+
res.writeHead(ok ? 200 : 400, { "Content-Type": "text/html; charset=utf-8" });
|
|
326
|
+
res.end(DONE_PAGE(ok, ok
|
|
327
|
+
? "You can close this tab and go back to your terminal."
|
|
328
|
+
: "Go back to your terminal and run <code>codiedev connect</code> again."));
|
|
329
|
+
if (cb.error)
|
|
330
|
+
settle({ error: cb.error });
|
|
331
|
+
else if (cb.state !== state)
|
|
332
|
+
settle({ error: "The sign-in response didn't match this request (state mismatch)." });
|
|
333
|
+
else
|
|
334
|
+
settle({ code: cb.code });
|
|
335
|
+
});
|
|
336
|
+
await new Promise((resolve, reject) => {
|
|
337
|
+
server.once("error", reject);
|
|
338
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
339
|
+
});
|
|
340
|
+
const port = server.address().port;
|
|
341
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
342
|
+
const timer = setTimeout(() => settle({ error: "Timed out waiting for the browser (10 minutes)." }), LOGIN_TIMEOUT_MS);
|
|
343
|
+
try {
|
|
344
|
+
const reg = await fetchImpl(`${opts.backendUrl}/oauth/register`, {
|
|
345
|
+
method: "POST",
|
|
346
|
+
headers: { "Content-Type": "application/json" },
|
|
347
|
+
body: JSON.stringify({
|
|
348
|
+
client_name: opts.clientName ?? `Codiedev CLI on ${os.hostname()}`,
|
|
349
|
+
redirect_uris: [redirectUri],
|
|
350
|
+
}),
|
|
351
|
+
});
|
|
352
|
+
if (reg.status !== 201)
|
|
353
|
+
throw new Error(`Sign-in setup failed (HTTP ${reg.status}).`);
|
|
354
|
+
const { client_id: clientId } = (await reg.json());
|
|
355
|
+
const authorize = new URL(`${opts.backendUrl}/oauth/authorize`);
|
|
356
|
+
authorize.search = new URLSearchParams({
|
|
357
|
+
client_id: clientId,
|
|
358
|
+
redirect_uri: redirectUri,
|
|
359
|
+
response_type: "code",
|
|
360
|
+
code_challenge: challenge,
|
|
361
|
+
code_challenge_method: "S256",
|
|
362
|
+
state,
|
|
363
|
+
scope: "cli",
|
|
364
|
+
}).toString();
|
|
365
|
+
const link = authorize.toString();
|
|
366
|
+
if (opts.onAuthorizeUrl) {
|
|
367
|
+
opts.onAuthorizeUrl(link);
|
|
368
|
+
}
|
|
369
|
+
else {
|
|
370
|
+
log("Opening your browser to sign in…");
|
|
371
|
+
log(`If it doesn't open, visit:\n ${link}\n`);
|
|
372
|
+
openBrowser(link);
|
|
373
|
+
}
|
|
374
|
+
const cb = await result;
|
|
375
|
+
if (cb.error || !cb.code)
|
|
376
|
+
throw new Error(cb.error ?? "Sign-in was cancelled.");
|
|
377
|
+
const tok = await postForm(fetchImpl, `${opts.backendUrl}/oauth/token`, {
|
|
378
|
+
grant_type: "authorization_code",
|
|
379
|
+
code: cb.code,
|
|
380
|
+
client_id: clientId,
|
|
381
|
+
redirect_uri: redirectUri,
|
|
382
|
+
code_verifier: verifier,
|
|
383
|
+
}, REFRESH_TIMEOUT_MS);
|
|
384
|
+
const body = tok.body;
|
|
385
|
+
if (tok.status !== 200 || typeof body?.access_token !== "string" || typeof body.refresh_token !== "string") {
|
|
386
|
+
throw new Error(`Sign-in failed: ${body?.error_description ?? body?.error ?? `HTTP ${tok.status}`}`);
|
|
387
|
+
}
|
|
388
|
+
if (body.scope !== "cli")
|
|
389
|
+
throw new Error("The server didn't grant CLI access.");
|
|
390
|
+
return {
|
|
391
|
+
accessToken: body.access_token,
|
|
392
|
+
refreshToken: body.refresh_token,
|
|
393
|
+
expiresAt: now() + Number(body.expires_in ?? 0) * 1000,
|
|
394
|
+
clientId,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
finally {
|
|
398
|
+
clearTimeout(timer);
|
|
399
|
+
server.close();
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// ─── device sign-in (SSH / headless) ──────────────────────────────────
|
|
403
|
+
exports.DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
404
|
+
/**
|
|
405
|
+
* True when a browser opened here can't reach the user: an SSH session, or a
|
|
406
|
+
* Linux box with no display. Then `connect` uses the device flow (a code the
|
|
407
|
+
* user approves on any device) instead of a loopback redirect, with no flag.
|
|
408
|
+
*/
|
|
409
|
+
function isHeadless(env = process.env, platform = process.platform) {
|
|
410
|
+
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY)
|
|
411
|
+
return true;
|
|
412
|
+
if (platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY)
|
|
413
|
+
return true;
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
async function deviceLogin(opts) {
|
|
417
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
418
|
+
const log = opts.log ?? ((line) => console.log(line));
|
|
419
|
+
const now = opts.now ?? Date.now;
|
|
420
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
421
|
+
const reg = await fetchImpl(`${opts.backendUrl}/oauth/register`, {
|
|
422
|
+
method: "POST",
|
|
423
|
+
headers: { "Content-Type": "application/json" },
|
|
424
|
+
body: JSON.stringify({
|
|
425
|
+
client_name: opts.clientName ?? `Codiedev CLI on ${os.hostname()}`,
|
|
426
|
+
redirect_uris: [],
|
|
427
|
+
grant_types: [exports.DEVICE_CODE_GRANT, "refresh_token"],
|
|
428
|
+
}),
|
|
429
|
+
});
|
|
430
|
+
if (reg.status !== 201)
|
|
431
|
+
throw new Error(`Sign-in setup failed (HTTP ${reg.status}).`);
|
|
432
|
+
const { client_id: clientId } = (await reg.json());
|
|
433
|
+
const start = await fetchImpl(`${opts.backendUrl}/oauth/device_authorization`, {
|
|
434
|
+
method: "POST",
|
|
435
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
436
|
+
body: new URLSearchParams({ client_id: clientId, scope: "cli" }).toString(),
|
|
437
|
+
});
|
|
438
|
+
const auth = (await start.json().catch(() => null));
|
|
439
|
+
if (start.status !== 200 || !auth?.device_code || !auth.user_code || !auth.verification_uri) {
|
|
440
|
+
throw new Error(`Sign-in setup failed: ${auth?.error_description ?? `HTTP ${start.status}`}`);
|
|
441
|
+
}
|
|
442
|
+
const complete = auth.verification_uri_complete ?? auth.verification_uri;
|
|
443
|
+
if (opts.onCode) {
|
|
444
|
+
opts.onCode({ userCode: auth.user_code, verificationUri: auth.verification_uri, verificationUriComplete: complete });
|
|
445
|
+
}
|
|
446
|
+
log("To sign in, open this link on any device (your laptop or phone) and approve:\n");
|
|
447
|
+
log(` ${complete}\n`);
|
|
448
|
+
log(`Your code: ${auth.user_code} (check it matches the page)\n`);
|
|
449
|
+
log("Waiting for approval…");
|
|
450
|
+
let intervalMs = Math.max(1, Number(auth.interval ?? 5)) * 1000;
|
|
451
|
+
const deadline = now() + Number(auth.expires_in ?? 600) * 1000;
|
|
452
|
+
for (;;) {
|
|
453
|
+
await sleep(intervalMs);
|
|
454
|
+
if (now() > deadline)
|
|
455
|
+
throw new Error("The code expired before it was approved. Run `codiedev connect` again.");
|
|
456
|
+
let res;
|
|
457
|
+
try {
|
|
458
|
+
res = await postForm(fetchImpl, `${opts.backendUrl}/oauth/token`, { grant_type: exports.DEVICE_CODE_GRANT, device_code: auth.device_code, client_id: clientId }, REFRESH_TIMEOUT_MS);
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
continue; // network blip: keep polling until the code expires
|
|
462
|
+
}
|
|
463
|
+
const body = res.body;
|
|
464
|
+
if (res.status === 200 && typeof body?.access_token === "string" && typeof body.refresh_token === "string") {
|
|
465
|
+
if (body.scope !== "cli")
|
|
466
|
+
throw new Error("The server didn't grant CLI access.");
|
|
467
|
+
return {
|
|
468
|
+
accessToken: body.access_token,
|
|
469
|
+
refreshToken: body.refresh_token,
|
|
470
|
+
expiresAt: now() + Number(body.expires_in ?? 0) * 1000,
|
|
471
|
+
clientId,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
switch (body?.error) {
|
|
475
|
+
case "authorization_pending":
|
|
476
|
+
continue;
|
|
477
|
+
case "slow_down":
|
|
478
|
+
intervalMs += 5000;
|
|
479
|
+
continue;
|
|
480
|
+
case "access_denied":
|
|
481
|
+
throw new Error("Sign-in was denied in the browser.");
|
|
482
|
+
case "expired_token":
|
|
483
|
+
throw new Error("The code expired before it was approved. Run `codiedev connect` again.");
|
|
484
|
+
default:
|
|
485
|
+
throw new Error(`Sign-in failed: ${body?.error_description ?? body?.error ?? `HTTP ${res.status}`}`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/** `codiedev logout`: end a browser sign-in server-side, forget it locally. */
|
|
490
|
+
async function logout(deps) {
|
|
491
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
492
|
+
const config = readConfigAt(deps.configPath);
|
|
493
|
+
if (!config?.token)
|
|
494
|
+
return { wasConnected: false, hadBrowserAuth: false, serverRevoked: false };
|
|
495
|
+
let serverRevoked = false;
|
|
496
|
+
const hadBrowserAuth = !!config.auth;
|
|
497
|
+
if (config.token && config.auth) {
|
|
498
|
+
try {
|
|
499
|
+
const res = await fetchImpl(`${config.backendUrl}/api/cli/logout`, {
|
|
500
|
+
method: "POST",
|
|
501
|
+
headers: { Authorization: `Bearer ${config.token}` },
|
|
502
|
+
});
|
|
503
|
+
serverRevoked = res.ok && (await res.json().catch(() => null))?.revoked === true;
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
/* offline: still forget locally; the portal can revoke it */
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const out = { ...config, token: "" };
|
|
510
|
+
delete out.auth;
|
|
511
|
+
writeConfigAtomic(deps.configPath, out);
|
|
512
|
+
return { wasConnected: true, hadBrowserAuth, serverRevoked };
|
|
513
|
+
}
|
package/dist/codie.js
CHANGED
|
@@ -59,6 +59,11 @@ const upgradeNudge_1 = require("./upgradeNudge");
|
|
|
59
59
|
const codie_1 = require("./packs/codie");
|
|
60
60
|
const paths_1 = require("./packs/paths");
|
|
61
61
|
const managedPi_1 = require("./managedPi");
|
|
62
|
+
const cliSession_1 = require("./cliSession");
|
|
63
|
+
// The engine reads its gateway key once, at launch. With browser sign-in the
|
|
64
|
+
// key is a 24h access token, so start every session with at least 12h left
|
|
65
|
+
// (refreshing first if needed) instead of one that dies mid-task.
|
|
66
|
+
const CODIE_MIN_TOKEN_VALIDITY_MS = 12 * 60 * 60_000;
|
|
62
67
|
// How long to let the gateway finish booking the last request before asking
|
|
63
68
|
// what the session cost.
|
|
64
69
|
const USAGE_SETTLE_MS = 1200;
|
|
@@ -91,7 +96,8 @@ function existingInlineConfig() {
|
|
|
91
96
|
}
|
|
92
97
|
}
|
|
93
98
|
// The one credential. `codiedev connect` wrote it; the gateway validates the
|
|
94
|
-
// same userApiTokens row
|
|
99
|
+
// same userApiTokens row (a browser sign-in reports through its own row), so
|
|
100
|
+
// there is no second harness token to mint.
|
|
95
101
|
function connectedCodiedevConfig() {
|
|
96
102
|
try {
|
|
97
103
|
return JSON.parse(fs.readFileSync(path.join((0, configDir_1.codiedevStateDir)(), "config.json"), "utf8"));
|
|
@@ -171,7 +177,21 @@ function installBrandWordmarkUnsafe(runtime) {
|
|
|
171
177
|
}
|
|
172
178
|
async function main() {
|
|
173
179
|
(0, codieRuntime_1.assertCodieNodeVersion)(process.versions.node);
|
|
180
|
+
if (!process.env.CODIEDEV_GATEWAY_KEY && !process.env.CODIEDEV_TOKEN) {
|
|
181
|
+
const fresh = await (0, cliSession_1.ensureFreshToken)({ configPath: path.join((0, configDir_1.codiedevStateDir)(), "config.json") }, CODIE_MIN_TOKEN_VALIDITY_MS);
|
|
182
|
+
if (fresh.status === "signed_out") {
|
|
183
|
+
throw new Error("Your Codiedev sign-in has ended. Run `codiedev connect` to sign in again");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
174
186
|
const connected = connectedCodiedevConfig();
|
|
187
|
+
// Browser sign-in: let the engine's per-request hook keep the 24h key
|
|
188
|
+
// fresh for sessions that run longer (assets/session-key.mjs). Not for an
|
|
189
|
+
// explicit key from the environment, and not for pasted API tokens.
|
|
190
|
+
if (!process.env.CODIEDEV_GATEWAY_KEY && !process.env.CODIEDEV_TOKEN && connected.auth) {
|
|
191
|
+
process.env.CODIEDEV_SESSION_CONFIG = path.join((0, configDir_1.codiedevStateDir)(), "config.json");
|
|
192
|
+
process.env.CODIEDEV_CLI_JS = path.join(__dirname, "cli.js");
|
|
193
|
+
process.env.CODIEDEV_NODE = process.execPath;
|
|
194
|
+
}
|
|
175
195
|
const key = process.env.CODIEDEV_GATEWAY_KEY ||
|
|
176
196
|
process.env.CODIEDEV_TOKEN ||
|
|
177
197
|
(typeof connected.token === "string" ? connected.token.trim() : "");
|
package/dist/configDir.d.ts
CHANGED
|
@@ -1 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user's home, always absolute. $HOME can arrive relative or unexpanded ("~", ".", "") from a
|
|
3
|
+
* sandboxed shell or a wrapper; trusting it once wrote hook commands like ".codiedev/bin/…" into
|
|
4
|
+
* ~/.claude/settings.json, which only resolve when Claude Code starts in the home folder, so
|
|
5
|
+
* capture silently failed in every project. Fall back to the account's home from the OS.
|
|
6
|
+
*/
|
|
7
|
+
export declare function realHome(): string;
|
|
8
|
+
/** $HOME when it's a real absolute path, else the OS account's home, else resolved against cwd. */
|
|
9
|
+
export declare function pickHome(fromEnv: string | undefined, fromOs: string | undefined): string;
|
|
1
10
|
export declare function codiedevStateDir(): string;
|
package/dist/configDir.js
CHANGED
|
@@ -33,6 +33,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.realHome = realHome;
|
|
37
|
+
exports.pickHome = pickHome;
|
|
36
38
|
exports.codiedevStateDir = codiedevStateDir;
|
|
37
39
|
// Single source of truth for where the CLI's config/state lives on disk.
|
|
38
40
|
//
|
|
@@ -46,9 +48,31 @@ exports.codiedevStateDir = codiedevStateDir;
|
|
|
46
48
|
// without creating a circular import.
|
|
47
49
|
const path = __importStar(require("path"));
|
|
48
50
|
const os = __importStar(require("os"));
|
|
51
|
+
/**
|
|
52
|
+
* The user's home, always absolute. $HOME can arrive relative or unexpanded ("~", ".", "") from a
|
|
53
|
+
* sandboxed shell or a wrapper; trusting it once wrote hook commands like ".codiedev/bin/…" into
|
|
54
|
+
* ~/.claude/settings.json, which only resolve when Claude Code starts in the home folder, so
|
|
55
|
+
* capture silently failed in every project. Fall back to the account's home from the OS.
|
|
56
|
+
*/
|
|
57
|
+
function realHome() {
|
|
58
|
+
let fromOs;
|
|
59
|
+
try {
|
|
60
|
+
fromOs = os.userInfo().homedir;
|
|
61
|
+
}
|
|
62
|
+
catch { /* no passwd entry */ }
|
|
63
|
+
return pickHome(os.homedir(), fromOs);
|
|
64
|
+
}
|
|
65
|
+
/** $HOME when it's a real absolute path, else the OS account's home, else resolved against cwd. */
|
|
66
|
+
function pickHome(fromEnv, fromOs) {
|
|
67
|
+
if (fromEnv && path.isAbsolute(fromEnv) && !fromEnv.split(path.sep).includes("~"))
|
|
68
|
+
return fromEnv;
|
|
69
|
+
if (fromOs && path.isAbsolute(fromOs))
|
|
70
|
+
return fromOs;
|
|
71
|
+
return path.resolve(fromEnv || ".");
|
|
72
|
+
}
|
|
49
73
|
const CONFIG_DIR = process.env.CODIEDEV_CONFIG_DIR?.trim()
|
|
50
74
|
? path.resolve(process.env.CODIEDEV_CONFIG_DIR.trim())
|
|
51
|
-
: path.join(
|
|
75
|
+
: path.join(realHome(), ".codiedev");
|
|
52
76
|
function codiedevStateDir() {
|
|
53
77
|
return CONFIG_DIR;
|
|
54
78
|
}
|
package/dist/connect.js
CHANGED
|
@@ -41,6 +41,7 @@ const crypto = __importStar(require("crypto"));
|
|
|
41
41
|
const os = __importStar(require("os"));
|
|
42
42
|
const path = __importStar(require("path"));
|
|
43
43
|
const utils_1 = require("./utils");
|
|
44
|
+
const cliSession_1 = require("./cliSession");
|
|
44
45
|
const connectFlow_1 = require("./connectFlow");
|
|
45
46
|
const version_1 = require("./version");
|
|
46
47
|
const upgradeNudge_1 = require("./upgradeNudge");
|
|
@@ -60,6 +61,16 @@ const BACKEND_URL = process.env.CODIEDEV_URL || "https://cli.codiedev.com";
|
|
|
60
61
|
* input in CI). Without a TTY there's no echo to suppress anyway, and
|
|
61
62
|
* raw mode would error.
|
|
62
63
|
*/
|
|
64
|
+
/** One visible line from the terminal (for yes/no questions). */
|
|
65
|
+
function prompt(question) {
|
|
66
|
+
return new Promise((resolve) => {
|
|
67
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
68
|
+
rl.question(question, (answer) => {
|
|
69
|
+
rl.close();
|
|
70
|
+
resolve(answer);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
63
74
|
function promptHidden(question) {
|
|
64
75
|
return new Promise((resolve) => {
|
|
65
76
|
const stdin = process.stdin;
|
|
@@ -168,16 +179,47 @@ async function runConnect(args = []) {
|
|
|
168
179
|
// mint a fresh token from the portal — which is what bit Sonifi's first
|
|
169
180
|
// engineer when he interpreted "no visible token" as "the old one is gone".
|
|
170
181
|
const force = args.includes("--force") || args.includes("-f");
|
|
182
|
+
// `--token`: paste an API token instead of signing in (CI). Otherwise sign
|
|
183
|
+
// in with the browser: a loopback redirect on a desktop, a device code
|
|
184
|
+
// (approve on any device) over SSH or with no display, detected
|
|
185
|
+
// automatically. `--no-browser` / `--browser` force one or the other.
|
|
186
|
+
const pasteToken = args.includes("--token");
|
|
187
|
+
const useDevice = args.includes("--no-browser") || (!args.includes("--browser") && (0, cliSession_1.isHeadless)());
|
|
171
188
|
console.log("\nWelcome to Codiedev CLI\n");
|
|
172
189
|
await (0, upgradeNudge_1.maybePrintUpgradeNudge)();
|
|
173
190
|
console.log("This will connect your coding agent to your Codiedev workspace and install");
|
|
174
191
|
console.log("the session capture hook for org-wide session sharing.\n");
|
|
192
|
+
// A saved browser sign-in is reused only if it still works: refresh it
|
|
193
|
+
// first (a revoked one comes back signed out, with an empty token).
|
|
194
|
+
await (0, cliSession_1.ensureFreshToken)({ configPath: utils_1.CONFIG_PATH }).catch(() => undefined);
|
|
175
195
|
const saved = (0, utils_1.readConfig)();
|
|
176
|
-
const decision = (0, connectFlow_1.resolveConnectToken)({ savedConfig: saved, force });
|
|
196
|
+
const decision = (0, connectFlow_1.resolveConnectToken)({ savedConfig: saved, force: force || pasteToken });
|
|
177
197
|
let token;
|
|
198
|
+
let auth;
|
|
178
199
|
if (decision.source === "reused") {
|
|
179
200
|
token = decision.token;
|
|
180
|
-
|
|
201
|
+
auth = saved?.auth;
|
|
202
|
+
console.log(auth
|
|
203
|
+
? `Reusing your browser sign-in from ~/.codiedev/config.json (run with --force to sign in again).\n`
|
|
204
|
+
: `Reusing API token from ~/.codiedev/config.json (run with --force to sign in again).\n`);
|
|
205
|
+
}
|
|
206
|
+
else if (!pasteToken) {
|
|
207
|
+
// Replacing a browser sign-in: end the old one so the portal doesn't
|
|
208
|
+
// keep listing it.
|
|
209
|
+
if (saved?.auth)
|
|
210
|
+
await (0, cliSession_1.logout)({ configPath: utils_1.CONFIG_PATH }).catch(() => undefined);
|
|
211
|
+
const result = await (0, connectFlow_1.signInWithTokenFallback)({
|
|
212
|
+
signIn: () => useDevice ? (0, cliSession_1.deviceLogin)({ backendUrl: BACKEND_URL }) : (0, cliSession_1.browserLogin)({ backendUrl: BACKEND_URL }),
|
|
213
|
+
interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
214
|
+
askYesNo: async (q) => /^y(es)?$/i.test((await prompt(q)).trim()),
|
|
215
|
+
askToken: promptHidden,
|
|
216
|
+
log: (line) => console.log(line),
|
|
217
|
+
error: (line) => console.error(line),
|
|
218
|
+
});
|
|
219
|
+
if (!result)
|
|
220
|
+
process.exit(1);
|
|
221
|
+
token = result.token;
|
|
222
|
+
auth = result.auth;
|
|
181
223
|
}
|
|
182
224
|
else {
|
|
183
225
|
// Hidden prompt — masks the token with `*` as it's typed/pasted so it
|
|
@@ -217,6 +259,7 @@ async function runConnect(args = []) {
|
|
|
217
259
|
deviceId: saved?.deviceId || crypto.randomUUID(),
|
|
218
260
|
deviceName: saved?.deviceName || os.hostname(),
|
|
219
261
|
captureIgnoredPaths: saved?.captureIgnoredPaths ?? [],
|
|
262
|
+
...(auth ? { auth } : {}),
|
|
220
263
|
};
|
|
221
264
|
try {
|
|
222
265
|
(0, utils_1.writeConfig)(config);
|
package/dist/connectFlow.d.ts
CHANGED
|
@@ -11,3 +11,24 @@ export type ConnectTokenResult = {
|
|
|
11
11
|
source: "prompt-required";
|
|
12
12
|
};
|
|
13
13
|
export declare function resolveConnectToken(input: ConnectTokenInput): ConnectTokenResult;
|
|
14
|
+
export interface SignInResult {
|
|
15
|
+
token: string;
|
|
16
|
+
auth?: import("./cliSession").BrowserAuth;
|
|
17
|
+
}
|
|
18
|
+
export interface SignInDeps {
|
|
19
|
+
/** Browser (loopback) or device-code sign-in; throws on failure. */
|
|
20
|
+
signIn: () => Promise<import("./cliSession").TokenSet>;
|
|
21
|
+
/** A person at a terminal who can answer prompts. */
|
|
22
|
+
interactive: boolean;
|
|
23
|
+
askYesNo: (question: string) => Promise<boolean>;
|
|
24
|
+
askToken: (question: string) => Promise<string>;
|
|
25
|
+
log: (line: string) => void;
|
|
26
|
+
error: (line: string) => void;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Browser sign-in, with the API-token paste as the way out: if the browser
|
|
30
|
+
* flow fails (blocked page, expired code, proxy) an interactive user is asked
|
|
31
|
+
* whether to paste a token instead, rather than being sent to rerun connect.
|
|
32
|
+
* Returns null when there's nothing to continue with (caller exits 1).
|
|
33
|
+
*/
|
|
34
|
+
export declare function signInWithTokenFallback(deps: SignInDeps): Promise<SignInResult | null>;
|
package/dist/connectFlow.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// generate a new one — leaving him thinking the old one had been lost.
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
12
|
exports.resolveConnectToken = resolveConnectToken;
|
|
13
|
+
exports.signInWithTokenFallback = signInWithTokenFallback;
|
|
13
14
|
function resolveConnectToken(input) {
|
|
14
15
|
if (input.force)
|
|
15
16
|
return { source: "prompt-required" };
|
|
@@ -19,3 +20,43 @@ function resolveConnectToken(input) {
|
|
|
19
20
|
return { source: "prompt-required" };
|
|
20
21
|
return { source: "reused", token: input.savedConfig.token };
|
|
21
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Browser sign-in, with the API-token paste as the way out: if the browser
|
|
25
|
+
* flow fails (blocked page, expired code, proxy) an interactive user is asked
|
|
26
|
+
* whether to paste a token instead, rather than being sent to rerun connect.
|
|
27
|
+
* Returns null when there's nothing to continue with (caller exits 1).
|
|
28
|
+
*/
|
|
29
|
+
async function signInWithTokenFallback(deps) {
|
|
30
|
+
deps.log("Prefer an API token? Press Ctrl+C and run `codiedev connect --token`.\n");
|
|
31
|
+
try {
|
|
32
|
+
const s = await deps.signIn();
|
|
33
|
+
deps.log("Signed in. No API key is stored on this machine.\n");
|
|
34
|
+
return {
|
|
35
|
+
token: s.accessToken,
|
|
36
|
+
auth: {
|
|
37
|
+
kind: "browser",
|
|
38
|
+
clientId: s.clientId,
|
|
39
|
+
refreshToken: s.refreshToken,
|
|
40
|
+
accessTokenExpiresAt: s.expiresAt,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
deps.error(`\nBrowser sign-in didn't finish: ${err.message}`);
|
|
46
|
+
if (!deps.interactive) {
|
|
47
|
+
deps.error("To paste an API token instead, run `codiedev connect --token`.");
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
if (!(await deps.askYesNo("Paste an API token instead? [y/N] "))) {
|
|
51
|
+
deps.error("Run `codiedev connect` to try again, or `codiedev connect --token` to paste a token.");
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
deps.log("Create one at https://codiedev.com/portal/integrations/claude-code\n");
|
|
55
|
+
const token = (await deps.askToken("Enter your Codiedev API token: ")).trim();
|
|
56
|
+
if (!token) {
|
|
57
|
+
deps.error("Error: API token is required.");
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return { token };
|
|
61
|
+
}
|
|
62
|
+
}
|
package/dist/hook.js
CHANGED
|
@@ -60,6 +60,8 @@ const captureExclusions_1 = require("./captureExclusions");
|
|
|
60
60
|
const cursorHooks_1 = require("./cursorHooks");
|
|
61
61
|
const copilotHooks_1 = require("./copilotHooks");
|
|
62
62
|
const piHooks_1 = require("./piHooks");
|
|
63
|
+
const cliSession_1 = require("./cliSession");
|
|
64
|
+
const utils_2 = require("./utils");
|
|
63
65
|
// Pure builder for the discovery additionalContext. Returns null when there are
|
|
64
66
|
// no matches. The suggested `codiedev pull` command carries the EXACT top key
|
|
65
67
|
// (e.g. "pr-push.md") rather than a "<key>" placeholder — otherwise agents
|
|
@@ -440,6 +442,11 @@ function resolveMode(arg) {
|
|
|
440
442
|
return "claude-code";
|
|
441
443
|
}
|
|
442
444
|
async function main() {
|
|
445
|
+
// Browser sign-in: refresh the access token under the shared lock before
|
|
446
|
+
// anything reads config (cliSession.ts). A no-op for pasted API tokens and
|
|
447
|
+
// for tokens with time left; never throws, never prints (hook stdout is
|
|
448
|
+
// the agent's).
|
|
449
|
+
await (0, cliSession_1.ensureFreshToken)({ configPath: utils_2.CONFIG_PATH }).catch(() => undefined);
|
|
443
450
|
// Shared upload deps used by both "upload" and "upload-deferred" branches.
|
|
444
451
|
// Extracted to keep both branches in lockstep — any new dep added here
|
|
445
452
|
// automatically applies to the deferred path too.
|
package/dist/launcher.d.ts
CHANGED
|
@@ -8,13 +8,18 @@ export declare function launcherPath(): string;
|
|
|
8
8
|
/** True iff `cmd` invokes the stable launcher (from any state dir). */
|
|
9
9
|
export declare function usesLauncher(cmd: string | undefined): boolean;
|
|
10
10
|
export declare function readInstallRecord(): InstallRecord | null;
|
|
11
|
+
/**
|
|
12
|
+
* The hook script worth recording: absolute and present, and a global install over an npx cache
|
|
13
|
+
* copy (npm prunes ~/.npm/_npx, and hooks pointed there go quietly dead). Null = don't record.
|
|
14
|
+
*/
|
|
15
|
+
export declare function stableHookScript(hookScript: string, globalCandidates?: string[]): string | null;
|
|
11
16
|
/** Point the launcher at `hookScript`. Atomic, so a hook mid-launch never reads half a file. */
|
|
12
|
-
export declare function recordCurrentInstall(
|
|
17
|
+
export declare function recordCurrentInstall(requested: string): boolean;
|
|
13
18
|
/**
|
|
14
19
|
* Make sure the launcher exists and forwards to `hookScript`, returning the
|
|
15
20
|
* launcher path to bake into hook configs — or null if the state dir is not
|
|
16
21
|
* writable, in which case callers keep baking the direct path. Memoized:
|
|
17
22
|
* one connect writes ~20 hook entries.
|
|
18
23
|
*/
|
|
19
|
-
export declare function ensureLauncher(
|
|
24
|
+
export declare function ensureLauncher(requested: string): string | null;
|
|
20
25
|
export declare function resetLauncherCacheForTests(): void;
|
package/dist/launcher.js
CHANGED
|
@@ -48,6 +48,7 @@ exports.LAUNCHER_FILE = void 0;
|
|
|
48
48
|
exports.launcherPath = launcherPath;
|
|
49
49
|
exports.usesLauncher = usesLauncher;
|
|
50
50
|
exports.readInstallRecord = readInstallRecord;
|
|
51
|
+
exports.stableHookScript = stableHookScript;
|
|
51
52
|
exports.recordCurrentInstall = recordCurrentInstall;
|
|
52
53
|
exports.ensureLauncher = ensureLauncher;
|
|
53
54
|
exports.resetLauncherCacheForTests = resetLauncherCacheForTests;
|
|
@@ -114,8 +115,35 @@ function packageVersionFor(hookScript) {
|
|
|
114
115
|
return undefined;
|
|
115
116
|
}
|
|
116
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* The hook script worth recording: absolute and present, and a global install over an npx cache
|
|
120
|
+
* copy (npm prunes ~/.npm/_npx, and hooks pointed there go quietly dead). Null = don't record.
|
|
121
|
+
*/
|
|
122
|
+
function stableHookScript(hookScript, globalCandidates = globalHookScripts()) {
|
|
123
|
+
if (!hookScript || !path.isAbsolute(hookScript) || hookScript.includes(`${path.sep}~${path.sep}`))
|
|
124
|
+
return null;
|
|
125
|
+
if (!fs.existsSync(hookScript))
|
|
126
|
+
return null;
|
|
127
|
+
if (hookScript.includes(`${path.sep}_npx${path.sep}`)) {
|
|
128
|
+
const global = globalCandidates.find((p) => path.isAbsolute(p) && fs.existsSync(p));
|
|
129
|
+
if (global)
|
|
130
|
+
return global;
|
|
131
|
+
}
|
|
132
|
+
return hookScript;
|
|
133
|
+
}
|
|
134
|
+
/** Where a global `npm i -g codiedev` puts hook.js, next to the node running this. */
|
|
135
|
+
function globalHookScripts() {
|
|
136
|
+
const prefix = path.dirname(path.dirname(process.execPath));
|
|
137
|
+
return [
|
|
138
|
+
path.join(prefix, "lib", "node_modules", "codiedev", "dist", "hook.js"),
|
|
139
|
+
path.join(prefix, "node_modules", "codiedev", "dist", "hook.js"),
|
|
140
|
+
];
|
|
141
|
+
}
|
|
117
142
|
/** Point the launcher at `hookScript`. Atomic, so a hook mid-launch never reads half a file. */
|
|
118
|
-
function recordCurrentInstall(
|
|
143
|
+
function recordCurrentInstall(requested) {
|
|
144
|
+
const hookScript = stableHookScript(requested);
|
|
145
|
+
if (!hookScript)
|
|
146
|
+
return false;
|
|
119
147
|
try {
|
|
120
148
|
const record = {
|
|
121
149
|
hookScript,
|
|
@@ -136,11 +164,17 @@ let ensuredFor;
|
|
|
136
164
|
* writable, in which case callers keep baking the direct path. Memoized:
|
|
137
165
|
* one connect writes ~20 hook entries.
|
|
138
166
|
*/
|
|
139
|
-
function ensureLauncher(
|
|
167
|
+
function ensureLauncher(requested) {
|
|
168
|
+
const hookScript = stableHookScript(requested);
|
|
169
|
+
if (!hookScript)
|
|
170
|
+
return null;
|
|
140
171
|
if (ensuredFor !== undefined && ensuredFor === hookScript)
|
|
141
172
|
return launcherPath();
|
|
142
173
|
try {
|
|
143
174
|
const target = launcherPath();
|
|
175
|
+
// A relative launcher path in a hook config only works from one folder; never bake one.
|
|
176
|
+
if (!path.isAbsolute(target))
|
|
177
|
+
return null;
|
|
144
178
|
const source = launcherSource(hookScript);
|
|
145
179
|
let current = null;
|
|
146
180
|
try {
|
package/dist/managedPi.js
CHANGED
|
@@ -94,7 +94,8 @@ exports.BLOCKED_BASE_URL = "http://127.0.0.1:9/blocked-by-codiedev";
|
|
|
94
94
|
* joins the captured pi session (same headers as the OpenCode metadata
|
|
95
95
|
* plugin, assets/git-refs.mjs). */
|
|
96
96
|
exports.GUARD_SOURCE = `// Written by Codiedev on every \`codie --pi\` launch. Edits are overwritten.
|
|
97
|
-
import { execFileSync } from "node:child_process";
|
|
97
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
98
|
+
import { readFileSync } from "node:fs";
|
|
98
99
|
import { getProviders } from "@mariozechner/pi-ai";
|
|
99
100
|
|
|
100
101
|
const PROVIDER = ${JSON.stringify(exports.PI_PROVIDER_ID)};
|
|
@@ -153,8 +154,23 @@ export default function (pi) {
|
|
|
153
154
|
const p = String(event?.input?.path ?? "").replace(/\\\\/g, "/").match(/\\/([a-z0-9][a-z0-9_.:-]{0,119})\\/SKILL\\.md$/i);
|
|
154
155
|
if (event?.toolName === "read" && !event.isError && p && skills.size < 50) skills.add(p[1]);
|
|
155
156
|
});
|
|
157
|
+
// Browser sign-in: pi reads CODIEDEV_GATEWAY_KEY at request time, so put
|
|
158
|
+
// the current token there, refreshing it as it nears expiry (same logic as
|
|
159
|
+
// assets/session-key.mjs). Pasted API tokens: no variables, nothing changes.
|
|
160
|
+
const freshKey = async () => {
|
|
161
|
+
const env = process.env;
|
|
162
|
+
if (!env.CODIEDEV_SESSION_CONFIG || !env.CODIEDEV_CLI_JS || !env.CODIEDEV_NODE) return;
|
|
163
|
+
const read = () => { try { const c = JSON.parse(readFileSync(env.CODIEDEV_SESSION_CONFIG, "utf8")); return c && c.auth && c.token ? c : null; } catch { return null; } };
|
|
164
|
+
let s = read();
|
|
165
|
+
if (!s) return;
|
|
166
|
+
if (!(Number(s.auth.accessTokenExpiresAt) - Date.now() > 30 * 60000)) {
|
|
167
|
+
await new Promise((r) => execFile(env.CODIEDEV_NODE, [env.CODIEDEV_CLI_JS, "session-refresh"], { timeout: 30000 }, () => r()));
|
|
168
|
+
s = read() ?? s;
|
|
169
|
+
}
|
|
170
|
+
env.CODIEDEV_GATEWAY_KEY = s.token;
|
|
171
|
+
};
|
|
156
172
|
// Before every model call in the turn: the agent may have switched branch.
|
|
157
|
-
pi.on("turn_start", async (_event, ctx) => tag(ctx));
|
|
173
|
+
pi.on("turn_start", async (_event, ctx) => { await freshKey(); tag(ctx); });
|
|
158
174
|
pi.on("before_provider_request", (_event, ctx) => {
|
|
159
175
|
// Belt and braces: the request is already headed for BLOCKED.
|
|
160
176
|
if (!ours(ctx.model)) ctx.abort();
|
package/dist/mcp.js
CHANGED
|
@@ -51,6 +51,7 @@ const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
|
|
|
51
51
|
const fs = __importStar(require("fs"));
|
|
52
52
|
const path = __importStar(require("path"));
|
|
53
53
|
const utils_1 = require("./utils");
|
|
54
|
+
const cliSession_1 = require("./cliSession");
|
|
54
55
|
const shared_1 = require("./commands/shared");
|
|
55
56
|
const inboxFormat_1 = require("./inboxFormat");
|
|
56
57
|
const PKG_NAME = "codiedev";
|
|
@@ -399,6 +400,8 @@ function emitTelemetry(config, event) {
|
|
|
399
400
|
});
|
|
400
401
|
}
|
|
401
402
|
server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
403
|
+
// Long-lived process: the access token may expire while it runs.
|
|
404
|
+
await (0, cliSession_1.ensureFreshToken)({ configPath: utils_1.CONFIG_PATH }).catch(() => undefined);
|
|
402
405
|
const config = (0, utils_1.readConfig)();
|
|
403
406
|
if (!config) {
|
|
404
407
|
return {
|
package/dist/utils.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { CaptureLogEntry } from "./captureLog";
|
|
|
6
6
|
import { codiedevStateDir } from "./configDir";
|
|
7
7
|
export { inspectPiExtension, piExtensionPath } from "./piHooks";
|
|
8
8
|
export { codiedevStateDir };
|
|
9
|
+
import type { BrowserAuth } from "./cliSession";
|
|
9
10
|
export interface CodiedevConfig {
|
|
10
11
|
token: string;
|
|
11
12
|
companyId: string;
|
|
@@ -20,6 +21,7 @@ export interface CodiedevConfig {
|
|
|
20
21
|
deviceId?: string;
|
|
21
22
|
deviceName?: string;
|
|
22
23
|
captureIgnoredPaths?: string[];
|
|
24
|
+
auth?: BrowserAuth;
|
|
23
25
|
}
|
|
24
26
|
export declare const CONFIG_PATH: string;
|
|
25
27
|
export declare function readConfig(): CodiedevConfig | null;
|
package/dist/utils.js
CHANGED
|
@@ -150,6 +150,7 @@ const piHooks_1 = require("./piHooks");
|
|
|
150
150
|
var piHooks_2 = require("./piHooks");
|
|
151
151
|
Object.defineProperty(exports, "inspectPiExtension", { enumerable: true, get: function () { return piHooks_2.inspectPiExtension; } });
|
|
152
152
|
Object.defineProperty(exports, "piExtensionPath", { enumerable: true, get: function () { return piHooks_2.piExtensionPath; } });
|
|
153
|
+
const cliSession_1 = require("./cliSession");
|
|
153
154
|
const CONFIG_DIR = (0, configDir_1.codiedevStateDir)();
|
|
154
155
|
exports.CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
|
155
156
|
function readConfig() {
|
|
@@ -171,12 +172,9 @@ function writeConfigAtPath(config, configDir, configPath) {
|
|
|
171
172
|
if (!fs.existsSync(configDir)) {
|
|
172
173
|
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
173
174
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
});
|
|
178
|
-
// writeFile's mode does not replace permissions on a pre-existing file.
|
|
179
|
-
fs.chmodSync(configPath, 0o600);
|
|
175
|
+
// Temp file + rename: hooks read this file concurrently, and a torn read
|
|
176
|
+
// looks like "not connected". The temp file is created 0600.
|
|
177
|
+
(0, cliSession_1.writeConfigAtomic)(configPath, config);
|
|
180
178
|
}
|
|
181
179
|
// Parse a remote URL out of git-config INI text WITHOUT invoking git. Prefers
|
|
182
180
|
// the "origin" remote; falls back to the first remote found. Returns null if
|
|
@@ -1038,7 +1036,11 @@ function resolveHookInvocation(subcommand, preferredNodePath) {
|
|
|
1038
1036
|
// Bake the stable launcher, not this install's path, so later
|
|
1039
1037
|
// upgrades (including pnpm's hashed dirs) never strand the hook.
|
|
1040
1038
|
const launcher = (0, launcher_1.ensureLauncher)(hookScript);
|
|
1041
|
-
|
|
1039
|
+
// No launcher and no stable install to point at (e.g. an npx cache under a bogus "~"):
|
|
1040
|
+
// the npx fallback below beats baking a path that won't exist tomorrow.
|
|
1041
|
+
if (launcher || (0, launcher_1.stableHookScript)(hookScript)) {
|
|
1042
|
+
return { command: nodePath, args: [launcher ?? hookScript, subcommand] };
|
|
1043
|
+
}
|
|
1042
1044
|
}
|
|
1043
1045
|
}
|
|
1044
1046
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codiedev",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.80",
|
|
4
4
|
"description": "Connect Claude Code, Codex, Cursor, OpenCode, or VS Code Copilot to Codiedev for team session capture and artifact collaboration — and run the governed Codie harness",
|
|
5
5
|
"bin": {
|
|
6
6
|
"codiedev": "dist/cli.js",
|