@velum-labs/routekit-daemon 0.9.4 → 0.9.6
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/index.js +92 -2
- package/dist/test/daemon.test.js +79 -0
- package/package.json +9 -9
package/dist/index.js
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* ports behind that front door; config/account reload builds a complete new
|
|
7
7
|
* generation before atomically switching new traffic and draining the old.
|
|
8
8
|
*/
|
|
9
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
9
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
10
10
|
import { basename, dirname, join } from "node:path";
|
|
11
|
-
import { CLIPROXY_API_KEY_ENV, CLIPROXY_BASE_URL_ENV, accountStoreEntries, cliproxyAuthDirectory, cliproxyAccountEntries, cliproxyAccountMatchesKind, cliproxyApiKey, cliproxyBaseUrl, cliproxyCredentialValid, defaultSubscriptionAccountDirectory, removeCliproxyAccount, removeSubscriptionAccount, sanitizeSubscriptionLabel } from "@velum-labs/routekit-accounts";
|
|
11
|
+
import { CLIPROXY_API_KEY_ENV, CLIPROXY_BASE_URL_ENV, accountStoreEntries, cliproxyAuthDirectory, cliproxyAccountEntries, cliproxyAccountMatchesKind, cliproxyApiKey, cliproxyBaseUrl, cliproxyCredentialValid, defaultSubscriptionAccountDirectory, RateLimitTracker, removeCliproxyAccount, removeSubscriptionAccount, renameSubscriptionAccount, sanitizeSubscriptionLabel } from "@velum-labs/routekit-accounts";
|
|
12
12
|
import { configuredProviderIds, globalRouterConfigPath, parseRouterConfigDocument, routekitHome, writeRouterConfig } from "@velum-labs/routekit-config";
|
|
13
13
|
import { createRouteKitControlHandler, ROUTEKIT_CONTROL_CAPABILITY } from "@velum-labs/routekit-control";
|
|
14
14
|
import { startSwitchingGatewayProxy } from "@velum-labs/routekit-gateway";
|
|
@@ -976,6 +976,96 @@ export async function startRouteKitDaemon(options) {
|
|
|
976
976
|
});
|
|
977
977
|
return { removed, revision: revisions.accounts };
|
|
978
978
|
},
|
|
979
|
+
"accounts.rename": async (params) => {
|
|
980
|
+
const resolved = resolveAccountConnector(params.kind);
|
|
981
|
+
if (resolved === undefined || resolved.info.connector !== "native") {
|
|
982
|
+
throw new ControlError({
|
|
983
|
+
code: "bad_request",
|
|
984
|
+
message: "account rename supports only claude-code and codex"
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
const kind = resolved.kind;
|
|
988
|
+
for (const [field, label] of [
|
|
989
|
+
["source", params.source],
|
|
990
|
+
["target", params.target]
|
|
991
|
+
]) {
|
|
992
|
+
if (sanitizeSubscriptionLabel(label) !== label || label.startsWith(".")) {
|
|
993
|
+
throw new ControlError({
|
|
994
|
+
code: "bad_request",
|
|
995
|
+
message: `${field} account label must already be normalized`
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
await serializeMutation(async () => {
|
|
1000
|
+
const directory = defaultSubscriptionAccountDirectory(kind, env);
|
|
1001
|
+
const sourcePath = join(directory, `${params.source}.json`);
|
|
1002
|
+
const targetPath = join(directory, `${params.target}.json`);
|
|
1003
|
+
const trackerPath = join(directory, ".state.json");
|
|
1004
|
+
if (!existsSync(sourcePath)) {
|
|
1005
|
+
throw new ControlError({
|
|
1006
|
+
code: "not_found",
|
|
1007
|
+
message: `${kind}/${params.source} is not enrolled`
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
try {
|
|
1011
|
+
lstatSync(targetPath);
|
|
1012
|
+
throw new ControlError({
|
|
1013
|
+
code: "conflict",
|
|
1014
|
+
message: `${kind}/${params.target} is already enrolled`
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
catch (error) {
|
|
1018
|
+
if (error instanceof ControlError ||
|
|
1019
|
+
typeof error !== "object" ||
|
|
1020
|
+
error === null ||
|
|
1021
|
+
!("code" in error) ||
|
|
1022
|
+
error.code !== "ENOENT") {
|
|
1023
|
+
throw error;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
const transaction = prepareAccountTransaction({
|
|
1027
|
+
home,
|
|
1028
|
+
configPath,
|
|
1029
|
+
accountPaths: [sourcePath, targetPath, trackerPath],
|
|
1030
|
+
accountRoots: [directory],
|
|
1031
|
+
kind,
|
|
1032
|
+
provider: kind,
|
|
1033
|
+
labels: [params.source, params.target]
|
|
1034
|
+
});
|
|
1035
|
+
try {
|
|
1036
|
+
renameSubscriptionAccount(kind, params.source, params.target, {
|
|
1037
|
+
accountsDirectory: directory
|
|
1038
|
+
});
|
|
1039
|
+
new RateLimitTracker(trackerPath, kind).renameMember(params.source, params.target);
|
|
1040
|
+
options.onAccountTransactionPhase?.("credentials-written");
|
|
1041
|
+
await replaceRouter(currentConfig, currentDocument, {
|
|
1042
|
+
write: false,
|
|
1043
|
+
accountRevision: true,
|
|
1044
|
+
beforeSwap: () => markAccountTransactionCommitted(transaction)
|
|
1045
|
+
});
|
|
1046
|
+
try {
|
|
1047
|
+
cleanupAccountTransaction(transaction);
|
|
1048
|
+
}
|
|
1049
|
+
catch {
|
|
1050
|
+
// A committed manifest is cleanup-only on the next daemon start.
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
catch (error) {
|
|
1054
|
+
const rollbackFailures = [];
|
|
1055
|
+
try {
|
|
1056
|
+
rollbackAccountTransaction(transaction, home);
|
|
1057
|
+
}
|
|
1058
|
+
catch (rollbackError) {
|
|
1059
|
+
rollbackFailures.push(rollbackError);
|
|
1060
|
+
}
|
|
1061
|
+
if (rollbackFailures.length > 0) {
|
|
1062
|
+
throw new AggregateError([error, ...rollbackFailures], `could not rename ${kind}/${params.source}; rollback failed`);
|
|
1063
|
+
}
|
|
1064
|
+
throw error;
|
|
1065
|
+
}
|
|
1066
|
+
});
|
|
1067
|
+
return { renamed: true, revision: revisions.accounts };
|
|
1068
|
+
},
|
|
979
1069
|
"accounts.sync": async () => {
|
|
980
1070
|
// A connector login wrote new account state outside the control
|
|
981
1071
|
// channel (the cliproxy auth store); rebuild the router generation and
|
package/dist/test/daemon.test.js
CHANGED
|
@@ -311,6 +311,85 @@ test("singleton daemon exposes authenticated control and a stable reloadable dat
|
|
|
311
311
|
rmSync(root, { recursive: true, force: true });
|
|
312
312
|
}
|
|
313
313
|
});
|
|
314
|
+
for (const kind of ["claude-code", "codex"]) {
|
|
315
|
+
test(`native ${kind} account rename preserves routing and usage state`, async () => {
|
|
316
|
+
const root = mkdtempSync(join(tmpdir(), `routekit-daemon-rename-${kind}-`));
|
|
317
|
+
const stateHome = join(root, "state");
|
|
318
|
+
const configPath = join(root, "router.yaml");
|
|
319
|
+
const accountsDirectory = join(stateHome, "subscriptions", kind);
|
|
320
|
+
const sourcePath = join(accountsDirectory, "work.json");
|
|
321
|
+
const targetPath = join(accountsDirectory, "personal.json");
|
|
322
|
+
const occupiedPath = join(accountsDirectory, "occupied.json");
|
|
323
|
+
const credential = `${JSON.stringify(nativeCredential(kind))}\n`;
|
|
324
|
+
const coolingUntil = Date.now() + 60_000;
|
|
325
|
+
mkdirSync(accountsDirectory, { recursive: true });
|
|
326
|
+
writeFileSync(sourcePath, credential, { mode: 0o600 });
|
|
327
|
+
writeFileSync(join(accountsDirectory, ".state.json"), JSON.stringify({ members: [{ id: "work", coolingUntil }] }), { mode: 0o600 });
|
|
328
|
+
writeFileSync(configPath, `providers:\n ${kind}: {}\ndefaultModel: ${kind}/${kind === "claude-code" ? "claude-test-model" : "gpt-test-model"}\n`);
|
|
329
|
+
try {
|
|
330
|
+
await withMockNativeDiscovery(kind, async () => {
|
|
331
|
+
const daemon = await startRouteKitDaemon({
|
|
332
|
+
packageVersion: "1.2.3",
|
|
333
|
+
stateHome,
|
|
334
|
+
configPath,
|
|
335
|
+
port: 0,
|
|
336
|
+
portless: false,
|
|
337
|
+
env: {
|
|
338
|
+
HOME: root,
|
|
339
|
+
ROUTEKIT_HOME: stateHome,
|
|
340
|
+
ROUTEKIT_PORTLESS: "0"
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
try {
|
|
344
|
+
const client = new RouteKitControlClient({
|
|
345
|
+
url: daemon.record.url,
|
|
346
|
+
token: daemon.record.controlToken
|
|
347
|
+
});
|
|
348
|
+
const before = await client.call("daemon.status", {});
|
|
349
|
+
const beforeConfig = await client.call("config.get", {});
|
|
350
|
+
const renamed = await client.call("accounts.rename", { kind, source: "work", target: "personal" }, { idempotencyKey: `rename-${kind}-work` });
|
|
351
|
+
assert.deepEqual(renamed, {
|
|
352
|
+
renamed: true,
|
|
353
|
+
revision: before.accountRevision + 1
|
|
354
|
+
});
|
|
355
|
+
assert.equal(existsSync(sourcePath), false);
|
|
356
|
+
assert.equal(readFileSync(targetPath, "utf8"), credential);
|
|
357
|
+
assert.deepEqual(await client.call("config.get", {}), beforeConfig);
|
|
358
|
+
assert.deepEqual((await client.call("accounts.list", {})).accounts, [
|
|
359
|
+
{ subscriptionKind: kind, label: "personal", connector: "native" }
|
|
360
|
+
]);
|
|
361
|
+
const status = await client.call("accounts.status", {});
|
|
362
|
+
assert.equal(status.accounts[0]?.label, "personal");
|
|
363
|
+
assert.equal(status.accounts[0]?.subscriptionKind, kind);
|
|
364
|
+
const usage = (await client.call("accounts.usage", {}));
|
|
365
|
+
assert.equal(usage.accountSets[0]?.members[0]?.label, "personal");
|
|
366
|
+
assert.equal(usage.accountSets[0]?.members[0]?.coolingUntil, coolingUntil);
|
|
367
|
+
const tracker = JSON.parse(readFileSync(join(accountsDirectory, ".state.json"), "utf8"));
|
|
368
|
+
assert.deepEqual(tracker.members.map((member) => [member.id, member.coolingUntil]), [["personal", coolingUntil]]);
|
|
369
|
+
assert.equal((await client.call("doctor.run", {})).checks.find((check) => check.name === "account/provider consistency")?.ok, true);
|
|
370
|
+
const afterRename = await client.call("daemon.status", {});
|
|
371
|
+
await assert.rejects(client.call("accounts.rename", { kind, source: "missing", target: "available" }, { idempotencyKey: `rename-${kind}-missing` }), (error) => error instanceof ControlError &&
|
|
372
|
+
error.code === "not_found" &&
|
|
373
|
+
/not enrolled/.test(error.message));
|
|
374
|
+
writeFileSync(occupiedPath, credential, { mode: 0o600 });
|
|
375
|
+
await assert.rejects(client.call("accounts.rename", { kind, source: "personal", target: "occupied" }, { idempotencyKey: `rename-${kind}-occupied` }), (error) => error instanceof ControlError &&
|
|
376
|
+
error.code === "conflict" &&
|
|
377
|
+
/already enrolled/.test(error.message));
|
|
378
|
+
assert.equal(readFileSync(targetPath, "utf8"), credential);
|
|
379
|
+
assert.equal(readFileSync(occupiedPath, "utf8"), credential);
|
|
380
|
+
assert.deepEqual(await client.call("daemon.status", {}), afterRename);
|
|
381
|
+
assert.equal(existsSync(join(stateHome, "account-transactions")), false);
|
|
382
|
+
}
|
|
383
|
+
finally {
|
|
384
|
+
await daemon.close();
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
rmSync(root, { recursive: true, force: true });
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
}
|
|
314
393
|
test("native provider stays enabled until its last account is removed", async () => {
|
|
315
394
|
const root = mkdtempSync(join(tmpdir(), "routekit-daemon-native-remove-"));
|
|
316
395
|
const stateHome = join(root, "state");
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@velum-labs/routekit-daemon",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.9.
|
|
4
|
+
"version": "0.9.6",
|
|
5
5
|
"description": "Singleton RouteKit control daemon and stable model gateway.",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -34,14 +34,14 @@
|
|
|
34
34
|
],
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"yaml": "2.9.0",
|
|
37
|
-
"@velum-labs/routekit-
|
|
38
|
-
"@velum-labs/routekit-
|
|
39
|
-
"@velum-labs/routekit-
|
|
40
|
-
"@velum-labs/routekit-
|
|
41
|
-
"@velum-labs/routekit-
|
|
42
|
-
"@velum-labs/routekit-
|
|
43
|
-
"@velum-labs/routekit-
|
|
44
|
-
"@velum-labs/routekit-
|
|
37
|
+
"@velum-labs/routekit-accounts": "0.9.6",
|
|
38
|
+
"@velum-labs/routekit-control": "0.9.6",
|
|
39
|
+
"@velum-labs/routekit-config": "0.9.6",
|
|
40
|
+
"@velum-labs/routekit-registry": "0.9.6",
|
|
41
|
+
"@velum-labs/routekit-router": "0.9.6",
|
|
42
|
+
"@velum-labs/routekit-gateway": "0.9.6",
|
|
43
|
+
"@velum-labs/routekit-runtime": "0.9.6",
|
|
44
|
+
"@velum-labs/routekit-telemetry-core": "0.9.6"
|
|
45
45
|
},
|
|
46
46
|
"scripts": {
|
|
47
47
|
"build": "tsc -b",
|