@vxnus/siduri 0.0.5 → 0.0.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/README.md CHANGED
@@ -1,47 +1,66 @@
1
1
  # @vxnus/siduri
2
2
 
3
- Experimental CLI for creating and configuring Siduri companions.
3
+ Experimental CLI for creating, diagnosing, and managing standalone Siduri companions powered by the `@siduri-x/*` organ ecosystem.
4
4
 
5
5
  Requires Node.js 20 or newer.
6
6
 
7
7
  ```bash
8
- npx @vxnus/siduri@0.0.5 create
8
+ npx @vxnus/siduri create my-companion
9
9
  ```
10
10
 
11
- The companion name becomes the project directory. For example, answering
12
- `My Companion` creates `./my-companion/siduri.config.json` from the current
13
- directory.
11
+ ## Features
14
12
 
15
- The wizard configures the required Brain and Memory organs, then lets you
16
- enable or disable Voice, Knowledge, Behavior, Body, and Vision. Knowledge can
17
- come from an installed E pack, an E Hub distribution, or a hosted provider.
13
+ - **Manifest-Driven Organ Discovery**: Dynamically discovers installed `@siduri-x/*` organs and generates custom, standalone ESM instance code.
14
+ - **Zero Monolithic Bundling**: Scaffolds standard Node.js ESM projects with explicit dependency trees.
15
+ - **Diagnostics (`siduri doctor`)**: Runs environment variable validation, external service checks, database health probes, and organ-specific assertions.
16
+ - **Database Migrations (`siduri db push`)**: Inspects database-owning organs (such as `@siduri-x/memory`) and executes SQL migrations with SHA-256 integrity checksums.
18
17
 
19
- Brain providers:
18
+ ## CLI Usage
20
19
 
21
- - **OpenRouter** managed model routing using `OPENROUTER_API_KEY`.
22
- - **OpenAI-compatible API** — a custom `baseUrl`, model ID, and API-key
23
- environment variable.
20
+ ### 1. Create a Standalone Companion
24
21
 
25
- Memory currently uses PostgreSQL. The wizard lets you choose Local PostgreSQL,
26
- Neon, Supabase, or another PostgreSQL provider; all use `DATABASE_URL`. SQLite
27
- is shown as a future option but is not selectable in this release.
22
+ ```bash
23
+ npx @vxnus/siduri create [directory]
24
+ ```
25
+
26
+ The interactive wizard allows you to name your companion and select any combination of available `@siduri-x/*` organs. It generates:
27
+
28
+ ```text
29
+ my-companion/
30
+ ├── package.json # ESM package referencing only selected @siduri-x/* organs
31
+ ├── siduri.config.json # Selected organ configurations
32
+ ├── siduri.schema.json # Composed JSON Schema from organ manifests
33
+ ├── .env.example # Only environment variables required by selected organs
34
+ ├── README.md # Instance-specific guide
35
+ └── src/
36
+ └── index.js # Direct runtime bootstrapping with explicit organ factories
37
+ ```
38
+
39
+ ### 2. Run Diagnostics
40
+
41
+ ```bash
42
+ npx @vxnus/siduri doctor [directory]
43
+ ```
44
+
45
+ Inspects active configuration, checks required/optional environment variables, and executes health probes.
46
+
47
+ ### 3. Apply Migrations
48
+
49
+ ```bash
50
+ npx @vxnus/siduri db push [directory]
51
+ ```
52
+
53
+ Runs database migrations exclusively for configured database organs. If no database organs are selected (e.g. Brain + Hands), reports that no migrations are needed.
28
54
 
29
- The wizard creates a project directory from the companion name, writes
30
- `siduri.config.json`, copies the Siduri runtime, and installs the runtime
31
- dependencies. API keys are never written to the configuration file. Optional
32
- organs can be configured as `{ "provider": "none" }`; Brain and Memory remain
33
- required.
55
+ ## Local Development
34
56
 
35
- After setup, start the generated instance with:
57
+ From the repository root:
36
58
 
37
59
  ```bash
38
- cd my-companion
39
- npm run start
60
+ pnpm --filter @vxnus/siduri build
61
+ pnpm --filter @vxnus/siduri test
40
62
  ```
41
63
 
42
- For local development, build the CLI from the repository root with
43
- `pnpm --filter @vxnus/siduri build`. For the full architecture, see the
44
- [CLI documentation](../docs/cli.md) and
45
- [configuration reference](../docs/configuration.md).
64
+ ## License
46
65
 
