@linkedclaw/cli 0.1.7 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +417 -27
- package/dist/bin.js.map +1 -1
- package/package.json +3 -3
- package/src/auth/decide.ts +48 -0
- package/src/auth/device.ts +203 -0
- package/src/auth/loopback.ts +239 -0
- package/src/auth/pkce.ts +22 -0
- package/src/commands/auth.ts +96 -10
- package/test/auth-decide.test.ts +38 -0
- package/test/auth-device.test.ts +126 -0
- package/test/auth-loopback.test.ts +190 -0
- package/test/auth-pkce.test.ts +32 -0
package/dist/bin.js
CHANGED
|
@@ -6357,19 +6357,409 @@ function registerArenaCommands(program2) {
|
|
|
6357
6357
|
});
|
|
6358
6358
|
}
|
|
6359
6359
|
|
|
6360
|
+
// src/auth/decide.ts
|
|
6361
|
+
import { existsSync as existsSync2 } from "fs";
|
|
6362
|
+
import { hostname, platform } from "os";
|
|
6363
|
+
function detectLabel(packageVersion) {
|
|
6364
|
+
const host = hostname();
|
|
6365
|
+
return truncate(
|
|
6366
|
+
`linkedclaw-cli/${packageVersion} on ${platform()} (host: ${host})`,
|
|
6367
|
+
120
|
|
6368
|
+
);
|
|
6369
|
+
}
|
|
6370
|
+
function truncate(s, n) {
|
|
6371
|
+
return s.length <= n ? s : s.slice(0, n - 1) + "\u2026";
|
|
6372
|
+
}
|
|
6373
|
+
function isHeadless(env = process.env) {
|
|
6374
|
+
if (env.SSH_CLIENT || env.SSH_TTY) return true;
|
|
6375
|
+
if (env.CODESPACES === "true") return true;
|
|
6376
|
+
if (env.LINKEDCLAW_FORCE_DEVICE_FLOW === "1") return true;
|
|
6377
|
+
if (existsSync2("/.dockerenv") || existsSync2("/run/.containerenv")) return true;
|
|
6378
|
+
if (platform() === "linux") {
|
|
6379
|
+
if (!env.DISPLAY && !env.WAYLAND_DISPLAY) return true;
|
|
6380
|
+
}
|
|
6381
|
+
return false;
|
|
6382
|
+
}
|
|
6383
|
+
|
|
6384
|
+
// src/auth/loopback.ts
|
|
6385
|
+
import { createServer } from "http";
|
|
6386
|
+
|
|
6387
|
+
// src/auth/pkce.ts
|
|
6388
|
+
import { createHash as createHash2, randomBytes } from "crypto";
|
|
6389
|
+
function generateVerifier() {
|
|
6390
|
+
return randomBytes(64).toString("base64url");
|
|
6391
|
+
}
|
|
6392
|
+
function deriveChallenge(verifier) {
|
|
6393
|
+
return createHash2("sha256").update(verifier).digest("base64url");
|
|
6394
|
+
}
|
|
6395
|
+
function generateState() {
|
|
6396
|
+
return randomBytes(16).toString("base64url");
|
|
6397
|
+
}
|
|
6398
|
+
|
|
6399
|
+
// src/auth/loopback.ts
|
|
6400
|
+
var LoopbackError = class extends Error {
|
|
6401
|
+
constructor(message, recoverable) {
|
|
6402
|
+
super(message);
|
|
6403
|
+
this.recoverable = recoverable;
|
|
6404
|
+
this.name = "LoopbackError";
|
|
6405
|
+
}
|
|
6406
|
+
recoverable;
|
|
6407
|
+
};
|
|
6408
|
+
var SUCCESS_HTML = `<!doctype html>
|
|
6409
|
+
<html><head><meta charset="utf-8"><title>LinkedClaw \u2014 Authorized</title></head>
|
|
6410
|
+
<body style="font-family:system-ui,sans-serif;text-align:center;padding:48px">
|
|
6411
|
+
<h2>\u2705 Authorized</h2>
|
|
6412
|
+
<p>You can close this tab and return to your terminal.</p>
|
|
6413
|
+
</body></html>`;
|
|
6414
|
+
var ERROR_HTML = (msg) => `<!doctype html>
|
|
6415
|
+
<html><head><meta charset="utf-8"><title>LinkedClaw \u2014 Error</title></head>
|
|
6416
|
+
<body style="font-family:system-ui,sans-serif;text-align:center;padding:48px">
|
|
6417
|
+
<h2>\u274C Authorization failed</h2>
|
|
6418
|
+
<p>${msg}</p>
|
|
6419
|
+
<p>Return to your terminal \u2014 the CLI will retry or fall back automatically.</p>
|
|
6420
|
+
</body></html>`;
|
|
6421
|
+
async function runLoopback(opts) {
|
|
6422
|
+
const verifier = generateVerifier();
|
|
6423
|
+
const challenge = deriveChallenge(verifier);
|
|
6424
|
+
const state = generateState();
|
|
6425
|
+
const server = createServer();
|
|
6426
|
+
await new Promise((resolve3, reject) => {
|
|
6427
|
+
server.once("error", reject);
|
|
6428
|
+
server.listen(0, "127.0.0.1", () => {
|
|
6429
|
+
server.removeListener("error", reject);
|
|
6430
|
+
resolve3();
|
|
6431
|
+
});
|
|
6432
|
+
});
|
|
6433
|
+
const port = server.address().port;
|
|
6434
|
+
const redirectUri = `http://127.0.0.1:${port}/cb`;
|
|
6435
|
+
let initResp;
|
|
6436
|
+
try {
|
|
6437
|
+
initResp = await initiate(opts.cloudUrl, {
|
|
6438
|
+
client_label: opts.clientLabel,
|
|
6439
|
+
requested_scope: opts.requestedScope,
|
|
6440
|
+
redirect_uri: redirectUri,
|
|
6441
|
+
code_challenge: challenge,
|
|
6442
|
+
code_challenge_method: "S256",
|
|
6443
|
+
state
|
|
6444
|
+
});
|
|
6445
|
+
} catch (err) {
|
|
6446
|
+
server.close();
|
|
6447
|
+
throw new LoopbackError(
|
|
6448
|
+
`Failed to start authorization: ${err.message}`,
|
|
6449
|
+
// network failure shouldn't fall through to device flow — it'll fail there too.
|
|
6450
|
+
false
|
|
6451
|
+
);
|
|
6452
|
+
}
|
|
6453
|
+
try {
|
|
6454
|
+
await opts.openBrowser(initResp.authorize_url);
|
|
6455
|
+
} catch {
|
|
6456
|
+
server.close();
|
|
6457
|
+
throw new LoopbackError(
|
|
6458
|
+
"Could not open browser; switching to device flow.",
|
|
6459
|
+
true
|
|
6460
|
+
);
|
|
6461
|
+
}
|
|
6462
|
+
if (opts.onUserPrompt) opts.onUserPrompt(initResp.authorize_url);
|
|
6463
|
+
const timeoutMs = opts.timeoutMs ?? 6e4;
|
|
6464
|
+
const callbackParams = await waitForCallback(server, timeoutMs).catch((err) => {
|
|
6465
|
+
server.close();
|
|
6466
|
+
throw err;
|
|
6467
|
+
});
|
|
6468
|
+
server.close();
|
|
6469
|
+
if (callbackParams.error) {
|
|
6470
|
+
throw new LoopbackError(`Authorization denied: ${callbackParams.error}`, false);
|
|
6471
|
+
}
|
|
6472
|
+
if (!callbackParams.code) {
|
|
6473
|
+
throw new LoopbackError("Authorization callback missing code.", true);
|
|
6474
|
+
}
|
|
6475
|
+
if (callbackParams.state !== state) {
|
|
6476
|
+
throw new LoopbackError("Authorization callback state mismatch.", false);
|
|
6477
|
+
}
|
|
6478
|
+
const tokenResp = await exchangeToken(opts.cloudUrl, {
|
|
6479
|
+
grant_type: "authorization_code",
|
|
6480
|
+
code: callbackParams.code,
|
|
6481
|
+
code_verifier: verifier
|
|
6482
|
+
});
|
|
6483
|
+
return {
|
|
6484
|
+
apiKey: tokenResp.api_key,
|
|
6485
|
+
userId: tokenResp.user_id,
|
|
6486
|
+
handle: tokenResp.handle ?? null,
|
|
6487
|
+
scope: tokenResp.scope,
|
|
6488
|
+
keyId: tokenResp.key_id
|
|
6489
|
+
};
|
|
6490
|
+
}
|
|
6491
|
+
function waitForCallback(server, timeoutMs) {
|
|
6492
|
+
return new Promise((resolve3, reject) => {
|
|
6493
|
+
const timer = setTimeout(() => {
|
|
6494
|
+
reject(new LoopbackError("Browser callback timed out.", true));
|
|
6495
|
+
}, timeoutMs);
|
|
6496
|
+
server.on("request", (req, res) => {
|
|
6497
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1`);
|
|
6498
|
+
if (url.pathname !== "/cb") {
|
|
6499
|
+
res.statusCode = 404;
|
|
6500
|
+
res.end();
|
|
6501
|
+
return;
|
|
6502
|
+
}
|
|
6503
|
+
const params = {
|
|
6504
|
+
code: url.searchParams.get("code") ?? void 0,
|
|
6505
|
+
state: url.searchParams.get("state") ?? void 0,
|
|
6506
|
+
error: url.searchParams.get("error") ?? void 0
|
|
6507
|
+
};
|
|
6508
|
+
const html = params.error ? ERROR_HTML(params.error) : params.code ? SUCCESS_HTML : ERROR_HTML("Missing code parameter");
|
|
6509
|
+
res.statusCode = params.code ? 200 : 400;
|
|
6510
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
6511
|
+
res.end(html);
|
|
6512
|
+
clearTimeout(timer);
|
|
6513
|
+
resolve3(params);
|
|
6514
|
+
});
|
|
6515
|
+
});
|
|
6516
|
+
}
|
|
6517
|
+
async function initiate(cloudUrl, body) {
|
|
6518
|
+
const resp = await fetch(`${cloudUrl}/api/v1/auth/oauth/initiate`, {
|
|
6519
|
+
method: "POST",
|
|
6520
|
+
headers: { "Content-Type": "application/json" },
|
|
6521
|
+
body: JSON.stringify(body)
|
|
6522
|
+
});
|
|
6523
|
+
if (resp.status !== 201) {
|
|
6524
|
+
const detail = await safeDetail(resp);
|
|
6525
|
+
throw new Error(`/auth/oauth/initiate ${resp.status}: ${detail}`);
|
|
6526
|
+
}
|
|
6527
|
+
return await resp.json();
|
|
6528
|
+
}
|
|
6529
|
+
async function exchangeToken(cloudUrl, body) {
|
|
6530
|
+
const resp = await fetch(`${cloudUrl}/api/v1/auth/oauth/token`, {
|
|
6531
|
+
method: "POST",
|
|
6532
|
+
headers: { "Content-Type": "application/json" },
|
|
6533
|
+
body: JSON.stringify(body)
|
|
6534
|
+
});
|
|
6535
|
+
if (resp.status !== 200) {
|
|
6536
|
+
const detail = await safeDetail(resp);
|
|
6537
|
+
throw new LoopbackError(`Token exchange failed: ${detail}`, false);
|
|
6538
|
+
}
|
|
6539
|
+
return await resp.json();
|
|
6540
|
+
}
|
|
6541
|
+
async function safeDetail(resp) {
|
|
6542
|
+
try {
|
|
6543
|
+
const j = await resp.json();
|
|
6544
|
+
return j.detail ?? `${resp.status}`;
|
|
6545
|
+
} catch {
|
|
6546
|
+
return `${resp.status}`;
|
|
6547
|
+
}
|
|
6548
|
+
}
|
|
6549
|
+
|
|
6550
|
+
// src/auth/device.ts
|
|
6551
|
+
import { setTimeout as sleep } from "timers/promises";
|
|
6552
|
+
var DeviceFlowError = class extends Error {
|
|
6553
|
+
constructor(message, code) {
|
|
6554
|
+
super(message);
|
|
6555
|
+
this.code = code;
|
|
6556
|
+
this.name = "DeviceFlowError";
|
|
6557
|
+
}
|
|
6558
|
+
code;
|
|
6559
|
+
};
|
|
6560
|
+
async function runDeviceFlow(opts) {
|
|
6561
|
+
const init = await issueDeviceCode(opts.cloudUrl, {
|
|
6562
|
+
client_label: opts.clientLabel,
|
|
6563
|
+
requested_scope: opts.requestedScope
|
|
6564
|
+
});
|
|
6565
|
+
if (opts.openBrowser) {
|
|
6566
|
+
try {
|
|
6567
|
+
await opts.openBrowser(init.verification_uri_complete);
|
|
6568
|
+
} catch {
|
|
6569
|
+
}
|
|
6570
|
+
}
|
|
6571
|
+
opts.onUserPrompt(init);
|
|
6572
|
+
const deadline = Date.now() + init.expires_in * 1e3;
|
|
6573
|
+
let interval = init.interval;
|
|
6574
|
+
while (Date.now() < deadline) {
|
|
6575
|
+
const before = Date.now();
|
|
6576
|
+
const result = await pollOnce(
|
|
6577
|
+
opts.cloudUrl,
|
|
6578
|
+
init.device_code,
|
|
6579
|
+
opts.pollOverride
|
|
6580
|
+
);
|
|
6581
|
+
if (result.kind === "approved") {
|
|
6582
|
+
return {
|
|
6583
|
+
apiKey: result.api_key,
|
|
6584
|
+
userId: result.user_id,
|
|
6585
|
+
handle: result.handle ?? null,
|
|
6586
|
+
scope: result.scope,
|
|
6587
|
+
keyId: result.key_id
|
|
6588
|
+
};
|
|
6589
|
+
}
|
|
6590
|
+
if (result.kind === "slow_down") {
|
|
6591
|
+
interval = Math.min(interval * 2, 30);
|
|
6592
|
+
}
|
|
6593
|
+
const elapsed = Date.now() - before;
|
|
6594
|
+
const wait = Math.max(0, interval * 1e3 - elapsed);
|
|
6595
|
+
await sleep(wait);
|
|
6596
|
+
}
|
|
6597
|
+
throw new DeviceFlowError(
|
|
6598
|
+
"Login window expired. Run `linkedclaw login` to retry.",
|
|
6599
|
+
"expired_token"
|
|
6600
|
+
);
|
|
6601
|
+
}
|
|
6602
|
+
async function pollOnce(cloudUrl, deviceCode, override) {
|
|
6603
|
+
const raw = override ? await override(deviceCode) : await rawPoll(cloudUrl, deviceCode);
|
|
6604
|
+
return interpretPoll(raw);
|
|
6605
|
+
}
|
|
6606
|
+
async function rawPoll(cloudUrl, deviceCode) {
|
|
6607
|
+
const resp = await fetch(`${cloudUrl}/api/v1/auth/device/poll`, {
|
|
6608
|
+
method: "POST",
|
|
6609
|
+
headers: { "Content-Type": "application/json" },
|
|
6610
|
+
body: JSON.stringify({ device_code: deviceCode })
|
|
6611
|
+
});
|
|
6612
|
+
if (resp.status === 400) {
|
|
6613
|
+
let detail = "invalid_grant";
|
|
6614
|
+
try {
|
|
6615
|
+
const j = await resp.json();
|
|
6616
|
+
if (j.detail) detail = j.detail;
|
|
6617
|
+
} catch {
|
|
6618
|
+
}
|
|
6619
|
+
if (detail === "slow_down") return { __slow: true };
|
|
6620
|
+
throw new DeviceFlowError(_humanize(detail), detail);
|
|
6621
|
+
}
|
|
6622
|
+
if (resp.status !== 200) {
|
|
6623
|
+
throw new DeviceFlowError(`Poll failed: HTTP ${resp.status}`, "http_error");
|
|
6624
|
+
}
|
|
6625
|
+
return await resp.json();
|
|
6626
|
+
}
|
|
6627
|
+
function interpretPoll(raw) {
|
|
6628
|
+
if (raw && typeof raw === "object" && "__slow" in raw) {
|
|
6629
|
+
return { kind: "slow_down" };
|
|
6630
|
+
}
|
|
6631
|
+
const obj = raw ?? {};
|
|
6632
|
+
if (obj.status === "pending") return { kind: "pending" };
|
|
6633
|
+
if (obj.status === "approved") {
|
|
6634
|
+
return {
|
|
6635
|
+
kind: "approved",
|
|
6636
|
+
api_key: String(obj.api_key),
|
|
6637
|
+
user_id: String(obj.user_id),
|
|
6638
|
+
handle: obj.handle ?? null,
|
|
6639
|
+
scope: String(obj.scope),
|
|
6640
|
+
key_id: String(obj.key_id)
|
|
6641
|
+
};
|
|
6642
|
+
}
|
|
6643
|
+
throw new DeviceFlowError(`Unexpected poll status: ${obj.status}`, "protocol_error");
|
|
6644
|
+
}
|
|
6645
|
+
function _humanize(code) {
|
|
6646
|
+
switch (code) {
|
|
6647
|
+
case "expired_token":
|
|
6648
|
+
return "Login window expired. Run `linkedclaw login` to retry.";
|
|
6649
|
+
case "access_denied":
|
|
6650
|
+
return "Authorization denied by user.";
|
|
6651
|
+
case "invalid_grant":
|
|
6652
|
+
return "Authorization session is no longer valid.";
|
|
6653
|
+
default:
|
|
6654
|
+
return code;
|
|
6655
|
+
}
|
|
6656
|
+
}
|
|
6657
|
+
async function issueDeviceCode(cloudUrl, body) {
|
|
6658
|
+
const resp = await fetch(`${cloudUrl}/api/v1/auth/device/code`, {
|
|
6659
|
+
method: "POST",
|
|
6660
|
+
headers: { "Content-Type": "application/json" },
|
|
6661
|
+
body: JSON.stringify(body)
|
|
6662
|
+
});
|
|
6663
|
+
if (resp.status !== 201) {
|
|
6664
|
+
let detail = `${resp.status}`;
|
|
6665
|
+
try {
|
|
6666
|
+
const j = await resp.json();
|
|
6667
|
+
if (j.detail) detail = j.detail;
|
|
6668
|
+
} catch {
|
|
6669
|
+
}
|
|
6670
|
+
throw new DeviceFlowError(`/auth/device/code ${resp.status}: ${detail}`, "init_failed");
|
|
6671
|
+
}
|
|
6672
|
+
return await resp.json();
|
|
6673
|
+
}
|
|
6674
|
+
|
|
6360
6675
|
// src/commands/auth.ts
|
|
6676
|
+
var CLI_VERSION = "0.2.0";
|
|
6361
6677
|
function registerAuthCommands(program2) {
|
|
6362
|
-
program2.command("login").description(
|
|
6678
|
+
program2.command("login").description(
|
|
6679
|
+
"Authenticate via browser (loopback PKCE; falls back to device code if headless)."
|
|
6680
|
+
).option("--api-key <key>", "Skip browser; store key directly").option("--paste", "Skip browser; prompt for key on stdin").option("--device", "Force device flow (skip loopback)").option("--scope <scope>", "Requested scope: full | read | invoke", "full").option("--label <label>", "Client label override (default: auto-detected)").option("--cloud-url <url>", "Override cloud URL").action(async (opts) => {
|
|
6363
6681
|
await runCommand(async () => {
|
|
6364
|
-
let apiKey = opts.apiKey;
|
|
6365
|
-
if (!apiKey) {
|
|
6366
|
-
apiKey = await readLine("Paste API key: ");
|
|
6367
|
-
}
|
|
6368
|
-
if (!apiKey) throw new Error("empty api key");
|
|
6369
6682
|
const prev = readFileConfig();
|
|
6370
|
-
const
|
|
6371
|
-
|
|
6372
|
-
|
|
6683
|
+
const cloudUrl = opts.cloudUrl ?? prev.cloudUrl ?? process.env.LINKEDCLAW_CLOUD_URL ?? DEFAULT_CLOUD_URL;
|
|
6684
|
+
if (opts.apiKey || opts.paste) {
|
|
6685
|
+
let apiKey = opts.apiKey;
|
|
6686
|
+
if (!apiKey) apiKey = await readLine("Paste API key: ");
|
|
6687
|
+
if (!apiKey) throw new Error("empty api key");
|
|
6688
|
+
writeFileConfig({ ...prev, apiKey, cloudUrl });
|
|
6689
|
+
return { ok: true, mode: "paste", path: configPath() };
|
|
6690
|
+
}
|
|
6691
|
+
const clientLabel = opts.label ?? detectLabel(CLI_VERSION);
|
|
6692
|
+
const requestedScope = opts.scope ?? "full";
|
|
6693
|
+
const openBrowser = async (url) => {
|
|
6694
|
+
const open = (await import("open")).default;
|
|
6695
|
+
await open(url);
|
|
6696
|
+
};
|
|
6697
|
+
const forceDevice = Boolean(opts.device) || isHeadless();
|
|
6698
|
+
if (!forceDevice) {
|
|
6699
|
+
try {
|
|
6700
|
+
const result2 = await runLoopback({
|
|
6701
|
+
cloudUrl,
|
|
6702
|
+
clientLabel,
|
|
6703
|
+
requestedScope,
|
|
6704
|
+
openBrowser,
|
|
6705
|
+
onUserPrompt: (url) => {
|
|
6706
|
+
process.stderr.write(
|
|
6707
|
+
`Opened browser to ${url}
|
|
6708
|
+
Approve the request to continue.
|
|
6709
|
+
`
|
|
6710
|
+
);
|
|
6711
|
+
}
|
|
6712
|
+
});
|
|
6713
|
+
writeFileConfig({ ...prev, apiKey: result2.apiKey, cloudUrl });
|
|
6714
|
+
return {
|
|
6715
|
+
ok: true,
|
|
6716
|
+
mode: "loopback",
|
|
6717
|
+
path: configPath(),
|
|
6718
|
+
user_id: result2.userId,
|
|
6719
|
+
handle: result2.handle,
|
|
6720
|
+
scope: result2.scope,
|
|
6721
|
+
key_id: result2.keyId
|
|
6722
|
+
};
|
|
6723
|
+
} catch (err) {
|
|
6724
|
+
if (err instanceof LoopbackError && err.recoverable) {
|
|
6725
|
+
process.stderr.write(`${err.message}
|
|
6726
|
+
`);
|
|
6727
|
+
} else {
|
|
6728
|
+
throw err;
|
|
6729
|
+
}
|
|
6730
|
+
}
|
|
6731
|
+
}
|
|
6732
|
+
const result = await runDeviceFlow({
|
|
6733
|
+
cloudUrl,
|
|
6734
|
+
clientLabel,
|
|
6735
|
+
requestedScope,
|
|
6736
|
+
openBrowser,
|
|
6737
|
+
onUserPrompt: (info) => {
|
|
6738
|
+
process.stderr.write(
|
|
6739
|
+
`
|
|
6740
|
+
\u{1F513} LinkedClaw login
|
|
6741
|
+
|
|
6742
|
+
Open this URL in your browser:
|
|
6743
|
+
${info.verification_uri_complete}
|
|
6744
|
+
|
|
6745
|
+
Or visit ${info.verification_uri} and enter:
|
|
6746
|
+
${info.user_code}
|
|
6747
|
+
|
|
6748
|
+
Waiting for approval... (${Math.floor(info.expires_in / 60)}m ${info.expires_in % 60}s remaining)
|
|
6749
|
+
`
|
|
6750
|
+
);
|
|
6751
|
+
}
|
|
6752
|
+
});
|
|
6753
|
+
writeFileConfig({ ...prev, apiKey: result.apiKey, cloudUrl });
|
|
6754
|
+
return {
|
|
6755
|
+
ok: true,
|
|
6756
|
+
mode: "device",
|
|
6757
|
+
path: configPath(),
|
|
6758
|
+
user_id: result.userId,
|
|
6759
|
+
handle: result.handle,
|
|
6760
|
+
scope: result.scope,
|
|
6761
|
+
key_id: result.keyId
|
|
6762
|
+
};
|
|
6373
6763
|
});
|
|
6374
6764
|
});
|
|
6375
6765
|
program2.command("register").description("Open browser to create a LinkedClaw account, then paste your API key").option("--no-browser", "Print URL instead of attempting to open the browser").option("--cloud-url <url>", "Override cloud URL").action(async (opts) => {
|
|
@@ -6439,7 +6829,7 @@ function tryParseJson(v) {
|
|
|
6439
6829
|
|
|
6440
6830
|
// src/commands/converge.ts
|
|
6441
6831
|
import { spawnSync } from "child_process";
|
|
6442
|
-
import { existsSync as
|
|
6832
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync4, unlinkSync as unlinkSync2 } from "fs";
|
|
6443
6833
|
import { isAbsolute as isAbsolute2, join as join5, relative, resolve as resolve2 } from "path";
|
|
6444
6834
|
|
|
6445
6835
|
// src/converge/api.ts
|
|
@@ -6574,7 +6964,7 @@ function makeConvergeApi(cloudUrl, apiKey) {
|
|
|
6574
6964
|
}
|
|
6575
6965
|
|
|
6576
6966
|
// src/converge/hash.ts
|
|
6577
|
-
import { createHash as
|
|
6967
|
+
import { createHash as createHash3 } from "crypto";
|
|
6578
6968
|
function encodeString(s) {
|
|
6579
6969
|
let out = '"';
|
|
6580
6970
|
for (let i = 0; i < s.length; i++) {
|
|
@@ -6600,7 +6990,7 @@ function canonicalize(value) {
|
|
|
6600
6990
|
return "{" + keys.map((k) => encodeString(k) + ":" + canonicalize(value[k])).join(",") + "}";
|
|
6601
6991
|
}
|
|
6602
6992
|
function sha256OfCanonicalJson(value) {
|
|
6603
|
-
const h =
|
|
6993
|
+
const h = createHash3("sha256");
|
|
6604
6994
|
h.update(canonicalize(value));
|
|
6605
6995
|
return "sha256:" + h.digest("hex");
|
|
6606
6996
|
}
|
|
@@ -6634,15 +7024,15 @@ function acquireLock(stagingDir) {
|
|
|
6634
7024
|
}
|
|
6635
7025
|
|
|
6636
7026
|
// src/converge/staging.ts
|
|
6637
|
-
import { createHash as
|
|
6638
|
-
import { existsSync as
|
|
7027
|
+
import { createHash as createHash4 } from "crypto";
|
|
7028
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync4, readdirSync, writeFileSync as writeFileSync2 } from "fs";
|
|
6639
7029
|
import { dirname as dirname2, join as join3 } from "path";
|
|
6640
7030
|
import { load as yamlLoad2, dump as yamlDump2 } from "js-yaml";
|
|
6641
7031
|
function stagingPathFor(stagingDir, cruxId) {
|
|
6642
7032
|
return join3(stagingDir, `${cruxId}.md`);
|
|
6643
7033
|
}
|
|
6644
7034
|
function listCruxFiles(stagingDir) {
|
|
6645
|
-
if (!
|
|
7035
|
+
if (!existsSync3(stagingDir)) return [];
|
|
6646
7036
|
return readdirSync(stagingDir).filter(
|
|
6647
7037
|
(f) => f.endsWith(".md") && !f.startsWith(".")
|
|
6648
7038
|
);
|
|
@@ -6685,17 +7075,17 @@ function writeStaging(path2, doc) {
|
|
|
6685
7075
|
writeFileSync2(path2, dumpStaging(doc), "utf8");
|
|
6686
7076
|
}
|
|
6687
7077
|
function computePaBodyHash(body) {
|
|
6688
|
-
return "sha256:" +
|
|
7078
|
+
return "sha256:" + createHash4("sha256").update(Buffer.from(body, "utf8")).digest("hex");
|
|
6689
7079
|
}
|
|
6690
7080
|
|
|
6691
7081
|
// src/converge/workspace.ts
|
|
6692
|
-
import { existsSync as
|
|
7082
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
|
|
6693
7083
|
import { dirname as dirname3, isAbsolute, join as join4, resolve } from "path";
|
|
6694
7084
|
import { load as yamlLoad3, dump as yamlDump3 } from "js-yaml";
|
|
6695
7085
|
var META_FILENAME = ".run-meta.yaml";
|
|
6696
7086
|
function readRunMeta(stagingDir) {
|
|
6697
7087
|
const metaPath = join4(stagingDir, META_FILENAME);
|
|
6698
|
-
if (!
|
|
7088
|
+
if (!existsSync4(metaPath)) return null;
|
|
6699
7089
|
return yamlLoad3(readFileSync5(metaPath, "utf8"));
|
|
6700
7090
|
}
|
|
6701
7091
|
function writeRunMeta(stagingDir, meta) {
|
|
@@ -6705,7 +7095,7 @@ function writeRunMeta(stagingDir, meta) {
|
|
|
6705
7095
|
function searchUpward(startDir, maxLevels = 5) {
|
|
6706
7096
|
let dir = startDir;
|
|
6707
7097
|
for (let i = 0; i < maxLevels; i++) {
|
|
6708
|
-
if (
|
|
7098
|
+
if (existsSync4(join4(dir, META_FILENAME))) return dir;
|
|
6709
7099
|
const parent = dirname3(dir);
|
|
6710
7100
|
if (parent === dir) break;
|
|
6711
7101
|
dir = parent;
|
|
@@ -6958,7 +7348,7 @@ async function buildCruxDecisionRequest(api, ws, cruxId, action, opts = {}) {
|
|
|
6958
7348
|
let synthesisEdited = false;
|
|
6959
7349
|
if (action === "accept") {
|
|
6960
7350
|
const stagingPath = safeStagingPathFor(ws.stagingDir, cruxId);
|
|
6961
|
-
if (
|
|
7351
|
+
if (existsSync5(stagingPath)) {
|
|
6962
7352
|
const doc = readStaging(stagingPath);
|
|
6963
7353
|
synthesisEdited = computePaBodyHash(doc.body) !== doc.frontmatter.pa_body_hash;
|
|
6964
7354
|
if (synthesisEdited) acceptedSynthesisText = doc.body;
|
|
@@ -6989,7 +7379,7 @@ async function postCruxDecision(api, ws, cruxId, action, opts = {}) {
|
|
|
6989
7379
|
}
|
|
6990
7380
|
async function materializeAcceptedCrux(ctx, api, ws, cruxId, payload, opts = {}) {
|
|
6991
7381
|
const stagingPath = safeStagingPathFor(ws.stagingDir, cruxId);
|
|
6992
|
-
if (!
|
|
7382
|
+
if (!existsSync5(stagingPath)) return { warning: `staging_not_found: ${stagingPath}` };
|
|
6993
7383
|
const doc = readStaging(stagingPath);
|
|
6994
7384
|
const fm = doc.frontmatter;
|
|
6995
7385
|
const sourceDebate = await api.getDebate(ws.sourceDebateId);
|
|
@@ -7033,11 +7423,11 @@ async function materializeAcceptedCrux(ctx, api, ws, cruxId, payload, opts = {})
|
|
|
7033
7423
|
const synthSlug = extractSynthesisSlug(body);
|
|
7034
7424
|
const finalDir = join5(ws.targetCorpus, "converged", topicSlug);
|
|
7035
7425
|
const finalPath = safeAcceptedPath(finalDir, cruxId, synthSlug);
|
|
7036
|
-
if (
|
|
7426
|
+
if (existsSync5(finalPath) && readStaging(finalPath).body !== acceptedDoc.body) {
|
|
7037
7427
|
return { warning: `sync_conflict: ${finalPath}` };
|
|
7038
7428
|
}
|
|
7039
7429
|
mkdirSync4(finalDir, { recursive: true });
|
|
7040
|
-
if (!
|
|
7430
|
+
if (!existsSync5(finalPath) || dumpStaging(readStaging(finalPath)) !== dumpStaging(acceptedDoc)) {
|
|
7041
7431
|
writeStaging(finalPath, acceptedDoc);
|
|
7042
7432
|
}
|
|
7043
7433
|
unlinkSync2(stagingPath);
|
|
@@ -7101,7 +7491,7 @@ async function syncTerminalDecisions(ctx, api, ws, opts = {}) {
|
|
|
7101
7491
|
if (result.warning) warnings.push(`${cid}: ${result.warning}`);
|
|
7102
7492
|
continue;
|
|
7103
7493
|
}
|
|
7104
|
-
if (
|
|
7494
|
+
if (existsSync5(stagingPath)) {
|
|
7105
7495
|
unlinkSync2(stagingPath);
|
|
7106
7496
|
cleaned.push(cid);
|
|
7107
7497
|
}
|
|
@@ -7217,7 +7607,7 @@ function registerConvergeCommands(program2) {
|
|
|
7217
7607
|
const body = buildPaBody(op);
|
|
7218
7608
|
const newPaBodyHash = computePaBodyHash(body);
|
|
7219
7609
|
const path2 = safeStagingPathFor(ws.stagingDir, c.crux_id);
|
|
7220
|
-
const existingDoc =
|
|
7610
|
+
const existingDoc = existsSync5(path2) ? readStaging(path2) : null;
|
|
7221
7611
|
if (existingDoc && existingDoc.frontmatter.pa_body_hash === newPaBodyHash) continue;
|
|
7222
7612
|
const fm = {
|
|
7223
7613
|
debate_id: ws.sourceDebateId,
|
|
@@ -8768,9 +9158,9 @@ async function resolveManifestOpt(manifestOpt, manifestId, intention, defaultInt
|
|
|
8768
9158
|
|
|
8769
9159
|
// src/bin.ts
|
|
8770
9160
|
var pkgPath = join6(dirname4(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
8771
|
-
var
|
|
9161
|
+
var CLI_VERSION2 = JSON.parse(readFileSync8(pkgPath, "utf8")).version;
|
|
8772
9162
|
var program = new Command();
|
|
8773
|
-
program.name("linkedclaw").description("Official LinkedClaw CLI \u2014 any agent can shell out to hire providers, invoke, or gig task").version(`cli ${
|
|
9163
|
+
program.name("linkedclaw").description("Official LinkedClaw CLI \u2014 any agent can shell out to hire providers, invoke, or gig task").version(`cli ${CLI_VERSION2}`);
|
|
8774
9164
|
registerAuthCommands(program);
|
|
8775
9165
|
registerRequesterCommands(program);
|
|
8776
9166
|
registerProviderCommands(program);
|