cinatra 0.1.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.
@@ -0,0 +1,801 @@
1
+ // packages/cli/src/agents-install.mjs
2
+ //
3
+ // `cinatra agents install` — resolve an agent dependency graph against Verdaccio,
4
+ // write `cinatra-agents.lock`, and perform per-package install side-effects.
5
+ //
6
+ // Fully self-contained plain-Node.js implementation.
7
+ // Uses pacote for registry resolution (semver handled internally by pacote/npm-pick-manifest).
8
+ // Uses pg directly for DB writes — no Drizzle/server-only chain required.
9
+ //
10
+ // Exit codes:
11
+ // 0 success | 1 usage error | 2 resolver error | 3 integrity error | 4 config missing
12
+
13
+ import { readFile, writeFile, mkdtemp, rm } from "node:fs/promises";
14
+ import { resolve as resolvePath } from "node:path";
15
+ import { tmpdir } from "node:os";
16
+ import { randomUUID, createHash } from "node:crypto";
17
+
18
+ // The connector catalog (`@cinatra-ai/connectors-catalog`) is NOT bundled into
19
+ // the published thin CLI — it is resolved from the operator's cinatra checkout
20
+ // at COMMAND ENTRY via ./checkout-resolve.mjs and threaded into the resolver as
21
+ // `knownConnectorPackageIds`. This is why `KNOWN_CONNECTOR_PACKAGE_IDS` is no
22
+ // longer a module-scope constant (codex must-fix #4): the module-scope set used
23
+ // to feed the validator at resolve time, which would require the catalog at
24
+ // import time and re-couple the CLI to the workspace.
25
+ import { importFromCheckout } from "./checkout-resolve.mjs";
26
+
27
+ const USAGE = `Usage: cinatra agents install [<name>[@<range>]] [options]
28
+ Options:
29
+ --manifest <path> Read root name+range from a manifest file (package.json shape)
30
+ --lockfile <path> Lockfile path (default: ./cinatra-agents.lock)
31
+ --lockfile-only Write lockfile but skip install side-effects
32
+ --dry-run Print resolved tree and exit; write nothing
33
+ --registry-url <url> Verdaccio registry URL (default: env CINATRA_AGENT_REGISTRY_URL)
34
+ --registry-token <tok> Verdaccio token (default: env CINATRA_AGENT_REGISTRY_TOKEN)
35
+
36
+ Exit codes:
37
+ 0 success | 1 usage error | 2 resolver error | 3 integrity error | 4 config missing
38
+ `;
39
+
40
+ // Lockfile v2 adds `resolvedConnectors` per node (concrete versions for
41
+ // `cinatra.connectorDependencies` ranges) and a top-level
42
+ // `connectorPackageIds` aggregate. v1 lockfiles are NOT
43
+ // schema-compatible: any cache hit on a v1 lockfile forces a full
44
+ // re-resolution so the v2 fields land.
45
+ const LOCKFILE_VERSION = 2;
46
+ // `connectorDependencies` entries are validated against the CLI-safe connector
47
+ // catalog (the same descriptors the host registry consumes) instead of a
48
+ // hand-maintained copy of the package-id list — the copy had already drifted
49
+ // from the catalog, and a literal list re-pins extension instance names in
50
+ // core (instance-coupling gate). The catalog is loaded lazily from the
51
+ // CHECKOUT (see `loadKnownConnectorPackageIds`) so the thin CLI carries no
52
+ // `@cinatra-ai/*` dependency.
53
+ async function loadKnownConnectorPackageIds(repoRoot) {
54
+ const mod = await importFromCheckout(
55
+ repoRoot,
56
+ "@cinatra-ai/connectors-catalog/descriptors.mjs",
57
+ );
58
+ const descriptors = mod.CONNECTOR_DESCRIPTORS;
59
+ if (!Array.isArray(descriptors)) {
60
+ throw new Error(
61
+ "agents install: @cinatra-ai/connectors-catalog/descriptors.mjs did not export CONNECTOR_DESCRIPTORS[].",
62
+ );
63
+ }
64
+ return new Set(descriptors.map((d) => d.packageId));
65
+ }
66
+ // Any well-formed scoped npm name is accepted in agentDependencies, matching
67
+ // Verdaccio's '@cinatra/*-agent' block.
68
+ const SCOPED_NAME_PATTERN = /^@[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/i;
69
+ const DEFAULT_REGISTRY_URL = "http://127.0.0.1:4873";
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Arg parsing
73
+ // ---------------------------------------------------------------------------
74
+
75
+ function redactToken(str, token) {
76
+ if (!token) return String(str);
77
+ return String(str).split(token).join("***");
78
+ }
79
+
80
+ /**
81
+ * Registry-scoped credential entry for pacote option objects.
82
+ *
83
+ * npm-registry-fetch (pacote's HTTP layer) resolves credentials ONLY from
84
+ * nerf-dart-scoped '//<host>/<path>:_authToken' option keys (or forceAuth) —
85
+ * a flat `token` option is silently ignored and produces requests with NO
86
+ * Authorization header (#179). Plain-JS mirror of the canonical TS helper
87
+ * `registryScopedAuthOptions` in @cinatra-ai/registries (this CLI script
88
+ * cannot import the TS source). Returns {} when no token is configured.
89
+ */
90
+ function registryScopedAuthOptions(registryUrl, token) {
91
+ if (!token) return {};
92
+ const parsed = new URL(registryUrl);
93
+ const pathname = parsed.pathname.endsWith("/")
94
+ ? parsed.pathname
95
+ : `${parsed.pathname}/`;
96
+ return { [`//${parsed.host}${pathname}:_authToken`]: token };
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Derive inputSchema from cinatra/oas.json when a tarball lacks the canonical
101
+ // compiled agent.json.
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /** Safe JSON.parse — returns null on parse failure instead of throwing. */
105
+ function safeJsonParse(text) {
106
+ try {
107
+ return JSON.parse(text);
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Derive `inputSchema` from the top-level Flow's `StartNode` referenced
115
+ * component in cinatra/oas.json. Returns null when the OAS shape doesn't
116
+ * match expectations — caller falls back to {}.
117
+ *
118
+ * Expected OAS Flow shape:
119
+ * $referenced_components.<startKey> = {
120
+ * component_type: "StartNode",
121
+ * inputs: [{ title, type, format?, default? }, ...],
122
+ * metadata: { cinatra: { required: ["foo"], hidden: ["bar"] } }
123
+ * }
124
+ *
125
+ * Maps to JSON Schema:
126
+ * { type: "object", required: [...], properties: { foo: { type, format } } }
127
+ */
128
+ function deriveInputSchemaFromOas(oas) {
129
+ if (!oas || typeof oas !== "object") return null;
130
+ if (oas.component_type !== "Flow") return null;
131
+
132
+ // Locate the start node via $referenced_components[start_node.$component_ref].
133
+ const startRef = oas.start_node?.["$component_ref"];
134
+ const refs = oas["$referenced_components"];
135
+ if (!startRef || !refs || typeof refs !== "object") return null;
136
+ const startNode = refs[startRef];
137
+ if (!startNode || startNode.component_type !== "StartNode") return null;
138
+
139
+ const inputs = Array.isArray(startNode.inputs) ? startNode.inputs : [];
140
+ const required = Array.isArray(startNode.metadata?.cinatra?.required)
141
+ ? startNode.metadata.cinatra.required.filter((s) => typeof s === "string")
142
+ : [];
143
+
144
+ const properties = {};
145
+ for (const input of inputs) {
146
+ if (!input || typeof input.title !== "string") continue;
147
+ const prop = { type: typeof input.type === "string" ? input.type : "string" };
148
+ if (typeof input.format === "string") prop.format = input.format;
149
+ if (typeof input.description === "string") prop.description = input.description;
150
+ properties[input.title] = prop;
151
+ }
152
+
153
+ return { type: "object", required, properties };
154
+ }
155
+
156
+ /**
157
+ * Pick the best inputSchema source:
158
+ * 1. agent.template.inputSchema (canonical — `publishAgentPackageFromGitDir`
159
+ * compiles + writes it via `compileOasAgentJson`).
160
+ * 2. Derived from cinatra/oas.json StartNode metadata (defense-in-depth for
161
+ * tarballs that lack agent.json.
162
+ * 3. Empty {} as last resort.
163
+ *
164
+ * The "non-empty" detection treats `agent.template.inputSchema` as missing
165
+ * when both `required` and `properties` are empty/absent — same effective
166
+ * gap as having no schema at all.
167
+ */
168
+ function pickInputSchema(agent, oas) {
169
+ const compiled = agent?.template?.inputSchema;
170
+ const compiledHasContent =
171
+ compiled &&
172
+ typeof compiled === "object" &&
173
+ ((Array.isArray(compiled.required) && compiled.required.length > 0) ||
174
+ (compiled.properties && Object.keys(compiled.properties).length > 0));
175
+ if (compiledHasContent) return compiled;
176
+
177
+ const derived = deriveInputSchemaFromOas(oas);
178
+ if (derived) return derived;
179
+
180
+ return compiled && typeof compiled === "object" ? compiled : {};
181
+ }
182
+
183
+ function parseArgv(argv) {
184
+ const flags = { lockfileOnly: false, dryRun: false };
185
+ const rest = [];
186
+ for (let i = 0; i < argv.length; i++) {
187
+ const a = argv[i];
188
+ if (a === "--lockfile-only") flags.lockfileOnly = true;
189
+ else if (a === "--dry-run") flags.dryRun = true;
190
+ else if (a === "--manifest") flags.manifest = argv[++i];
191
+ else if (a === "--lockfile") flags.lockfile = argv[++i];
192
+ else if (a === "--registry-url") flags.registryUrl = argv[++i];
193
+ else if (a === "--registry-token") flags.registryToken = argv[++i];
194
+ else if (a.startsWith("--")) {
195
+ flags.__error = `Unknown flag: ${a}`;
196
+ break;
197
+ } else {
198
+ rest.push(a);
199
+ }
200
+ }
201
+ if (rest.length > 0) flags.rootSpec = rest[0];
202
+ return flags;
203
+ }
204
+
205
+ function parseSpec(spec) {
206
+ if (!spec || typeof spec !== "string") {
207
+ throw new Error(`Invalid package spec: must be a non-empty string`);
208
+ }
209
+ const at = spec.lastIndexOf("@");
210
+ if (at <= 0) return { name: spec, range: "*" };
211
+ const name = spec.slice(0, at);
212
+ const range = spec.slice(at + 1);
213
+ if (!name) throw new Error(`Invalid package spec: empty name in "${spec}"`);
214
+ if (!range) throw new Error(`Invalid package spec: empty version range after "@" in "${spec}"`);
215
+ return { name, range };
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Verdaccio config (env-only, no DB — safe for plain Node.js)
220
+ // ---------------------------------------------------------------------------
221
+
222
+ function loadVerdaccioConfig(overrides = {}) {
223
+ const registryUrl = (
224
+ overrides.registryUrl ??
225
+ process.env.CINATRA_AGENT_REGISTRY_URL ??
226
+ process.env.VERDACCIO_REGISTRY_URL ??
227
+ DEFAULT_REGISTRY_URL
228
+ ).replace(/\/+$/, "");
229
+ const token = (
230
+ overrides.token ??
231
+ process.env.CINATRA_AGENT_REGISTRY_TOKEN ??
232
+ process.env.VERDACCIO_TOKEN ??
233
+ null
234
+ );
235
+ return { registryUrl, token };
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Dependency resolver using pacote
240
+ // ---------------------------------------------------------------------------
241
+
242
+ /**
243
+ * Resolve a full agent dependency tree using pacote.manifest for each node.
244
+ * pacote handles semver range resolution internally via npm-pick-manifest.
245
+ * Returns { root: ResolvedNode, all: Map<name, ResolvedNode> }.
246
+ *
247
+ * `knownConnectorPackageIds` is the checkout-resolved connector catalog id set
248
+ * (injected by the caller) — previously a module-scope constant; now passed in
249
+ * so the thin CLI does not import `@cinatra-ai/connectors-catalog` at load time.
250
+ */
251
+ async function resolveAgentDependencyTree({ rootPackageName, rootRange, registryUrl, token, knownConnectorPackageIds }) {
252
+ const pacoteOpts = {
253
+ registry: registryUrl + "/",
254
+ preferOnline: true,
255
+ fullMetadata: true,
256
+ // Scoped key, NEVER a flat `token` — npm-registry-fetch ignores that (#179).
257
+ ...registryScopedAuthOptions(registryUrl, token),
258
+ };
259
+
260
+ const { default: pacote } = await import("pacote");
261
+
262
+ const resolved = new Map();
263
+ const queue = [{ name: rootPackageName, range: rootRange, path: [], depth: 0 }];
264
+ const MAX_NODES = 500;
265
+ const MAX_DEPTH = 20;
266
+
267
+ while (queue.length > 0) {
268
+ const entry = queue.shift();
269
+ const { name, range, path, depth } = entry;
270
+
271
+ if (!SCOPED_NAME_PATTERN.test(name)) {
272
+ throw Object.assign(
273
+ new Error(`agentDependencies entries must be valid scoped npm names; received: ${name}`),
274
+ { code: "ESCOPE" }
275
+ );
276
+ }
277
+
278
+ if (path.includes(name)) {
279
+ throw Object.assign(
280
+ new Error(`Dependency cycle detected: ${[...path, name].join(" -> ")}`),
281
+ { code: "ECYCLE", cyclePath: [...path, name] }
282
+ );
283
+ }
284
+
285
+ if (depth > MAX_DEPTH) {
286
+ throw Object.assign(
287
+ new Error(`Dependency resolver exceeded depth limit of ${MAX_DEPTH}`),
288
+ { code: "ELIMIT" }
289
+ );
290
+ }
291
+
292
+ if (resolved.has(name)) {
293
+ // Already resolved — verify the already-pinned version satisfies the incoming range.
294
+ const existing = resolved.get(name);
295
+ const { default: semver } = await import("semver");
296
+ if (!semver.satisfies(existing.resolvedVersion, range)) {
297
+ throw Object.assign(
298
+ new Error(
299
+ `Incompatible versions required for ${name}: already pinned at ${existing.resolvedVersion}, range ${range} not satisfied`
300
+ ),
301
+ { code: "ECONFLICT", packageName: name }
302
+ );
303
+ }
304
+ continue;
305
+ }
306
+
307
+ if (resolved.size >= MAX_NODES) {
308
+ throw Object.assign(
309
+ new Error(`Dependency resolver exceeded nodes limit of ${MAX_NODES}`),
310
+ { code: "ELIMIT" }
311
+ );
312
+ }
313
+
314
+ let m;
315
+ try {
316
+ m = await pacote.manifest(`${name}@${range}`, pacoteOpts);
317
+ } catch (err) {
318
+ const code = err?.code ?? "";
319
+ if (code === "E404" || code === "ETARGET") {
320
+ throw Object.assign(
321
+ new Error(`No version satisfying ${name}@${range}`),
322
+ { code: "ENORESOLUTION", packageName: name, range }
323
+ );
324
+ }
325
+ throw err;
326
+ }
327
+
328
+ const childDeps = m.cinatra?.agentDependencies ?? {};
329
+ // connectorDependencies are declarative-only: validated here against the
330
+ // known connector catalog but NEVER enqueued for tree walking. Connectors
331
+ // are workspace-compiled and never runtime-installed from npm. Unknown
332
+ // package ids fail fast.
333
+ const childConnectorDeps = m.cinatra?.connectorDependencies ?? {};
334
+ for (const connectorId of Object.keys(childConnectorDeps)) {
335
+ if (!knownConnectorPackageIds.has(connectorId)) {
336
+ throw Object.assign(
337
+ new Error(
338
+ `${name} declares connectorDependencies entry ${connectorId} which is not in the connector catalog`,
339
+ ),
340
+ { code: "EUNKNOWNCONNECTOR", packageName: name, connectorId },
341
+ );
342
+ }
343
+ }
344
+
345
+ resolved.set(name, {
346
+ packageName: name,
347
+ resolvedVersion: m.version,
348
+ tarballUrl: m.dist?.tarball ?? "",
349
+ integrity: m.dist?.integrity ?? "",
350
+ requestedRange: range,
351
+ dependencies: { ...childDeps },
352
+ connectorDependencies: { ...childConnectorDeps },
353
+ });
354
+
355
+ const nextPath = [...path, name];
356
+ for (const [depName, depRange] of Object.entries(childDeps)) {
357
+ queue.push({ name: depName, range: depRange, path: nextPath, depth: depth + 1 });
358
+ }
359
+ }
360
+
361
+ const root = resolved.get(rootPackageName);
362
+ if (!root) {
363
+ throw Object.assign(
364
+ new Error(`Root package ${rootPackageName} was not resolved`),
365
+ { code: "ENORESOLUTION" }
366
+ );
367
+ }
368
+
369
+ return { root, all: resolved };
370
+ }
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // Lockfile
374
+ // ---------------------------------------------------------------------------
375
+
376
+ function lockfileFromTree(tree) {
377
+ const packages = {};
378
+ const connectorPackageIds = new Set();
379
+ for (const [name, node] of tree.all) {
380
+ const entry = {
381
+ version: node.resolvedVersion,
382
+ resolved: node.tarballUrl,
383
+ integrity: node.integrity,
384
+ };
385
+ if (node.dependencies && Object.keys(node.dependencies).length > 0) {
386
+ entry.dependencies = { ...node.dependencies };
387
+ }
388
+ if (
389
+ node.connectorDependencies &&
390
+ Object.keys(node.connectorDependencies).length > 0
391
+ ) {
392
+ entry.connectorDependencies = { ...node.connectorDependencies };
393
+ for (const id of Object.keys(node.connectorDependencies)) {
394
+ connectorPackageIds.add(id);
395
+ }
396
+ }
397
+ packages[name] = entry;
398
+ }
399
+ return {
400
+ lockfileVersion: LOCKFILE_VERSION,
401
+ root: { packageName: tree.root.packageName, version: tree.root.resolvedVersion },
402
+ packages,
403
+ // Set of all connector packageIds the resolved tree touches, deduplicated
404
+ // for quick preflight readiness scans.
405
+ connectorPackageIds: [...connectorPackageIds].sort(),
406
+ };
407
+ }
408
+
409
+ function stableStringifyLockfile(lockfile) {
410
+ // Stable JSON: sort keys in packages object for byte-deterministic output.
411
+ const sortedPackages = {};
412
+ for (const key of Object.keys(lockfile.packages).sort()) {
413
+ sortedPackages[key] = lockfile.packages[key];
414
+ }
415
+ return JSON.stringify({ ...lockfile, packages: sortedPackages }, null, 2) + "\n";
416
+ }
417
+
418
+ async function readLockfile(lockfilePath) {
419
+ try {
420
+ const raw = await readFile(lockfilePath, "utf8");
421
+ return JSON.parse(raw);
422
+ } catch {
423
+ return null;
424
+ }
425
+ }
426
+
427
+ async function writeLockfile(lockfilePath, lockfile) {
428
+ await writeFile(lockfilePath, stableStringifyLockfile(lockfile), "utf8");
429
+ }
430
+
431
+ function lockfileToTree(lockfile) {
432
+ const all = new Map();
433
+ for (const [name, entry] of Object.entries(lockfile.packages)) {
434
+ all.set(name, {
435
+ packageName: name,
436
+ resolvedVersion: entry.version,
437
+ tarballUrl: entry.resolved,
438
+ integrity: entry.integrity,
439
+ requestedRange: entry.version,
440
+ dependencies: entry.dependencies ?? {},
441
+ connectorDependencies: entry.connectorDependencies ?? {},
442
+ });
443
+ }
444
+ const root = all.get(lockfile.root.packageName);
445
+ return { root, all };
446
+ }
447
+
448
+ // ---------------------------------------------------------------------------
449
+ // Install tree traversal (leaf-first BFS)
450
+ // ---------------------------------------------------------------------------
451
+
452
+ async function installResolvedTree({ tree, install }) {
453
+ // Build a dependency-ordered list: leaves first, root last.
454
+ const visited = new Set();
455
+ const ordered = [];
456
+
457
+ function visit(name) {
458
+ if (visited.has(name)) return;
459
+ visited.add(name);
460
+ const node = tree.all.get(name);
461
+ if (node) {
462
+ for (const depName of Object.keys(node.dependencies)) {
463
+ visit(depName);
464
+ }
465
+ ordered.push(node);
466
+ }
467
+ }
468
+
469
+ visit(tree.root.packageName);
470
+ for (const node of ordered) {
471
+ await install(node);
472
+ }
473
+ }
474
+
475
+ // ---------------------------------------------------------------------------
476
+ // Install single agent package — extract tarball + write to DB
477
+ // ---------------------------------------------------------------------------
478
+
479
+ /**
480
+ * Extract a package from Verdaccio and upsert agent_template + agent_version rows.
481
+ * Uses pg directly (no Drizzle) so it works in plain Node.js.
482
+ */
483
+ async function installAgentFromPackage({ packageName, packageVersion, registryUrl, token }) {
484
+ const { default: pacote } = await import("pacote");
485
+ const { default: pg } = await import("pg");
486
+
487
+ const dbUrl = process.env.SUPABASE_DB_URL;
488
+ if (!dbUrl) {
489
+ throw new Error(
490
+ "SUPABASE_DB_URL is required for the full install step. " +
491
+ "Use --dry-run or --lockfile-only to skip DB writes."
492
+ );
493
+ }
494
+
495
+ const pacoteOpts = {
496
+ registry: registryUrl + "/",
497
+ preferOnline: true,
498
+ // Scoped key, NEVER a flat `token` — npm-registry-fetch ignores that (#179).
499
+ ...registryScopedAuthOptions(registryUrl, token),
500
+ };
501
+
502
+ const spec = packageVersion ? `${packageName}@${packageVersion}` : packageName;
503
+ const tempDir = await mkdtemp(tmpdir() + "/cinatra-agent-install-");
504
+
505
+ try {
506
+ await pacote.extract(spec, tempDir, pacoteOpts);
507
+
508
+ const [pkgRaw, agentRaw, oasRaw] = await Promise.all([
509
+ readFile(tempDir + "/package.json", "utf8"),
510
+ readFile(tempDir + "/agent.json", "utf8").catch(() => null),
511
+ // Defense-in-depth. When agent.json is missing from the tarball, we fall
512
+ // back to deriving inputSchema directly from cinatra/oas.json. Without
513
+ // this, `inputSchema = {}` prevents the setup-loop fallback from
514
+ // surfacing required inputs.
515
+ readFile(tempDir + "/cinatra/oas.json", "utf8").catch(() => null),
516
+ ]);
517
+
518
+ const pkg = JSON.parse(pkgRaw);
519
+ const agent = agentRaw ? JSON.parse(agentRaw) : null;
520
+ const oas = oasRaw ? safeJsonParse(oasRaw) : null;
521
+
522
+ const cinatraMeta = pkg.cinatra ?? {};
523
+ const agentDeps = cinatraMeta.agentDependencies ?? {};
524
+ const agentType = cinatraMeta.type ?? "leaf";
525
+ const executionMode = agent?.template?.executionMode ?? cinatraMeta.executionMode ?? "agentic";
526
+ const templateName = agent?.title?.trim() || agent?.template?.name || pkg.name;
527
+ const description = agent?.description ?? agent?.template?.description ?? null;
528
+ const sourceNl = agent?.template?.sourceNl ?? "";
529
+ const compiledPlan = agent?.template?.compiledPlan ?? [];
530
+ // When agent.json supplies a non-empty inputSchema, use it. Otherwise
531
+ // derive from cinatra/oas.json's StartNode metadata so setup-loop fallback
532
+ // knows which inputs are required.
533
+ const inputSchema = pickInputSchema(agent, oas);
534
+ const outputSchema = agent?.template?.outputSchema ?? null;
535
+ const approvalPolicy = agent?.template?.approvalPolicy ?? { steps: [] };
536
+ const taskSpec = agent?.template?.taskSpec ?? null;
537
+ const lgGraphCode = agent?.template?.lgGraphCode ?? null;
538
+ const lgGraphId = agent?.template?.lgGraphId ?? null;
539
+ const executionProvider = agent?.template?.executionProvider ?? cinatraMeta.executionProvider ?? "default";
540
+ const snapshot = agent?.version?.snapshot ?? {};
541
+ const sourceVersionId = agent?.version?.sourceVersionId ?? cinatraMeta.sourceVersionId ?? null;
542
+ const sourceVersionNumber = agent?.version?.sourceVersionNumber ?? cinatraMeta.sourceVersionNumber ?? 1;
543
+
544
+ const schema = process.env.SUPABASE_SCHEMA ?? "cinatra";
545
+ const client = new pg.Client({ connectionString: dbUrl });
546
+ await client.connect();
547
+
548
+ try {
549
+ // Check for existing template by packageName
550
+ const existingResult = await client.query(
551
+ `SELECT id FROM ${schema}.agent_templates WHERE package_name = $1 LIMIT 1`,
552
+ [packageName]
553
+ );
554
+
555
+ let templateId;
556
+ const versionId = randomUUID();
557
+
558
+ if (existingResult.rows.length > 0) {
559
+ // Update existing template
560
+ templateId = existingResult.rows[0].id;
561
+ await client.query(
562
+ `UPDATE ${schema}.agent_templates SET
563
+ name = $2, description = $3, source_nl = $4, compiled_plan = $5,
564
+ input_schema = $6, output_schema = $7, approval_policy = $8,
565
+ type = $9, task_spec = $10,
566
+ package_version = $11, agent_dependencies = $12,
567
+ lg_graph_code = $13, lg_graph_id = $14, execution_provider = $15,
568
+ updated_at = NOW()
569
+ WHERE id = $1`,
570
+ [
571
+ templateId,
572
+ templateName,
573
+ description,
574
+ sourceNl,
575
+ JSON.stringify(compiledPlan),
576
+ JSON.stringify(inputSchema),
577
+ outputSchema ? JSON.stringify(outputSchema) : null,
578
+ JSON.stringify(approvalPolicy),
579
+ agentType,
580
+ taskSpec,
581
+ pkg.version,
582
+ Object.keys(agentDeps).length > 0 ? JSON.stringify(agentDeps) : null,
583
+ lgGraphCode,
584
+ lgGraphId,
585
+ executionProvider,
586
+ ]
587
+ );
588
+ } else {
589
+ // Insert new template
590
+ templateId = randomUUID();
591
+ await client.query(
592
+ `INSERT INTO ${schema}.agent_templates (
593
+ id, name, description, source_nl, compiled_plan, input_schema,
594
+ output_schema, approval_policy, type, task_spec,
595
+ package_name, package_version, agent_dependencies,
596
+ lg_graph_code, lg_graph_id, execution_provider,
597
+ hitl_required, status, created_at, updated_at
598
+ ) VALUES (
599
+ $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,
600
+ false, 'draft', NOW(), NOW()
601
+ )`,
602
+ [
603
+ templateId,
604
+ templateName,
605
+ description,
606
+ sourceNl,
607
+ JSON.stringify(compiledPlan),
608
+ JSON.stringify(inputSchema),
609
+ outputSchema ? JSON.stringify(outputSchema) : null,
610
+ JSON.stringify(approvalPolicy),
611
+ agentType,
612
+ taskSpec,
613
+ packageName,
614
+ pkg.version,
615
+ Object.keys(agentDeps).length > 0 ? JSON.stringify(agentDeps) : null,
616
+ lgGraphCode,
617
+ lgGraphId,
618
+ executionProvider,
619
+ ]
620
+ );
621
+ }
622
+
623
+ // Insert version row
624
+ const contentHash = createHash("sha256")
625
+ .update(JSON.stringify(snapshot))
626
+ .digest("hex");
627
+
628
+ // Determine next version number
629
+ const versionNumResult = await client.query(
630
+ `SELECT COALESCE(MAX(version_number), 0) + 1 AS next_num
631
+ FROM ${schema}.agent_versions WHERE template_id = $1`,
632
+ [templateId]
633
+ );
634
+ const versionNumber = versionNumResult.rows[0].next_num;
635
+
636
+ await client.query(
637
+ `INSERT INTO ${schema}.agent_versions (
638
+ id, template_id, version_number, content_hash, snapshot, created_at
639
+ ) VALUES ($1, $2, $3, $4, $5, NOW())`,
640
+ [versionId, templateId, versionNumber, contentHash, JSON.stringify(snapshot)]
641
+ );
642
+
643
+ return { templateId, versionId, packageName, packageVersion: pkg.version, agentDependencies: agentDeps };
644
+ } finally {
645
+ await client.end();
646
+ }
647
+ } finally {
648
+ await rm(tempDir, { recursive: true, force: true });
649
+ }
650
+ }
651
+
652
+ // ---------------------------------------------------------------------------
653
+ // Main entrypoint
654
+ // ---------------------------------------------------------------------------
655
+
656
+ export async function runAgentsInstall(argv, io = {}) {
657
+ const stdout = io.stdout ?? process.stdout;
658
+ const stderr = io.stderr ?? process.stderr;
659
+ const exit = io.exit ?? ((c) => process.exit(c));
660
+ // The CLI dispatch passes `io.repoRoot` (the cinatra checkout) so the
661
+ // connector catalog can be resolved from the checkout. It is only consulted
662
+ // when a fresh tree resolution runs (not on usage-error early exits nor on a
663
+ // lockfile fast-path hit), so tests that drive only those paths need not set
664
+ // it.
665
+ const repoRoot = io.repoRoot;
666
+
667
+ const flags = parseArgv(argv);
668
+ if (flags.__error) {
669
+ stderr.write(flags.__error + "\n" + USAGE);
670
+ return exit(1);
671
+ }
672
+ if (!flags.rootSpec && !flags.manifest) {
673
+ stderr.write(USAGE);
674
+ return exit(1);
675
+ }
676
+
677
+ let rootName;
678
+ let rootRange;
679
+ try {
680
+ if (flags.rootSpec) {
681
+ ({ name: rootName, range: rootRange } = parseSpec(flags.rootSpec));
682
+ } else {
683
+ const manifestRaw = await readFile(resolvePath(flags.manifest), "utf8");
684
+ const manifest = JSON.parse(manifestRaw);
685
+ rootName = manifest.name;
686
+ rootRange = manifest.version ?? "*";
687
+ }
688
+ } catch (err) {
689
+ stderr.write(`Usage error: ${err.message}\n${USAGE}`);
690
+ return exit(1);
691
+ }
692
+
693
+ // Verdaccio config precedence: flags → env → defaults
694
+ const cfg = loadVerdaccioConfig({
695
+ registryUrl: flags.registryUrl,
696
+ token: flags.registryToken,
697
+ });
698
+ const effectiveRegistry = cfg.registryUrl;
699
+ const effectiveToken = cfg.token;
700
+
701
+ const lockfilePath = resolvePath(flags.lockfile ?? "./cinatra-agents.lock");
702
+
703
+ // Lockfile fast-path: reuse only when lockfile pins the requested root at a version
704
+ // that satisfies the requested range AND the lockfile version matches the current
705
+ // LOCKFILE_VERSION. Otherwise re-resolve to pick up upgrades or v1→v2 schema fills.
706
+ const existingLockfile = await readLockfile(lockfilePath);
707
+ const { default: semver } = await import("semver");
708
+ let tree;
709
+ if (
710
+ existingLockfile &&
711
+ existingLockfile.lockfileVersion === LOCKFILE_VERSION &&
712
+ existingLockfile.root.packageName === rootName &&
713
+ semver.satisfies(existingLockfile.root.version, rootRange)
714
+ ) {
715
+ tree = lockfileToTree(existingLockfile);
716
+ } else {
717
+ // Resolve the connector catalog from the checkout ONLY when a fresh tree
718
+ // resolution is actually needed (the validator consults it per node).
719
+ let knownConnectorPackageIds;
720
+ try {
721
+ if (!repoRoot) {
722
+ throw new Error(
723
+ "agents install: no cinatra checkout root available to resolve the connector catalog " +
724
+ "(@cinatra-ai/connectors-catalog). Run from inside a cinatra checkout.",
725
+ );
726
+ }
727
+ knownConnectorPackageIds = await loadKnownConnectorPackageIds(repoRoot);
728
+ } catch (err) {
729
+ stderr.write(`Config error: ${err?.message ?? String(err)}\n`);
730
+ return exit(4);
731
+ }
732
+ try {
733
+ tree = await resolveAgentDependencyTree({
734
+ rootPackageName: rootName,
735
+ rootRange: rootRange,
736
+ registryUrl: effectiveRegistry,
737
+ token: effectiveToken,
738
+ knownConnectorPackageIds,
739
+ });
740
+ } catch (err) {
741
+ const msg = redactToken(err?.message ?? String(err), effectiveToken);
742
+ stderr.write(`Resolver error: ${msg}\n`);
743
+ if (err?.cyclePath) stderr.write(`Cycle: ${err.cyclePath.join(" -> ")}\n`);
744
+ return exit(2);
745
+ }
746
+ }
747
+
748
+ if (flags.dryRun) {
749
+ stdout.write(
750
+ JSON.stringify({ root: tree.root, nodes: [...tree.all.keys()] }, null, 2) + "\n"
751
+ );
752
+ return exit(0);
753
+ }
754
+
755
+ // Write lockfile
756
+ const lockfile = lockfileFromTree(tree);
757
+ await writeLockfile(lockfilePath, lockfile);
758
+
759
+ if (flags.lockfileOnly) return exit(0);
760
+
761
+ // Install side-effects — upsert agent_templates + agent_versions rows in DB
762
+ const install = async (node) => {
763
+ await installAgentFromPackage({
764
+ packageName: node.packageName,
765
+ packageVersion: node.resolvedVersion,
766
+ registryUrl: effectiveRegistry,
767
+ token: effectiveToken,
768
+ });
769
+ };
770
+
771
+ try {
772
+ await installResolvedTree({ tree, install });
773
+ } catch (err) {
774
+ const msg = redactToken(err?.message ?? String(err), effectiveToken);
775
+ stderr.write(`Install error: ${msg}\n`);
776
+ if (err?.code === "EINTEGRITY") {
777
+ stderr.write(`Integrity mismatch: tarball sha512 does not match lockfile\n`);
778
+ return exit(3);
779
+ }
780
+ return exit(1);
781
+ }
782
+
783
+ stdout.write(`Installed ${tree.all.size} agents from ${rootName}@${rootRange}\n`);
784
+ return exit(0);
785
+ }
786
+
787
+ export const __test = {
788
+ parseArgv,
789
+ parseSpec,
790
+ redactToken,
791
+ // Exported so the #179 regression (flat pacote `token` option, ignored by
792
+ // npm-registry-fetch) stays pinned at the CLI layer too.
793
+ registryScopedAuthOptions,
794
+ lockfileToTree,
795
+ lockfileFromTree,
796
+ stableStringifyLockfile,
797
+ // Exported for unit test coverage of the inputSchema derivation fallback.
798
+ deriveInputSchemaFromOas,
799
+ pickInputSchema,
800
+ safeJsonParse,
801
+ };