47
- This release is experimental and is not intended for production use.
66
+ Licensed under the [Apache License, Version 2.0](./LICENSE).
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,222 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const node_child_process_1 = require("node:child_process");
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const generator_1 = require("./generator");
10
+ const discovery_1 = require("./discovery");
11
+ describe('Phase 5: Clean-Machine Distribution & E2E Integration Suite', () => {
12
+ const repoRoot = node_path_1.default.resolve(__dirname, '../..');
13
+ const tempPackDir = node_path_1.default.resolve(__dirname, '../temp-packs-e2e');
14
+ const cleanMachineRoot = node_path_1.default.resolve(__dirname, '../temp-clean-machine-e2e');
15
+ const ALL_CANONICAL_PACKAGES = [
16
+ { filter: '@siduri-x/core', tarName: 'siduri-x-core-1.0.0.tgz', isOrgan: false },
17
+ { filter: '@siduri-x/brain', tarName: 'siduri-x-brain-1.0.0.tgz', isOrgan: true },
18
+ { filter: '@siduri-x/memory', tarName: 'siduri-x-memory-1.0.0.tgz', isOrgan: true },
19
+ { filter: '@siduri-x/knowledge', tarName: 'siduri-x-knowledge-1.0.0.tgz', isOrgan: true },
20
+ { filter: '@siduri-x/behavior', tarName: 'siduri-x-behavior-1.0.0.tgz', isOrgan: true },
21
+ { filter: '@siduri-x/ear', tarName: 'siduri-x-ear-1.0.0.tgz', isOrgan: true },
22
+ { filter: '@siduri-x/vision', tarName: 'siduri-x-vision-1.0.0.tgz', isOrgan: true },
23
+ { filter: '@siduri-x/hands', tarName: 'siduri-x-hands-1.0.0.tgz', isOrgan: true },
24
+ { filter: '@siduri-x/body', tarName: 'siduri-x-body-1.0.0.tgz', isOrgan: true },
25
+ { filter: '@siduri-x/voice', tarName: 'siduri-x-voice-1.0.0.tgz', isOrgan: true },
26
+ { filter: '@siduri-x/observation', tarName: 'siduri-x-observation-1.0.0.tgz', isOrgan: true },
27
+ { filter: '@vxnus/siduri', tarName: 'vxnus-siduri-0.0.6.tgz', isOrgan: false },
28
+ ];
29
+ beforeAll(() => {
30
+ // 1. Prepare clean directories
31
+ if (node_fs_1.default.existsSync(tempPackDir))
32
+ node_fs_1.default.rmSync(tempPackDir, { recursive: true, force: true });
33
+ if (node_fs_1.default.existsSync(cleanMachineRoot))
34
+ node_fs_1.default.rmSync(cleanMachineRoot, { recursive: true, force: true });
35
+ node_fs_1.default.mkdirSync(tempPackDir, { recursive: true });
36
+ node_fs_1.default.mkdirSync(cleanMachineRoot, { recursive: true });
37
+ // 2. Build all packages in repo
38
+ (0, node_child_process_1.execSync)('pnpm build', { cwd: repoRoot, stdio: 'pipe' });
39
+ // 3. Pack each canonical package into tempPackDir
40
+ for (const pkg of ALL_CANONICAL_PACKAGES) {
41
+ (0, node_child_process_1.execSync)(`pnpm --filter ${pkg.filter} pack --pack-destination ${tempPackDir}`, {
42
+ cwd: repoRoot,
43
+ stdio: 'pipe',
44
+ });
45
+ }
46
+ }, 90000);
47
+ afterAll(() => {
48
+ if (node_fs_1.default.existsSync(tempPackDir))
49
+ node_fs_1.default.rmSync(tempPackDir, { recursive: true, force: true });
50
+ if (node_fs_1.default.existsSync(cleanMachineRoot))
51
+ node_fs_1.default.rmSync(cleanMachineRoot, { recursive: true, force: true });
52
+ });
53
+ describe('Phase 5A: Package Artifact Verification', () => {
54
+ test('all 12 packages produce valid tarballs', () => {
55
+ for (const pkg of ALL_CANONICAL_PACKAGES) {
56
+ const tarPath = node_path_1.default.join(tempPackDir, pkg.tarName);
57
+ expect(node_fs_1.default.existsSync(tarPath)).toBe(true);
58
+ }
59
+ });
60
+ test('packed package.json files have zero workspace:* or link: dependencies', () => {
61
+ for (const pkg of ALL_CANONICAL_PACKAGES) {
62
+ const tarPath = node_path_1.default.join(tempPackDir, pkg.tarName);
63
+ const pkgJsonRaw = (0, node_child_process_1.execSync)(`tar -xzf ${tarPath} -O package/package.json`, { encoding: 'utf8' });
64
+ const pkgJson = JSON.parse(pkgJsonRaw);
65
+ const allDeps = {
66
+ ...(pkgJson.dependencies || {}),
67
+ ...(pkgJson.peerDependencies || {}),
68
+ };
69
+ for (const [depName, version] of Object.entries(allDeps)) {
70
+ if (typeof version === 'string') {
71
+ expect(version.startsWith('workspace:')).toBe(false);
72
+ expect(version.startsWith('link:')).toBe(false);
73
+ expect(version.includes('../')).toBe(false);
74
+ }
75
+ }
76
+ }
77
+ });
78
+ test('every organ package tarball contains organ-manifest.json and dist files', () => {
79
+ const organPackages = ALL_CANONICAL_PACKAGES.filter((p) => p.isOrgan);
80
+ for (const pkg of organPackages) {
81
+ const tarPath = node_path_1.default.join(tempPackDir, pkg.tarName);
82
+ const listing = (0, node_child_process_1.execSync)(`tar -tzf ${tarPath}`, { encoding: 'utf8' }).split('\n');
83
+ expect(listing.some((line) => line.includes('package/organ-manifest.json'))).toBe(true);
84
+ expect(listing.some((line) => line.includes('package/dist/index.js'))).toBe(true);
85
+ expect(listing.some((line) => line.includes('package/dist/index.d.ts'))).toBe(true);
86
+ }
87
+ });
88
+ test('memory organ tarball packages migrations/001_initial_schema.sql', () => {
89
+ const memoryTarPath = node_path_1.default.join(tempPackDir, 'siduri-x-memory-1.0.0.tgz');
90
+ const listing = (0, node_child_process_1.execSync)(`tar -tzf ${memoryTarPath}`, { encoding: 'utf8' });
91
+ expect(listing).toContain('package/migrations/001_initial_schema.sql');
92
+ });
93
+ });
94
+ describe('Phase 5B & 5C & 5G: Clean-Machine Installation & Isolation Acceptance', () => {
95
+ const registry = discovery_1.OrganRegistry.discover([node_path_1.default.resolve(repoRoot, 'packages/organs')]);
96
+ const brain = registry.get('brain');
97
+ const hands = registry.get('hands');
98
+ const memory = registry.get('memory');
99
+ test('Clean Composition 1: Brain only instance installs, starts, runs doctor and db push', () => {
100
+ const instanceDir = node_path_1.default.join(cleanMachineRoot, 'inst-brain-only');
101
+ node_fs_1.default.mkdirSync(node_path_1.default.join(instanceDir, 'src'), { recursive: true });
102
+ const files = (0, generator_1.generateInstanceFiles)({
103
+ name: 'CleanBrainOnly',
104
+ selectedManifests: [brain],
105
+ });
106
+ // Write instance files referencing packed tarballs directly for true clean machine install
107
+ const pkgObj = JSON.parse(files['package.json']);
108
+ pkgObj.dependencies = {
109
+ '@siduri-x/core': `file:${node_path_1.default.join(tempPackDir, 'siduri-x-core-1.0.0.tgz')}`,
110
+ '@siduri-x/brain': `file:${node_path_1.default.join(tempPackDir, 'siduri-x-brain-1.0.0.tgz')}`,
111
+ };
112
+ node_fs_1.default.writeFileSync(node_path_1.default.join(instanceDir, 'package.json'), JSON.stringify(pkgObj, null, 2) + '\n');
113
+ node_fs_1.default.writeFileSync(node_path_1.default.join(instanceDir, 'siduri.config.json'), files['siduri.config.json']);
114
+ node_fs_1.default.writeFileSync(node_path_1.default.join(instanceDir, 'siduri.schema.json'), files['siduri.schema.json']);
115
+ node_fs_1.default.writeFileSync(node_path_1.default.join(instanceDir, '.env.example'), files['.env.example']);
116
+ node_fs_1.default.writeFileSync(node_path_1.default.join(instanceDir, 'README.md'), files['README.md']);
117
+ node_fs_1.default.writeFileSync(node_path_1.default.join(instanceDir, 'src/index.js'), files['src/index.js']);
118
+ // 1. Run npm install in isolated directory
119
+ (0, node_child_process_1.execSync)('npm install --no-audit --no-fund', { cwd: instanceDir, stdio: 'pipe' });
120
+ // 2. Assert unselected organs are NOT in node_modules
121
+ expect(node_fs_1.default.existsSync(node_path_1.default.join(instanceDir, 'node_modules/@siduri-x/memory'))).toBe(false);
122
+ expect(node_fs_1.default.existsSync(node_path_1.default.join(instanceDir, 'node_modules/@siduri-x/hands'))).toBe(false);
123
+ expect(node_fs_1.default.existsSync(node_path_1.default.join(instanceDir, 'node_modules/@siduri-x/voice'))).toBe(false);
124
+ expect(node_fs_1.default.existsSync(node_path_1.default.join(instanceDir, 'siduri-x-runtime.js'))).toBe(false);
125
+ // 3. Run node src/index.js (starts cleanly without PostgreSQL or unselected organs)
126
+ const startOutput = (0, node_child_process_1.execSync)('node src/index.js', { cwd: instanceDir, encoding: 'utf8' });
127
+ expect(startOutput).toContain('✓ Siduri [CleanBrainOnly] initialized with [Brain].');
128
+ // 4. Verify doctor from standalone directory
129
+ const doctorPass = (0, node_child_process_1.execSync)(`node ${node_path_1.default.resolve(repoRoot, 'cli/dist/index.js')} doctor`, {
130
+ cwd: instanceDir,
131
+ env: { ...process.env, OPENROUTER_API_KEY: 'test-key-clean' },
132
+ encoding: 'utf8',
133
+ });
134
+ expect(doctorPass).toContain('Result: PASS');
135
+ expect(doctorPass).toContain('Database');
136
+ expect(doctorPass).toContain('Not required by current composition');
137
+ // 5. Verify db push from standalone directory
138
+ const dbPushOutput = (0, node_child_process_1.execSync)(`node ${node_path_1.default.resolve(repoRoot, 'cli/dist/index.js')} db push`, {
139
+ cwd: instanceDir,
140
+ encoding: 'utf8',
141
+ });
142
+ expect(dbPushOutput).toContain('No database migrations are required by this instance.');
143
+ }, 60000);
144
+ test('Clean Composition 2: Brain + Hands installs and runs doctor verifying ACTION_POLICY_SECRET', () => {
145
+ const instanceDir = node_path_1.default.join(cleanMachineRoot, 'inst-brain-hands');
146
+ node_fs_1.default.mkdirSync(node_path_1.default.join(instanceDir, 'src'), { recursive: true });
147
+ const files = (0, generator_1.generateInstanceFiles)({
148
+ name: 'CleanBrainHands',
149
+ selectedManifests: [brain, hands],
150
+ });
151
+ const pkgObj = JSON.parse(files['package.json']);
152
+ pkgObj.dependencies = {
153
+ '@siduri-x/core': `file:${node_path_1.default.join(tempPackDir, 'siduri-x-core-1.0.0.tgz')}`,
154
+ '@siduri-x/brain': `file:${node_path_1.default.join(tempPackDir, 'siduri-x-brain-1.0.0.tgz')}`,
155
+ '@siduri-x/hands': `file:${node_path_1.default.join(tempPackDir, 'siduri-x-hands-1.0.0.tgz')}`,
156
+ };
157
+ node_fs_1.default.writeFileSync(node_path_1.default.join(instanceDir, 'package.json'), JSON.stringify(pkgObj, null, 2) + '\n');
158
+ node_fs_1.default.writeFileSync(node_path_1.default.join(instanceDir, 'siduri.config.json'), files['siduri.config.json']);
159
+ node_fs_1.default.writeFileSync(node_path_1.default.join(instanceDir, 'siduri.schema.json'), files['siduri.schema.json']);
160
+ node_fs_1.default.writeFileSync(node_path_1.default.join(instanceDir, '.env.example'), files['.env.example']);
161
+ node_fs_1.default.writeFileSync(node_path_1.default.join(instanceDir, 'README.md'), files['README.md']);
162
+ node_fs_1.default.writeFileSync(node_path_1.default.join(instanceDir, 'src/index.js'), files['src/index.js']);
163
+ (0, node_child_process_1.execSync)('npm install --no-audit --no-fund', { cwd: instanceDir, stdio: 'pipe' });
164
+ expect(node_fs_1.default.existsSync(node_path_1.default.join(instanceDir, 'node_modules/@siduri-x/memory'))).toBe(false);
165
+ const startOutput = (0, node_child_process_1.execSync)('node src/index.js', { cwd: instanceDir, encoding: 'utf8' });
166
+ expect(startOutput).toContain('✓ Siduri [CleanBrainHands] initialized with [Brain, Hands].');
167
+ const doctorOutput = (0, node_child_process_1.execSync)(`node ${node_path_1.default.resolve(repoRoot, 'cli/dist/index.js')} doctor`, {
168
+ cwd: instanceDir,
169
+ env: {
170
+ ...process.env,
171
+ OPENROUTER_API_KEY: 'test-key-clean',
172
+ ACTION_POLICY_SECRET: 'test-secret-clean',
173
+ },
174
+ encoding: 'utf8',
175
+ });
176
+ expect(doctorOutput).toContain('ACTION_POLICY_SECRET');
177
+ expect(doctorOutput).toContain('Result: PASS');
178
+ }, 60000);
179
+ test('Clean Composition 3: Brain + Memory discovers packaged migrations and detects DATABASE_URL', () => {
180
+ const instanceDir = node_path_1.default.join(cleanMachineRoot, 'inst-brain-memory');
181
+ node_fs_1.default.mkdirSync(node_path_1.default.join(instanceDir, 'src'), { recursive: true });
182
+ const files = (0, generator_1.generateInstanceFiles)({
183
+ name: 'CleanBrainMemory',
184
+ selectedManifests: [brain, memory],
185
+ });
186
+ const pkgObj = JSON.parse(files['package.json']);
187
+ pkgObj.dependencies = {
188
+ '@siduri-x/core': `file:${node_path_1.default.join(tempPackDir, 'siduri-x-core-1.0.0.tgz')}`,
189
+ '@siduri-x/brain': `file:${node_path_1.default.join(tempPackDir, 'siduri-x-brain-1.0.0.tgz')}`,
190
+ '@siduri-x/memory': `file:${node_path_1.default.join(tempPackDir, 'siduri-x-memory-1.0.0.tgz')}`,
191
+ };
192
+ node_fs_1.default.writeFileSync(node_path_1.default.join(instanceDir, 'package.json'), JSON.stringify(pkgObj, null, 2) + '\n');
193
+ node_fs_1.default.writeFileSync(node_path_1.default.join(instanceDir, 'siduri.config.json'), files['siduri.config.json']);
194
+ node_fs_1.default.writeFileSync(node_path_1.default.join(instanceDir, 'siduri.schema.json'), files['siduri.schema.json']);
195
+ node_fs_1.default.writeFileSync(node_path_1.default.join(instanceDir, '.env.example'), files['.env.example']);
196
+ node_fs_1.default.writeFileSync(node_path_1.default.join(instanceDir, 'README.md'), files['README.md']);
197
+ node_fs_1.default.writeFileSync(node_path_1.default.join(instanceDir, 'src/index.js'), files['src/index.js']);
198
+ (0, node_child_process_1.execSync)('npm install --no-audit --no-fund', { cwd: instanceDir, stdio: 'pipe' });
199
+ // Verify migrations exist inside node_modules/@siduri-x/memory/migrations
200
+ const installedMigrationsPath = node_path_1.default.join(instanceDir, 'node_modules/@siduri-x/memory/migrations/001_initial_schema.sql');
201
+ expect(node_fs_1.default.existsSync(installedMigrationsPath)).toBe(true);
202
+ // Doctor detects database requirement
203
+ let doctorOutput = '';
204
+ try {
205
+ doctorOutput = (0, node_child_process_1.execSync)(`node ${node_path_1.default.resolve(repoRoot, 'cli/dist/index.js')} doctor`, {
206
+ cwd: instanceDir,
207
+ env: {
208
+ ...process.env,
209
+ OPENROUTER_API_KEY: 'test-key-clean',
210
+ DATABASE_URL: '', // deliberately empty to test requirement detection
211
+ },
212
+ encoding: 'utf8',
213
+ });
214
+ }
215
+ catch (err) {
216
+ doctorOutput = (err.stdout || '') + (err.stderr || '');
217
+ }
218
+ expect(doctorOutput).toContain('DATABASE_URL is not configured');
219
+ expect(doctorOutput).toContain('Result: FAIL');
220
+ }, 60000);
221
+ });
222
+ });
package/dist/db.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ export declare function redactDatabaseUrl(url: string): string;
2
+ export interface MigrationFile {
3
+ name: string;
4
+ fullPath: string;
5
+ sql: string;
6
+ checksum: string;
7
+ }
8
+ export interface MigrationRecord {
9
+ id: number;
10
+ name: string;
11
+ checksum: string;
12
+ applied_at: string;
13
+ }
14
+ export interface DbPushOptions {
15
+ projectDir?: string;
16
+ connectionString?: string;
17
+ env?: Record<string, string | undefined>;
18
+ }
19
+ export interface DbPushResult {
20
+ status: 'NOOP' | 'APPLIED' | 'UP_TO_DATE';
21
+ appliedMigrations: string[];
22
+ skippedMigrations: string[];
23
+ message: string;
24
+ }
25
+ export declare function runDbPush(options?: DbPushOptions): Promise<DbPushResult>;
package/dist/db.js ADDED
@@ -0,0 +1,152 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.redactDatabaseUrl = redactDatabaseUrl;
7
+ exports.runDbPush = runDbPush;
8
+ const node_crypto_1 = require("node:crypto");
9
+ const node_fs_1 = __importDefault(require("node:fs"));
10
+ const node_path_1 = __importDefault(require("node:path"));
11
+ const pg_1 = require("pg");
12
+ const discovery_1 = require("./discovery");
13
+ function redactDatabaseUrl(url) {
14
+ try {
15
+ const parsed = new URL(url);
16
+ if (parsed.password) {
17
+ parsed.password = '***';
18
+ }
19
+ return parsed.toString();
20
+ }
21
+ catch {
22
+ return 'postgres://***:***@...';
23
+ }
24
+ }
25
+ async function runDbPush(options = {}) {
26
+ const projectDir = options.projectDir ? node_path_1.default.resolve(options.projectDir) : process.cwd();
27
+ const configPath = node_path_1.default.join(projectDir, 'siduri.config.json');
28
+ if (!node_fs_1.default.existsSync(configPath)) {
29
+ throw new Error(`siduri.config.json not found at ${projectDir}. Make sure you are in a Siduri instance directory.`);
30
+ }
31
+ const config = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf8'));
32
+ const configuredOrgans = config.organs || {};
33
+ // 1. Discover manifests of configured organs
34
+ const registry = discovery_1.OrganRegistry.discover([
35
+ node_path_1.default.join(projectDir, 'node_modules/@siduri-x'),
36
+ node_path_1.default.resolve(__dirname, '../../packages/organs'),
37
+ node_path_1.default.resolve(process.cwd(), 'packages/organs'),
38
+ ]);
39
+ const databaseOrgans = [];
40
+ for (const [key] of Object.entries(configuredOrgans)) {
41
+ const manifest = registry.get(key) || registry.getAll().find((m) => m.configKey === key || m.organType === key);
42
+ if (manifest && manifest.database && manifest.database.migrationsDir) {
43
+ // Resolve migrations directory relative to the organ package
44
+ const candidatePaths = [
45
+ node_path_1.default.resolve(projectDir, 'node_modules', manifest.name, manifest.database.migrationsDir),
46
+ node_path_1.default.resolve(__dirname, '../../packages/organs', manifest.organType, manifest.database.migrationsDir),
47
+ node_path_1.default.resolve(process.cwd(), 'packages/organs', manifest.organType, manifest.database.migrationsDir),
48
+ ];
49
+ const resolvedDir = candidatePaths.find((p) => node_fs_1.default.existsSync(p));
50
+ if (resolvedDir) {
51
+ databaseOrgans.push({
52
+ organType: manifest.organType,
53
+ manifest,
54
+ migrationsDir: resolvedDir,
55
+ });
56
+ }
57
+ }
58
+ }
59
+ if (databaseOrgans.length === 0) {
60
+ return {
61
+ status: 'NOOP',
62
+ appliedMigrations: [],
63
+ skippedMigrations: [],
64
+ message: 'No database migrations are required by this instance.',
65
+ };
66
+ }
67
+ // 2. Resolve DATABASE_URL
68
+ const connectionString = options.connectionString ||
69
+ options.env?.DATABASE_URL ||
70
+ process.env.DATABASE_URL;
71
+ if (!connectionString) {
72
+ throw new Error('DATABASE_URL is required to run database migrations. Set it in .env or environment.');
73
+ }
74
+ const pool = new pg_1.Pool({
75
+ connectionString,
76
+ connectionTimeoutMillis: 5000,
77
+ });
78
+ const client = await pool.connect();
79
+ try {
80
+ // 3. Ensure migrations table exists
81
+ await client.query(`
82
+ CREATE TABLE IF NOT EXISTS siduri_migrations (
83
+ id SERIAL PRIMARY KEY,
84
+ name VARCHAR(255) UNIQUE NOT NULL,
85
+ checksum VARCHAR(64) NOT NULL,
86
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
87
+ );
88
+ `);
89
+ // 4. Fetch applied migrations
90
+ const existingRes = await client.query('SELECT name, checksum FROM siduri_migrations ORDER BY id ASC;');
91
+ const appliedMap = new Map();
92
+ for (const row of existingRes.rows) {
93
+ appliedMap.set(row.name, row.checksum);
94
+ }
95
+ // 5. Gather all migration files across database organs in deterministic order
96
+ const allMigrationFiles = [];
97
+ for (const item of databaseOrgans) {
98
+ const files = node_fs_1.default.readdirSync(item.migrationsDir).filter((f) => f.endsWith('.sql')).sort();
99
+ for (const file of files) {
100
+ const fullPath = node_path_1.default.join(item.migrationsDir, file);
101
+ const sql = node_fs_1.default.readFileSync(fullPath, 'utf8');
102
+ const checksum = (0, node_crypto_1.createHash)('sha256').update(sql).digest('hex');
103
+ allMigrationFiles.push({
104
+ name: `${item.organType}/${file}`,
105
+ fullPath,
106
+ sql,
107
+ checksum,
108
+ });
109
+ }
110
+ }
111
+ allMigrationFiles.sort((a, b) => a.name.localeCompare(b.name));
112
+ const appliedList = [];
113
+ const skippedList = [];
114
+ for (const migration of allMigrationFiles) {
115
+ const recordedChecksum = appliedMap.get(migration.name);
116
+ if (recordedChecksum !== undefined) {
117
+ if (recordedChecksum !== migration.checksum) {
118
+ throw new Error(`Migration checksum mismatch for ${migration.name}! Recorded: ${recordedChecksum.slice(0, 8)}, Current: ${migration.checksum.slice(0, 8)}. Refusing to apply modified migration.`);
119
+ }
120
+ skippedList.push(migration.name);
121
+ }
122
+ else {
123
+ // Apply migration in transaction
124
+ await client.query('BEGIN');
125
+ try {
126
+ await client.query(migration.sql);
127
+ await client.query('INSERT INTO siduri_migrations (name, checksum) VALUES ($1, $2)', [migration.name, migration.checksum]);
128
+ await client.query('COMMIT');
129
+ appliedList.push(migration.name);
130
+ }
131
+ catch (err) {
132
+ await client.query('ROLLBACK');
133
+ throw err;
134
+ }
135
+ }
136
+ }
137
+ const redacted = redactDatabaseUrl(connectionString);
138
+ const message = appliedList.length > 0
139
+ ? `Applied ${appliedList.length} migration(s) to ${redacted}`
140
+ : `Database schema up to date on ${redacted}`;
141
+ return {
142
+ status: appliedList.length > 0 ? 'APPLIED' : 'UP_TO_DATE',
143
+ appliedMigrations: appliedList,
144
+ skippedMigrations: skippedList,
145
+ message,
146
+ };
147
+ }
148
+ finally {
149
+ client.release();
150
+ await pool.end().catch(() => { });
151
+ }
152
+ }
@@ -0,0 +1,13 @@
1
+ import { OrganManifest } from './manifest';
2
+ export declare class OrganRegistry {
3
+ private manifests;
4
+ constructor(manifests?: OrganManifest[]);
5
+ register(manifest: OrganManifest): void;
6
+ get(organType: string): OrganManifest | undefined;
7
+ getAll(): OrganManifest[];
8
+ getAvailableOrganTypes(): string[];
9
+ /**
10
+ * Discover installed or monorepo organ packages.
11
+ */
12
+ static discover(searchRoots?: string[]): OrganRegistry;
13
+ }
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.OrganRegistry = void 0;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const manifest_1 = require("./manifest");
10
+ class OrganRegistry {
11
+ manifests = new Map();
12
+ constructor(manifests) {
13
+ if (manifests) {
14
+ for (const m of manifests) {
15
+ this.manifests.set(m.organType, m);
16
+ }
17
+ }
18
+ }
19
+ register(manifest) {
20
+ const validated = (0, manifest_1.validateOrganManifest)(manifest);
21
+ this.manifests.set(validated.organType, validated);
22
+ }
23
+ get(organType) {
24
+ return this.manifests.get(organType);
25
+ }
26
+ getAll() {
27
+ return Array.from(this.manifests.values());
28
+ }
29
+ getAvailableOrganTypes() {
30
+ return Array.from(this.manifests.keys());
31
+ }
32
+ /**
33
+ * Discover installed or monorepo organ packages.
34
+ */
35
+ static discover(searchRoots) {
36
+ const registry = new OrganRegistry();
37
+ const rootsToScan = searchRoots && searchRoots.length > 0
38
+ ? searchRoots
39
+ : [
40
+ // 1. Monorepo organs folder relative to cli
41
+ node_path_1.default.resolve(__dirname, '../../packages/organs'),
42
+ // 2. Monorepo organs folder relative to cwd
43
+ node_path_1.default.resolve(process.cwd(), 'packages/organs'),
44
+ // 3. Node modules of current directory or global resolution
45
+ node_path_1.default.resolve(process.cwd(), 'node_modules/@siduri-x'),
46
+ node_path_1.default.resolve(__dirname, '../node_modules/@siduri-x'),
47
+ ];
48
+ const visitedDirs = new Set();
49
+ for (const root of rootsToScan) {
50
+ if (!node_fs_1.default.existsSync(root) || visitedDirs.has(root))
51
+ continue;
52
+ visitedDirs.add(root);
53
+ try {
54
+ const entries = node_fs_1.default.readdirSync(root, { withFileTypes: true });
55
+ for (const entry of entries) {
56
+ if (!entry.isDirectory())
57
+ continue;
58
+ const candidateDir = node_path_1.default.join(root, entry.name);
59
+ const manifestPath = node_path_1.default.join(candidateDir, 'organ-manifest.json');
60
+ if (node_fs_1.default.existsSync(manifestPath)) {
61
+ try {
62
+ const raw = JSON.parse(node_fs_1.default.readFileSync(manifestPath, 'utf8'));
63
+ const manifest = (0, manifest_1.validateOrganManifest)(raw, manifestPath);
64
+ registry.register(manifest);
65
+ }
66
+ catch (err) {
67
+ console.warn(`[Siduri CLI] Warning: failed to parse manifest at ${manifestPath}: ${err.message}`);
68
+ }
69
+ }
70
+ }
71
+ }
72
+ catch (err) {
73
+ // Continue scanning other roots
74
+ }
75
+ }
76
+ return registry;
77
+ }
78
+ }
79
+ exports.OrganRegistry = OrganRegistry;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const node_path_1 = __importDefault(require("node:path"));
7
+ const discovery_1 = require("./discovery");
8
+ const generator_1 = require("./generator");
9
+ describe('Discovery & Dynamic Composition System Tests (Phase 3)', () => {
10
+ const rootOrgansDir = node_path_1.default.resolve(__dirname, '../../packages/organs');
11
+ test('Registry successfully discovers all 10 organ packages from workspace', () => {
12
+ const registry = discovery_1.OrganRegistry.discover([rootOrgansDir]);
13
+ const manifests = registry.getAll();
14
+ expect(manifests.length).toBe(10);
15
+ const organTypes = registry.getAvailableOrganTypes().sort();
16
+ expect(organTypes).toEqual([
17
+ 'behavior',
18
+ 'body',
19
+ 'brain',
20
+ 'ear',
21
+ 'hands',
22
+ 'knowledge',
23
+ 'memory',
24
+ 'observation',
25
+ 'vision',
26
+ 'voice',
27
+ ]);
28
+ });
29
+ test('Generates valid instances from discovered manifests', () => {
30
+ const registry = discovery_1.OrganRegistry.discover([rootOrgansDir]);
31
+ // Test Brain + Hands + Vision
32
+ const brain = registry.get('brain');
33
+ const hands = registry.get('hands');
34
+ const vision = registry.get('vision');
35
+ const files = (0, generator_1.generateInstanceFiles)({
36
+ name: 'robotics-agent',
37
+ selectedManifests: [brain, hands, vision],
38
+ });
39
+ const pkg = JSON.parse(files['package.json']);
40
+ expect(Object.keys(pkg.dependencies).sort()).toEqual([
41
+ '@siduri-x/brain',
42
+ '@siduri-x/core',
43
+ '@siduri-x/hands',
44
+ '@siduri-x/vision',
45
+ ]);
46
+ const schema = JSON.parse(files['siduri.schema.json']);
47
+ expect(Object.keys(schema.properties.organs.properties).sort()).toEqual(['brain', 'hands', 'vision']);
48
+ const config = JSON.parse(files['siduri.config.json']);
49
+ expect(Object.keys(config.organs).sort()).toEqual(['brain', 'hands', 'vision']);
50
+ expect(files['.env.example']).toContain('OPENROUTER_API_KEY');
51
+ expect(files['.env.example']).toContain('ACTION_POLICY_SECRET');
52
+ expect(files['.env.example']).not.toContain('DATABASE_URL');
53
+ });
54
+ test('Validates manifest contract integrity during discovery', () => {
55
+ expect(() => {
56
+ const invalidRegistry = new discovery_1.OrganRegistry();
57
+ invalidRegistry.register({});
58
+ }).toThrow(/Invalid manifest/);
59
+ });
60
+ });