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.
- package/LICENSE +202 -0
- package/README.md +77 -0
- package/bin/cinatra.mjs +8 -0
- package/package.json +32 -0
- package/src/agents-install.mjs +801 -0
- package/src/checkout-resolve.mjs +236 -0
- package/src/cinatra-dev-extensions.mjs +338 -0
- package/src/clone-registry.mjs +623 -0
- package/src/clone-runtime.mjs +543 -0
- package/src/command-table.mjs +390 -0
- package/src/dev-apps.mjs +79 -0
- package/src/dev-cli-modules.mjs +91 -0
- package/src/dev-refresh.mjs +117 -0
- package/src/dev-repo-sync.mjs +297 -0
- package/src/extensions-dependency-gate.mjs +258 -0
- package/src/extensions-submit.mjs +137 -0
- package/src/index.mjs +9203 -0
- package/src/install.mjs +815 -0
- package/src/login.mjs +508 -0
- package/src/marketplace-mcp.mjs +100 -0
- package/src/mcp-public-base-url-shape.mjs +134 -0
- package/src/prod-extension-acquisition.mjs +679 -0
- package/src/seed-local-registry.mjs +538 -0
- package/src/tailscale-provision.mjs +219 -0
- package/src/teardown-config.mjs +113 -0
- package/src/worktree-collision-guard.mjs +157 -0
|
@@ -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
|
+
};
|