@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.
@@ -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
+ });
@@ -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 { ScopedStore } from './paths.js';
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 { SCOPED_SCHEMA_VERSION } from './schema.js';
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
- throw new Error(`scoped SQL store is not initialized: ${store.path}; crtr was updated and \`crtr sys daemon restart\` converts local state`);
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 declare function initializeFreshScopedStore(input: {
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 function initializeFreshScopedStore(input) {
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 = input.kind === 'user' ? userLegacyPaths(store.root) : [join(store.root, STATE_FILE)];
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 = providerLegacyPaths(store.root);
113
+ const paths = legacyScopedStorePaths(store);
131
114
  await withLegacyProviderLocks(paths, async () => {
132
115
  if (scopedStoreExists(store))
133
116
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.313",
3
+ "version": "0.3.314",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.313",
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.313",
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.313",
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.313",
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.313",
5219
+ "version": "0.3.314",
5220
5220
  "license": "UNLICENSED",
5221
5221
  "dependencies": {
5222
5222
  "@north-light/crouter-api": "^0.3.295"