@north-light/crouter 0.3.313 → 0.3.314
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/clients/attach/viewer.js +604 -604
- package/dist/core/__tests__/integration/fresh-home-commands.test.d.ts +1 -0
- package/dist/core/__tests__/integration/fresh-home-commands.test.js +49 -0
- package/dist/core/command.js +11 -0
- package/dist/core/scoped-state/db.d.ts +12 -1
- package/dist/core/scoped-state/db.js +46 -3
- package/dist/core/scoped-state/migrate.d.ts +1 -4
- package/dist/core/scoped-state/migrate.js +4 -21
- package/package.json +1 -1
- package/runtime.lock.json +5 -5
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import test from 'node:test';
|
|
8
|
+
const CLI = fileURLToPath(new URL('../../../cli.ts', import.meta.url));
|
|
9
|
+
const ROOT = new URL('../../../build-root.ts', import.meta.url).href;
|
|
10
|
+
const LOADER = import.meta.resolve('tsx/esm');
|
|
11
|
+
test('command discovery initializes fresh homes and refuses legacy state without conversion', () => {
|
|
12
|
+
const commands = [
|
|
13
|
+
[CLI, '-h'],
|
|
14
|
+
[CLI, 'human', '-h'],
|
|
15
|
+
['--input-type=module', '--eval', `import { coreCommandPaths } from ${JSON.stringify(ROOT)}; if (!(await coreCommandPaths()).has('human')) throw new Error('human command missing'); console.log('coreCommandPaths: human present');`],
|
|
16
|
+
];
|
|
17
|
+
for (const legacy of [false, true])
|
|
18
|
+
for (const command of commands) {
|
|
19
|
+
const home = mkdtempSync(join(tmpdir(), 'crtr-command-home-'));
|
|
20
|
+
const config = join(home, '.crouter', 'config.json');
|
|
21
|
+
const database = join(home, '.crouter', 'operations.db');
|
|
22
|
+
try {
|
|
23
|
+
if (legacy) {
|
|
24
|
+
mkdirSync(dirname(config), { recursive: true });
|
|
25
|
+
writeFileSync(config, '{}\n');
|
|
26
|
+
}
|
|
27
|
+
const result = spawnSync(process.execPath, ['--conditions=crtr-src', '--import', LOADER, ...command], {
|
|
28
|
+
cwd: home,
|
|
29
|
+
env: { HOME: home, PATH: process.env.PATH, CRTR_NO_DAEMON_AUTOSTART: '1', CRTR_NO_AUTO_UPDATE: '1', CRTR_NO_EXPORTS: '1' },
|
|
30
|
+
encoding: 'utf8',
|
|
31
|
+
timeout: 15_000,
|
|
32
|
+
});
|
|
33
|
+
const detail = `${legacy ? 'legacy' : 'fresh'} HOME: ${command.join(' ')}\n${result.stdout}\n${result.stderr}`;
|
|
34
|
+
assert.ifError(result.error);
|
|
35
|
+
assert.equal(result.status, legacy ? 1 : 0, detail);
|
|
36
|
+
assert.equal(existsSync(database), !legacy, detail);
|
|
37
|
+
if (legacy) {
|
|
38
|
+
assert.match(result.stderr, /legacy scoped state exists.*crtr sys daemon restart/s, detail);
|
|
39
|
+
assert.equal(readFileSync(config, 'utf8'), '{}\n');
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
assert.notEqual(result.stdout.trim(), '', detail);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
rmSync(home, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
});
|
package/dist/core/command.js
CHANGED
|
@@ -641,6 +641,17 @@ export async function runCli(root, argv) {
|
|
|
641
641
|
process.exitCode = ExitCode.SUCCESS;
|
|
642
642
|
return;
|
|
643
643
|
}
|
|
644
|
+
// `crtr --version` / `crtr -v`. The front door already routes both here, so
|
|
645
|
+
// without this branch they fall through to the unknown-path error below.
|
|
646
|
+
// Imported lazily: `sys/shared.js` locates the package root eagerly at module
|
|
647
|
+
// load, and the cold-start check forbids adding that to cli.js's static
|
|
648
|
+
// import closure.
|
|
649
|
+
if (tokens.length === 1 && (tokens[0] === '--version' || tokens[0] === '-v')) {
|
|
650
|
+
const { readPackageVersion } = await import('../commands/sys/shared.js');
|
|
651
|
+
process.stdout.write(readPackageVersion() + '\n');
|
|
652
|
+
process.exitCode = ExitCode.SUCCESS;
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
644
655
|
let { node, path, remaining } = walk(root, tokens);
|
|
645
656
|
try {
|
|
646
657
|
// Passthrough branches (e.g. `crtr capture`) opt out of the tree model
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
-
import type
|
|
2
|
+
import { type ScopedStore } from './paths.js';
|
|
3
|
+
export declare const PROFILE_ID_SHAPE: RegExp;
|
|
4
|
+
export declare function writeOwnerRoot(db: DatabaseSync, key: string, root: string): void;
|
|
3
5
|
export declare function scopedStoreExists(store: ScopedStore): boolean;
|
|
6
|
+
export declare function legacyScopedStorePaths(store: ScopedStore): string[];
|
|
7
|
+
/** Acquiring the store on demand races every other process reaching the same
|
|
8
|
+
* fresh root at once (e.g. two brokers sharing a fresh agent dir on first
|
|
9
|
+
* launch), so the check-then-create runs under a cross-process lock — the
|
|
10
|
+
* same primitive worktree-sweep and the plugin-swap path already use. */
|
|
11
|
+
export declare function initializeFreshScopedStore(input: {
|
|
12
|
+
kind: 'user' | 'project' | 'provider';
|
|
13
|
+
root: string;
|
|
14
|
+
}): void;
|
|
4
15
|
export declare function openScopedStore(store: ScopedStore): DatabaseSync;
|
|
5
16
|
export declare function createScopedStore(store: ScopedStore, create: (db: DatabaseSync) => void): void;
|
|
6
17
|
export declare function withScopedWrite<T>(store: ScopedStore, run: (db: DatabaseSync) => T): T;
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
-
import { chmodSync, existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import {
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { CONFIG_FILE, PROFILE_DIR, STATE_FILE } from '../../types.js';
|
|
5
|
+
import { withExclusiveDirectoryLock } from '../exclusive-lock.js';
|
|
6
|
+
import { providerStore, projectStore, userStore } from './paths.js';
|
|
7
|
+
import { createScopedSchema, SCOPED_SCHEMA_VERSION } from './schema.js';
|
|
4
8
|
const handles = new Map();
|
|
9
|
+
export const PROFILE_ID_SHAPE = /^[a-z0-9]+(?:-[a-z0-9]+)*-[0-9a-f]{8}$/;
|
|
10
|
+
export function writeOwnerRoot(db, key, root) {
|
|
11
|
+
db.prepare('UPDATE scope_owners SET root=? WHERE owner_key=?').run(root, key);
|
|
12
|
+
}
|
|
5
13
|
function version(db) {
|
|
6
14
|
return Number(db.prepare('PRAGMA user_version').get().user_version);
|
|
7
15
|
}
|
|
@@ -15,12 +23,47 @@ function secure(path) {
|
|
|
15
23
|
export function scopedStoreExists(store) {
|
|
16
24
|
return existsSync(store.path);
|
|
17
25
|
}
|
|
26
|
+
export function legacyScopedStorePaths(store) {
|
|
27
|
+
if (store.kind === 'project')
|
|
28
|
+
return [join(store.root, STATE_FILE)];
|
|
29
|
+
if (store.kind === 'provider')
|
|
30
|
+
return [join(store.root, 'anthropic-subscriptions.json'), join(store.root, 'openai-codex-subscriptions.json'), join(store.root, 'provider-rotation.json')];
|
|
31
|
+
const paths = [join(store.root, CONFIG_FILE), join(store.root, STATE_FILE), join(store.root, 'profile-defaults.json')];
|
|
32
|
+
const profiles = join(store.root, PROFILE_DIR);
|
|
33
|
+
if (existsSync(profiles))
|
|
34
|
+
for (const entry of readdirSync(profiles, { withFileTypes: true }))
|
|
35
|
+
if (entry.isDirectory() && PROFILE_ID_SHAPE.test(entry.name))
|
|
36
|
+
paths.push(join(profiles, entry.name, 'profile.json'), join(profiles, entry.name, CONFIG_FILE), join(profiles, entry.name, STATE_FILE));
|
|
37
|
+
return paths;
|
|
38
|
+
}
|
|
39
|
+
/** Acquiring the store on demand races every other process reaching the same
|
|
40
|
+
* fresh root at once (e.g. two brokers sharing a fresh agent dir on first
|
|
41
|
+
* launch), so the check-then-create runs under a cross-process lock — the
|
|
42
|
+
* same primitive worktree-sweep and the plugin-swap path already use. */
|
|
43
|
+
export function initializeFreshScopedStore(input) {
|
|
44
|
+
const store = input.kind === 'user' ? userStore(input.root) : input.kind === 'project' ? projectStore(input.root) : providerStore(input.root);
|
|
45
|
+
if (scopedStoreExists(store))
|
|
46
|
+
return;
|
|
47
|
+
mkdirSync(store.root, { recursive: true, mode: 0o700 });
|
|
48
|
+
withExclusiveDirectoryLock(join(store.root, '.init.lock'), () => {
|
|
49
|
+
if (scopedStoreExists(store))
|
|
50
|
+
return;
|
|
51
|
+
const legacyPath = legacyScopedStorePaths(store).find(existsSync);
|
|
52
|
+
if (legacyPath !== undefined)
|
|
53
|
+
throw new Error(`legacy scoped state exists at ${legacyPath}; crtr was updated and \`crtr sys daemon restart\` converts local state`);
|
|
54
|
+
createScopedStore(store, (db) => {
|
|
55
|
+
createScopedSchema(db, store.kind);
|
|
56
|
+
if (store.kind !== 'provider')
|
|
57
|
+
writeOwnerRoot(db, store.kind, store.root);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
18
61
|
export function openScopedStore(store) {
|
|
19
62
|
const cached = handles.get(store.path);
|
|
20
63
|
if (cached !== undefined)
|
|
21
64
|
return cached;
|
|
22
65
|
if (!existsSync(store.path))
|
|
23
|
-
|
|
66
|
+
initializeFreshScopedStore(store);
|
|
24
67
|
const db = new DatabaseSync(store.path);
|
|
25
68
|
db.exec('PRAGMA busy_timeout = 5000; PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
|
|
26
69
|
const found = version(db);
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
export
|
|
2
|
-
kind: 'user' | 'project' | 'provider';
|
|
3
|
-
root: string;
|
|
4
|
-
}): void;
|
|
1
|
+
export { initializeFreshScopedStore } from './db.js';
|
|
5
2
|
/** Conversion is intentionally explicit. Deployment coordination must prove all legacy writers at these shared roots are stopped before calling it. */
|
|
6
3
|
export declare function convertLegacyScopedStore(input: {
|
|
7
4
|
kind: 'user' | 'project' | 'provider';
|
|
@@ -4,10 +4,9 @@ import { dirname, join, relative } from 'node:path';
|
|
|
4
4
|
import lockfile from 'proper-lockfile';
|
|
5
5
|
import { CONFIG_FILE, PROFILE_DIR, STATE_FILE } from '../../types.js';
|
|
6
6
|
import { LockedJsonFile } from '../locked-file.js';
|
|
7
|
-
import { createScopedStore, scopedStoreExists } from './db.js';
|
|
7
|
+
import { createScopedStore, legacyScopedStorePaths, PROFILE_ID_SHAPE, scopedStoreExists, writeOwnerRoot } from './db.js';
|
|
8
8
|
import { providerStore, projectStore, userStore } from './paths.js';
|
|
9
9
|
import { createScopedSchema } from './schema.js';
|
|
10
|
-
function writeOwnerRoot(db, key, root) { db.prepare('UPDATE scope_owners SET root=? WHERE owner_key=?').run(root, key); }
|
|
11
10
|
function json(path) { return JSON.parse(readFileSync(path, 'utf8')); }
|
|
12
11
|
function existsLegacy(paths) { return paths.some(existsSync); }
|
|
13
12
|
function preserve(root, paths) { const target = join(root, '.legacy-scoped-state'); mkdirSync(target, { recursive: true, mode: 0o700 }); chmodSync(target, 0o700); for (const path of paths)
|
|
@@ -20,12 +19,6 @@ function preserve(root, paths) { const target = join(root, '.legacy-scoped-state
|
|
|
20
19
|
copyFileSync(path, destination);
|
|
21
20
|
chmodSync(destination, 0o600);
|
|
22
21
|
} }
|
|
23
|
-
const PROFILE_ID_SHAPE = /^[a-z0-9]+(?:-[a-z0-9]+)*-[0-9a-f]{8}$/;
|
|
24
|
-
function userLegacyPaths(root) { const out = [join(root, CONFIG_FILE), join(root, STATE_FILE), join(root, 'profile-defaults.json')]; const profiles = join(root, PROFILE_DIR); if (existsSync(profiles))
|
|
25
|
-
for (const entry of readdirSync(profiles, { withFileTypes: true }))
|
|
26
|
-
if (entry.isDirectory() && PROFILE_ID_SHAPE.test(entry.name))
|
|
27
|
-
out.push(join(profiles, entry.name, 'profile.json'), join(profiles, entry.name, CONFIG_FILE), join(profiles, entry.name, STATE_FILE)); return out; }
|
|
28
|
-
function providerLegacyPaths(agentDir) { return [join(agentDir, 'anthropic-subscriptions.json'), join(agentDir, 'openai-codex-subscriptions.json'), join(agentDir, 'provider-rotation.json')]; }
|
|
29
22
|
function withLegacyProjectStateLock(root, run) {
|
|
30
23
|
const path = join(root, STATE_FILE);
|
|
31
24
|
const release = lockfile.lockSync(path, { realpath: false, retries: 0, stale: 30_000, update: 10_000 });
|
|
@@ -36,17 +29,7 @@ function withLegacyProjectStateLock(root, run) {
|
|
|
36
29
|
release();
|
|
37
30
|
}
|
|
38
31
|
}
|
|
39
|
-
export
|
|
40
|
-
const store = input.kind === 'user' ? userStore(input.root) : input.kind === 'project' ? projectStore(input.root) : providerStore(input.root);
|
|
41
|
-
if (scopedStoreExists(store))
|
|
42
|
-
return;
|
|
43
|
-
const legacy = input.kind === 'user' ? userLegacyPaths(store.root) : input.kind === 'project' ? [join(store.root, STATE_FILE)] : providerLegacyPaths(store.root);
|
|
44
|
-
const legacyPath = legacy.find(existsSync);
|
|
45
|
-
if (legacyPath !== undefined)
|
|
46
|
-
throw new Error(`legacy scoped state exists at ${legacyPath}; crtr was updated and \`crtr sys daemon restart\` converts local state`);
|
|
47
|
-
createScopedStore(store, (db) => { createScopedSchema(db, input.kind); if (input.kind !== 'provider')
|
|
48
|
-
writeOwnerRoot(db, input.kind === 'user' ? 'user' : 'project', store.root); });
|
|
49
|
-
}
|
|
32
|
+
export { initializeFreshScopedStore } from './db.js';
|
|
50
33
|
/** Conversion is intentionally explicit. Deployment coordination must prove all legacy writers at these shared roots are stopped before calling it. */
|
|
51
34
|
export function convertLegacyScopedStore(input) {
|
|
52
35
|
const store = input.kind === 'user' ? userStore(input.root) : input.kind === 'project' ? projectStore(input.root) : providerStore(input.root);
|
|
@@ -54,7 +37,7 @@ export function convertLegacyScopedStore(input) {
|
|
|
54
37
|
return;
|
|
55
38
|
if (input.kind === 'provider')
|
|
56
39
|
throw new Error('provider conversion requires convertLegacyProviderStore() under coordinated shared-agent-dir activation');
|
|
57
|
-
const paths =
|
|
40
|
+
const paths = legacyScopedStorePaths(store);
|
|
58
41
|
const convert = () => {
|
|
59
42
|
if (scopedStoreExists(store))
|
|
60
43
|
return;
|
|
@@ -127,7 +110,7 @@ export async function convertLegacyProviderStore(agentDir) {
|
|
|
127
110
|
const store = providerStore(agentDir);
|
|
128
111
|
if (scopedStoreExists(store))
|
|
129
112
|
return;
|
|
130
|
-
const paths =
|
|
113
|
+
const paths = legacyScopedStorePaths(store);
|
|
131
114
|
await withLegacyProviderLocks(paths, async () => {
|
|
132
115
|
if (scopedStoreExists(store))
|
|
133
116
|
return;
|
package/package.json
CHANGED
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.314",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@north-light/crouter",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.314",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"workspaces": [
|
|
@@ -5206,17 +5206,17 @@
|
|
|
5206
5206
|
},
|
|
5207
5207
|
"packages/crouter-api": {
|
|
5208
5208
|
"name": "@north-light/crouter-api",
|
|
5209
|
-
"version": "0.3.
|
|
5209
|
+
"version": "0.3.314",
|
|
5210
5210
|
"license": "UNLICENSED"
|
|
5211
5211
|
},
|
|
5212
5212
|
"packages/crouter-env-docker": {
|
|
5213
5213
|
"name": "@north-light/crouter-env-docker",
|
|
5214
|
-
"version": "0.3.
|
|
5214
|
+
"version": "0.3.314",
|
|
5215
5215
|
"license": "UNLICENSED"
|
|
5216
5216
|
},
|
|
5217
5217
|
"packages/crouter-sdk": {
|
|
5218
5218
|
"name": "@north-light/crouter-sdk",
|
|
5219
|
-
"version": "0.3.
|
|
5219
|
+
"version": "0.3.314",
|
|
5220
5220
|
"license": "UNLICENSED",
|
|
5221
5221
|
"dependencies": {
|
|
5222
5222
|
"@north-light/crouter-api": "^0.3.295"
|