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,236 @@
1
+ // packages/cli/src/checkout-resolve.mjs (published as `cinatra/src/checkout-resolve.mjs`)
2
+ //
3
+ // Runtime resolution of the internal `@cinatra-ai/*` workspace packages from the
4
+ // operator's CINATRA CHECKOUT — never from the published `cinatra` CLI's own
5
+ // dependency tree.
6
+ //
7
+ // WHY THIS EXISTS (cinatra#402, P1 extraction)
8
+ // --------------------------------------------
9
+ // The `cinatra` CLI is a THIN, dependency-light driver published to npm. It is
10
+ // deliberately decoupled from the heavy in-repo workspace packages it drives:
11
+ //
12
+ // - `@cinatra-ai/migrations` — the node-pg-migrate runner + SQL chain
13
+ // - `@cinatra-ai/connectors-catalog` — the CLI-safe connector descriptors
14
+ // - `@cinatra-ai/skills` — the agent-skill compile/register walker
15
+ //
16
+ // Bundling any of these into the published tarball would (a) re-couple the CLI
17
+ // to the monorepo's heavy server graph and (b) ship a STALE copy of code that
18
+ // must always match the checkout it operates on. So the CLI resolves them at
19
+ // COMMAND ENTRY from the checkout's own `node_modules`, against the checkout's
20
+ // installed versions.
21
+ //
22
+ // CODEX must-fix #2 — DO NOT file://-path-only resolve
23
+ // ----------------------------------------------------
24
+ // A naive `import(pathToFileURL(<repoRoot>/packages/<pkg>/...))` is wrong:
25
+ // in the BAKED production runtime image only `packages/migrations` physical
26
+ // source is guaranteed (Next.js output-file-tracing copies it explicitly; the
27
+ // other workspace packages may be absent there). So the primary path is a
28
+ // node-module resolution anchored at the checkout root (which honors the
29
+ // package's `exports`/subpaths and finds the real installed copy), with a
30
+ // guaranteed-source self-resolution fallback used ONLY for migrations.
31
+ //
32
+ // SECURITY / CORRECTNESS (per codex review of this shim)
33
+ // ------------------------------------------------------
34
+ // - Only `@cinatra-ai/*` specifiers are accepted (no builtins, no escape).
35
+ // - Resolution is anchored at an ABSOLUTE `<repoRoot>/package.json`.
36
+ // - The resolved file is realpath-contained within the checkout (realpath +
37
+ // `path.relative`, NOT brittle `startsWith`; pnpm symlinks realpath to
38
+ // `<repoRoot>/packages/*` and `<repoRoot>/node_modules/.pnpm/*`, both inside
39
+ // the checkout).
40
+ // - The OWNING package's `package.json#name` is verified to equal the
41
+ // expected `@cinatra-ai/*` package (defense against a spoofed resolution).
42
+ // - The expected package name is REQUIRED (derived from the specifier).
43
+
44
+ import { createRequire } from "node:module";
45
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
46
+ import path from "node:path";
47
+ import { pathToFileURL } from "node:url";
48
+
49
+ const CINATRA_SCOPE = "@cinatra-ai/";
50
+
51
+ /**
52
+ * `@cinatra-ai/migrations` → `@cinatra-ai/migrations`
53
+ * `@cinatra-ai/skills/cli` → `@cinatra-ai/skills`
54
+ * `@cinatra-ai/connectors-catalog/descriptors.mjs` → `@cinatra-ai/connectors-catalog`
55
+ */
56
+ function packageNameFromSpecifier(specifier) {
57
+ if (typeof specifier !== "string" || !specifier.startsWith(CINATRA_SCOPE)) {
58
+ throw new Error(
59
+ `checkout-resolve: refusing to resolve non-@cinatra-ai specifier "${specifier}".`,
60
+ );
61
+ }
62
+ const parts = specifier.split("/");
63
+ // ["@cinatra-ai", "<pkg>", ...subpath]
64
+ return `${parts[0]}/${parts[1]}`;
65
+ }
66
+
67
+ /**
68
+ * The cinatra checkout sentinel — the pnpm workspace manifest AND the
69
+ * never-removed internal `@cinatra-ai/migrations` package manifest (by exact
70
+ * name). Mirrors `isCinatraRepoRoot` / `isCinatraCheckout` in index.mjs /
71
+ * install.mjs; deliberately does NOT gate on `packages/cli` (that package goes
72
+ * external at P1/P2) nor on the bin-colliding root name `cinatra`.
73
+ */
74
+ function assertCinatraCheckout(repoRoot) {
75
+ const ws = path.join(repoRoot, "pnpm-workspace.yaml");
76
+ const migPkg = path.join(repoRoot, "packages", "migrations", "package.json");
77
+ if (!existsSync(ws)) {
78
+ throw new Error(
79
+ `checkout-resolve: "${repoRoot}" is not a cinatra checkout (missing pnpm-workspace.yaml).`,
80
+ );
81
+ }
82
+ let migName;
83
+ try {
84
+ migName = JSON.parse(readFileSync(migPkg, "utf8"))?.name;
85
+ } catch {
86
+ migName = undefined;
87
+ }
88
+ if (migName !== "@cinatra-ai/migrations") {
89
+ throw new Error(
90
+ `checkout-resolve: "${repoRoot}" is not a cinatra checkout ` +
91
+ `(packages/migrations/package.json missing or not @cinatra-ai/migrations).`,
92
+ );
93
+ }
94
+ }
95
+
96
+ /** Best-effort native realpath; falls back to the resolved absolute path. */
97
+ function realpath(p) {
98
+ try {
99
+ return realpathSync.native(path.resolve(p));
100
+ } catch {
101
+ return path.resolve(p);
102
+ }
103
+ }
104
+
105
+ /** True iff `child` is `root` or a descendant of `root` (realpath-aware). */
106
+ function isContained(root, child) {
107
+ const rel = path.relative(realpath(root), realpath(child));
108
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
109
+ }
110
+
111
+ /**
112
+ * Walk up from `fromFile` to find the owning `package.json` and assert its
113
+ * `name` equals `expectName`. Bounded at the checkout root.
114
+ */
115
+ function assertOwningPackageName(fromFile, expectName, repoRoot) {
116
+ const rootReal = realpath(repoRoot);
117
+ let dir = path.dirname(path.resolve(fromFile));
118
+ for (;;) {
119
+ const pkgPath = path.join(dir, "package.json");
120
+ if (existsSync(pkgPath)) {
121
+ let name;
122
+ try {
123
+ name = JSON.parse(readFileSync(pkgPath, "utf8"))?.name;
124
+ } catch {
125
+ name = undefined;
126
+ }
127
+ if (name === expectName) return;
128
+ // A nested package.json without the expected name — keep walking up only
129
+ // while still inside the checkout; a node_modules entry's own
130
+ // package.json is the authoritative owner, so a mismatch here is fatal.
131
+ throw new Error(
132
+ `checkout-resolve: resolved "${expectName}" to a file owned by ` +
133
+ `package "${name ?? "<unknown>"}" (${pkgPath}); refusing to import.`,
134
+ );
135
+ }
136
+ const parent = path.dirname(dir);
137
+ // Stop at the checkout root or the filesystem root.
138
+ if (parent === dir || realpath(dir) === rootReal) {
139
+ throw new Error(
140
+ `checkout-resolve: could not find the owning package.json for "${expectName}".`,
141
+ );
142
+ }
143
+ dir = parent;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Resolve a checkout-local `@cinatra-ai/*` specifier to an absolute file path,
149
+ * validating containment + owning-package name. Returns the absolute path.
150
+ *
151
+ * Primary: `createRequire(<repoRoot>/package.json).resolve(specifier)` — honors
152
+ * the package's `exports` subpaths (e.g. `@cinatra-ai/skills/cli`) and finds the
153
+ * real installed copy in the checkout's node_modules.
154
+ *
155
+ * Fallback (migrations ONLY, the guaranteed-source package): self-resolve from
156
+ * inside `<repoRoot>/packages/migrations` so a baked prod image whose top-level
157
+ * node_modules lacks the workspace symlink still resolves.
158
+ */
159
+ function resolveCheckoutFile(repoRoot, specifier) {
160
+ const expectName = packageNameFromSpecifier(specifier);
161
+ const rootAbs = path.resolve(repoRoot);
162
+
163
+ // A resolved path is USABLE only if it is absolute, realpath-contained within
164
+ // the checkout, and owned by the expected `@cinatra-ai/*` package. Returns
165
+ // null (NOT throw) for an unusable candidate so the migrations fallback can
166
+ // still run when the PRIMARY resolution points OUTSIDE the checkout (e.g. the
167
+ // published CLI is installed inside an ambient parent's node_modules and
168
+ // require.resolve found that copy) — codex must-fix: an outside/mismatched
169
+ // primary must not pre-empt the guaranteed-source fallback.
170
+ const validate = (resolved) => {
171
+ if (!resolved || !path.isAbsolute(resolved)) return null;
172
+ if (!isContained(rootAbs, resolved)) return null;
173
+ try {
174
+ assertOwningPackageName(resolved, expectName, rootAbs);
175
+ } catch {
176
+ return null;
177
+ }
178
+ return resolved;
179
+ };
180
+
181
+ const tryResolveFrom = (anchorPkgJson) => {
182
+ try {
183
+ return createRequire(anchorPkgJson).resolve(specifier);
184
+ } catch {
185
+ return null;
186
+ }
187
+ };
188
+
189
+ // Primary: resolve from the checkout root (honors `exports` subpaths and finds
190
+ // the installed copy in the checkout's node_modules).
191
+ let usable = validate(tryResolveFrom(path.join(rootAbs, "package.json")));
192
+
193
+ // Fallback for migrations ONLY (the package guaranteed present in the baked
194
+ // prod image): self-resolve from inside `<repoRoot>/packages/migrations` so a
195
+ // missing top-level workspace symlink — OR an ambient-parent primary that
196
+ // failed containment above — still resolves to the in-checkout source.
197
+ if (!usable && expectName === "@cinatra-ai/migrations") {
198
+ const pkgJson = path.join(rootAbs, "packages", "migrations", "package.json");
199
+ if (existsSync(pkgJson)) {
200
+ usable = validate(tryResolveFrom(pkgJson));
201
+ }
202
+ }
203
+
204
+ if (!usable) {
205
+ throw new Error(
206
+ `checkout-resolve: cannot resolve "${specifier}" to an in-checkout copy of ` +
207
+ `${expectName} from the cinatra checkout at ${rootAbs} (not installed, or it ` +
208
+ `resolved OUTSIDE the checkout). Run the install step (pnpm install) inside ` +
209
+ `the checkout so its workspace packages are present.`,
210
+ );
211
+ }
212
+ return usable;
213
+ }
214
+
215
+ /**
216
+ * Resolve AND dynamically import a checkout-local `@cinatra-ai/*` module.
217
+ *
218
+ * @param {string} repoRoot Absolute path to the cinatra checkout root.
219
+ * @param {string} specifier e.g. "@cinatra-ai/migrations",
220
+ * "@cinatra-ai/skills/cli",
221
+ * "@cinatra-ai/connectors-catalog/descriptors.mjs".
222
+ * @returns {Promise<object>} The imported module namespace.
223
+ */
224
+ export async function importFromCheckout(repoRoot, specifier) {
225
+ assertCinatraCheckout(repoRoot);
226
+ const resolved = resolveCheckoutFile(repoRoot, specifier);
227
+ return import(pathToFileURL(resolved).href);
228
+ }
229
+
230
+ // Exposed for unit tests.
231
+ export const __test = {
232
+ packageNameFromSpecifier,
233
+ isContained,
234
+ resolveCheckoutFile,
235
+ assertCinatraCheckout,
236
+ };
@@ -0,0 +1,338 @@
1
+ import path from "node:path";
2
+ import { readFileSync } from "node:fs";
3
+ import { defaultRepoSyncDeps, normalizeGitHubRemote, syncOneRepo } from "./dev-repo-sync.mjs";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // `cinatra setup` dev-extension clone bootstrap.
7
+ //
8
+ // Dev consumes each extension from a git checkout under
9
+ // `extensions/<scope>/<name>/` (git-ignored after the cutover). This module
10
+ // clones-or-fast-forwards each entry of `package.json` `cinatra.devExtensions`
11
+ // (the `cinatra.devExtensions` map, placed under the `cinatra` key for
12
+ // consistency with `devApps`/`extensions`) into its slot.
13
+ //
14
+ // It REUSES the proven five-state tree-safety model from
15
+ // `dev-repo-sync.mjs` (`syncOneRepo`): absent/empty → clone; clean
16
+ // + correct origin/branch → fetch + ff-only (force: hard reset); dirty → skip
17
+ // unless force; wrong origin/branch → hard fail even with force; non-empty
18
+ // non-git → hard fail.
19
+ //
20
+ // `--pinned` (CI; cinatra#141) swaps tip-tracking for detached checkouts at
21
+ // the shas committed in the two lock files (see `loadDevExtensionPins`), so a
22
+ // companion-repo merge can never change what a host CI run validates. Local
23
+ // `cinatra setup` keeps tip-tracking — devs want tips.
24
+ //
25
+ // `syncCinatraDevExtensions` returns `{ skipped: true, reason: "no-config" }` on
26
+ // an empty map, or `{ results: [...] }` with one entry per selected extension
27
+ // (`{ pkgName, action, kind, dest }`). A caller that materializes new checkouts
28
+ // (a fresh clone or a `--force` reset) MUST re-run `pnpm install` afterward so the
29
+ // newly-present extension packages are linked into the pnpm workspace — pnpm only
30
+ // creates an extension's per-extension `node_modules` (and links its transitive
31
+ // deps) when the package exists on disk at install time, so a package cloned in
32
+ // AFTER the initial install stays unlinked until the next install. See
33
+ // `installAfterExtensionSync` in index.mjs (the `setup dev` / `setup clone` flows).
34
+ // ---------------------------------------------------------------------------
35
+
36
+ const KIND_SUFFIXES = [
37
+ ["-agent", "agent"],
38
+ ["-connector", "connector"],
39
+ ["-artifact", "artifact"],
40
+ ["-skills", "skill"],
41
+ ["-skill", "skill"],
42
+ ["-workflow", "workflow"],
43
+ ];
44
+
45
+ /** Derive the extension kind WITHOUT cloning — from a declared `kind` or the
46
+ * package-name suffix (best-effort; null when unknown). `--kind` filtering must
47
+ * not require a not-yet-cloned package.json. */
48
+ export function deriveKindFromName(pkgName, declaredKind) {
49
+ if (declaredKind) return declaredKind;
50
+ const short = String(pkgName).replace(/^@[^/]+\//, "");
51
+ for (const [suffix, kind] of KIND_SUFFIXES) if (short.endsWith(suffix)) return kind;
52
+ return null;
53
+ }
54
+
55
+ // A real scoped npm name: @scope/name with conservative segment chars — NO "/"
56
+ // inside the name segment, no "..", no leading dot. Blocks path-traversal config
57
+ // keys like `@cinatra-ai/../../outside` from escaping the extensions tree.
58
+ const SAFE_SCOPED_PKG_RE = /^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/;
59
+
60
+ /** Throw unless `dest` resolves to a path strictly INSIDE `rootDir`. */
61
+ function assertContainedIn(rootDir, dest, pkgName, label) {
62
+ const rel = path.relative(path.resolve(rootDir), dest);
63
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) {
64
+ throw new Error(
65
+ `[cinatra dev-extensions] refusing a destination outside ${label}: "${pkgName}" → "${dest}".`,
66
+ );
67
+ }
68
+ return dest;
69
+ }
70
+
71
+ /** `@scope/name` → `<targetRoot>/extensions/<scope>/<name>` (or spec.path).
72
+ * Hardened: an explicit `spec.path` may live anywhere UNDER the repo/worktree
73
+ * root but never escape it (blocks absolute paths + `../` traversal); a derived
74
+ * path requires a valid scoped package name + is contained to `extensions/`. */
75
+ export function destDirForExtension(pkgName, spec, targetRoot) {
76
+ if (spec?.path) {
77
+ const dest = path.resolve(targetRoot, spec.path);
78
+ return assertContainedIn(targetRoot, dest, pkgName, "the repo root");
79
+ }
80
+ if (!SAFE_SCOPED_PKG_RE.test(String(pkgName))) {
81
+ throw new Error(
82
+ `[cinatra dev-extensions] invalid extension package name "${pkgName}" — expected @scope/name ` +
83
+ `(lowercase; no "/" in the name segment, no "..").`,
84
+ );
85
+ }
86
+ const m = String(pkgName).match(/^@([^/]+)\/(.+)$/);
87
+ const dest = path.resolve(targetRoot, "extensions", m[1], m[2]);
88
+ return assertContainedIn(path.join(targetRoot, "extensions"), dest, pkgName, "extensions/");
89
+ }
90
+
91
+ export function readDevExtensionsConfig(repoRoot, readFile = readFileSync) {
92
+ try {
93
+ const pkg = JSON.parse(readFile(path.join(repoRoot, "package.json"), "utf8"));
94
+ // The dev-extension clone set lives under `cinatra.devExtensions`
95
+ // (consistent with `cinatra.devApps`).
96
+ const cfg = pkg?.cinatra?.devExtensions;
97
+ return cfg && typeof cfg === "object" ? cfg : null;
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ const shortName = (pkgName) => String(pkgName).replace(/^@[^/]+\//, "");
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Pinned mode (cinatra#141): CI checks out every dev extension DETACHED at a
107
+ // committed lock sha instead of tracking branch tips, so a companion-repo
108
+ // merge can never change what a host CI run validates.
109
+ //
110
+ // The pin set is PARTITIONED across two committed locks, with no overlap:
111
+ // - cinatra-required-extensions.lock.json — the prod bootable set (also the
112
+ // image build's acquisition source; it stays the SINGLE authority for
113
+ // those packages — never duplicated into the dev lock);
114
+ // - cinatra-dev-extensions.lock.json — every OTHER cinatra.devExtensions
115
+ // entry (regenerated by scripts/extensions/update-dev-extension-lock.mjs).
116
+ // ---------------------------------------------------------------------------
117
+
118
+ // Kept in lockstep with prod-extension-acquisition.mjs `LOCK_FILENAME` (not
119
+ // imported from there: that module imports `destDirForExtension` from THIS
120
+ // one, and the consistency test asserts the strings stay equal).
121
+ export const REQUIRED_EXTENSIONS_LOCK_FILENAME = "cinatra-required-extensions.lock.json";
122
+ export const DEV_EXTENSIONS_LOCK_FILENAME = "cinatra-dev-extensions.lock.json";
123
+
124
+ const PIN_SHA_RE = /^[0-9a-f]{40}$/;
125
+ const REPO_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
126
+
127
+ function readPinLock(repoRoot, filename, readFile, { allowEmpty = false } = {}) {
128
+ let raw;
129
+ try {
130
+ raw = readFile(path.join(repoRoot, filename), "utf8");
131
+ } catch {
132
+ throw new Error(
133
+ `[cinatra dev-extensions] pinned sync requires the committed ${filename}, which could not be read. ` +
134
+ `Regenerate it (scripts/extensions/update-${filename.includes("-dev-") ? "dev" : "required"}-extension-lock.mjs) and commit it.`,
135
+ );
136
+ }
137
+ let doc;
138
+ try {
139
+ doc = JSON.parse(raw);
140
+ } catch (err) {
141
+ throw new Error(`[cinatra dev-extensions] ${filename} is not valid JSON: ${err.message}`);
142
+ }
143
+ const packages = Array.isArray(doc?.packages) ? doc.packages : null;
144
+ if (!packages || (packages.length === 0 && !allowEmpty)) {
145
+ throw new Error(`[cinatra dev-extensions] ${filename} has no "packages" entries — refusing pinned sync.`);
146
+ }
147
+ const seen = new Set();
148
+ for (const [i, p] of packages.entries()) {
149
+ const tag = `${filename} packages[${i}]`;
150
+ if (!p || typeof p !== "object" || typeof p.packageName !== "string") {
151
+ throw new Error(`[cinatra dev-extensions] ${tag}: not an object with a packageName.`);
152
+ }
153
+ if (typeof p.resolvedSha !== "string" || !PIN_SHA_RE.test(p.resolvedSha)) {
154
+ throw new Error(`[cinatra dev-extensions] ${tag} (${p.packageName}): resolvedSha must be a 40-hex lowercase commit sha.`);
155
+ }
156
+ if (typeof p.repo !== "string" || !REPO_SLUG_RE.test(p.repo) || p.repo.includes("..")) {
157
+ throw new Error(`[cinatra dev-extensions] ${tag} (${p.packageName}): repo must be an "owner/name" GitHub slug.`);
158
+ }
159
+ // Duplicates WITHIN one lock would make the later merge order-dependent
160
+ // (last entry silently wins) — refuse them here, fail-closed.
161
+ if (seen.has(p.packageName)) {
162
+ throw new Error(`[cinatra dev-extensions] ${tag}: duplicate pin for "${p.packageName}" in ${filename}.`);
163
+ }
164
+ seen.add(p.packageName);
165
+ }
166
+ return packages;
167
+ }
168
+
169
+ const configUrlOf = (spec) => (spec && typeof spec === "object" ? spec.url : String(spec));
170
+
171
+ /**
172
+ * Build the fail-closed pkgName -> { sha, repo, source } pin map for pinned
173
+ * sync. Throws (never degrades to tip-tracking) when:
174
+ * - either lock is missing/malformed;
175
+ * - a package is pinned in BOTH locks (two authorities = divergence risk);
176
+ * - a dev-lock entry is not in `cinatra.devExtensions` (stale pin);
177
+ * - a config entry has no pin in either lock (unpinnable universe);
178
+ * - a lock `repo` slug contradicts the COMMITTED config URL (a
179
+ * CINATRA_*_REPO_URL env override is deliberately NOT consulted here — an
180
+ * override is only an alternate remote that must still serve the pin).
181
+ */
182
+ export function loadDevExtensionPins(repoRoot, readFile = readFileSync) {
183
+ const config = readDevExtensionsConfig(repoRoot, readFile);
184
+ if (!config || Object.keys(config).length === 0) {
185
+ throw new Error("[cinatra dev-extensions] pinned sync requires a non-empty `cinatra.devExtensions` config.");
186
+ }
187
+ const required = readPinLock(repoRoot, REQUIRED_EXTENSIONS_LOCK_FILENAME, readFile);
188
+ // An empty dev lock is legal IFF the required lock covers the whole
189
+ // universe (the per-entry completeness check below still enforces that).
190
+ const dev = readPinLock(repoRoot, DEV_EXTENSIONS_LOCK_FILENAME, readFile, { allowEmpty: true });
191
+
192
+ const pins = new Map();
193
+ for (const p of required) {
194
+ pins.set(p.packageName, { sha: p.resolvedSha, repo: p.repo, source: REQUIRED_EXTENSIONS_LOCK_FILENAME });
195
+ }
196
+ for (const p of dev) {
197
+ if (pins.has(p.packageName)) {
198
+ throw new Error(
199
+ `[cinatra dev-extensions] "${p.packageName}" is pinned in BOTH locks — the required lock is the sole ` +
200
+ `authority for its packages; remove the duplicate from ${DEV_EXTENSIONS_LOCK_FILENAME} ` +
201
+ `(regenerate it via scripts/extensions/update-dev-extension-lock.mjs).`,
202
+ );
203
+ }
204
+ if (!(p.packageName in config)) {
205
+ throw new Error(
206
+ `[cinatra dev-extensions] ${DEV_EXTENSIONS_LOCK_FILENAME} pins "${p.packageName}", which is not a ` +
207
+ `cinatra.devExtensions entry — stale pin; regenerate the dev lock.`,
208
+ );
209
+ }
210
+ pins.set(p.packageName, { sha: p.resolvedSha, repo: p.repo, source: DEV_EXTENSIONS_LOCK_FILENAME });
211
+ }
212
+
213
+ for (const [pkgName, spec] of Object.entries(config)) {
214
+ const pin = pins.get(pkgName);
215
+ if (!pin) {
216
+ throw new Error(
217
+ `[cinatra dev-extensions] "${pkgName}" has no pin in ${REQUIRED_EXTENSIONS_LOCK_FILENAME} or ` +
218
+ `${DEV_EXTENSIONS_LOCK_FILENAME} — pinned sync is fail-closed. Regenerate the dev lock ` +
219
+ `(scripts/extensions/update-dev-extension-lock.mjs) and commit it.`,
220
+ );
221
+ }
222
+ // Repo-slug cross-check against the COMMITTED config URL. Local remotes
223
+ // (file:// / absolute path — test fixtures) normalize to null and skip it.
224
+ const want = normalizeGitHubRemote(configUrlOf(spec));
225
+ if (want !== null && pin.repo.toLowerCase() !== want) {
226
+ throw new Error(
227
+ `[cinatra dev-extensions] "${pkgName}": lock pins repo "${pin.repo}" but the committed config URL ` +
228
+ `resolves to "${want}" — retargeted repo without a re-pin; regenerate the lock entry.`,
229
+ );
230
+ }
231
+ }
232
+ return pins;
233
+ }
234
+
235
+ export function parseDevExtensionFlags(argv = []) {
236
+ const val = (flag) => {
237
+ const i = argv.indexOf(flag);
238
+ return i >= 0 && argv[i + 1] ? argv[i + 1] : null;
239
+ };
240
+ const list = (v) => (v ? v.split(",").map((s) => s.trim()).filter(Boolean) : []);
241
+ const jobs = parseInt(val("--jobs") ?? "1", 10);
242
+ return {
243
+ select: list(val("--select")),
244
+ kinds: list(val("--kind")),
245
+ exclude: list(val("--exclude")),
246
+ jobs: Number.isFinite(jobs) && jobs > 0 ? jobs : 1,
247
+ force: argv.includes("--force"),
248
+ // CI mode (cinatra#141): detached checkouts at the committed lock shas
249
+ // instead of branch tips. Mutually exclusive with --force by design — the
250
+ // pinned path has no stash/reset semantics.
251
+ pinned: argv.includes("--pinned"),
252
+ };
253
+ }
254
+
255
+ /** Apply `--select` / `--kind` / `--exclude` (match full or short name). */
256
+ export function selectEntries(config, flags) {
257
+ const entries = Object.entries(config).map(([pkgName, spec]) => {
258
+ const normalized = spec && typeof spec === "object" ? spec : { url: String(spec) };
259
+ return { pkgName, spec: normalized, kind: deriveKindFromName(pkgName, normalized.kind) };
260
+ });
261
+ const matches = (set, e) => set.includes(e.pkgName) || set.includes(shortName(e.pkgName));
262
+ return entries.filter((e) => {
263
+ if (flags.select.length && !matches(flags.select, e)) return false;
264
+ if (flags.exclude.length && matches(flags.exclude, e)) return false;
265
+ if (flags.kinds.length && (!e.kind || !flags.kinds.includes(e.kind))) return false;
266
+ return true;
267
+ });
268
+ }
269
+
270
+ /** "@cinatra-ai/foo-agent" → "CINATRA_FOO_AGENT_REPO_URL" */
271
+ export function extensionEnvOverrideVarFor(pkgName) {
272
+ return `CINATRA_${shortName(pkgName).replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase()}_REPO_URL`;
273
+ }
274
+
275
+ /**
276
+ * Clone-or-pull every selected `cinatra.devExtensions` entry into its
277
+ * `extensions/<scope>/<name>` slot under `targetRoot`. Sequential (the clone
278
+ * correctness is the point; `--jobs` is parsed + reserved for a later perf pass,
279
+ * since the underlying git is synchronous and the dev config is empty today).
280
+ */
281
+ export async function syncCinatraDevExtensions({
282
+ repoRoot,
283
+ targetRoot,
284
+ argv = [],
285
+ env = process.env,
286
+ log = console.log,
287
+ deps,
288
+ } = {}) {
289
+ const config = readDevExtensionsConfig(repoRoot, deps?.readFile);
290
+ if (!config || Object.keys(config).length === 0) {
291
+ return { skipped: true, reason: "no-config" };
292
+ }
293
+ const flags = parseDevExtensionFlags(argv);
294
+ if (flags.pinned && flags.force) {
295
+ throw new Error(
296
+ "[cinatra dev-extensions] --pinned and --force are mutually exclusive — pinned sync never stashes or resets local work.",
297
+ );
298
+ }
299
+ // Fail-closed BEFORE any git work: every config entry must be pinnable, or
300
+ // pinned sync refuses outright (a partially-pinned universe would mix
301
+ // committed state with floating tips).
302
+ const pins = flags.pinned ? loadDevExtensionPins(repoRoot, deps?.readFile) : null;
303
+ const selected = selectEntries(config, flags);
304
+ if (selected.length === 0) {
305
+ log("- Dev extensions: nothing matched the --select/--kind/--exclude filters.");
306
+ return { results: [] };
307
+ }
308
+ // Merge over defaults so a caller can inject ONE dep (e.g. `readFile` for
309
+ // config, or a fake `git` in tests) without having to supply the whole git-op
310
+ // surface (`exists`/`git`/`mkdirp`/`readdir`) that `syncOneRepo` needs.
311
+ const realDeps = deps ? { ...defaultRepoSyncDeps(), ...deps } : defaultRepoSyncDeps();
312
+ const results = [];
313
+ log(
314
+ `- Dev extensions (${selected.length} selected${flags.pinned ? ", pinned to the committed lock shas" : ""}${flags.jobs > 1 ? `, --jobs ${flags.jobs} reserved` : ""}):`,
315
+ );
316
+ for (const { pkgName, spec, kind } of selected) {
317
+ const url = env[extensionEnvOverrideVarFor(pkgName)] || spec.url;
318
+ const branch = spec.branch || "main";
319
+ const dest = destDirForExtension(pkgName, spec, targetRoot);
320
+ const r = syncOneRepo({
321
+ pkgName,
322
+ url,
323
+ branch,
324
+ // An env-override remote (fork/mirror) must still SERVE the pinned sha;
325
+ // it never unpins (loadDevExtensionPins validated the lock against the
326
+ // committed config URL, not the override).
327
+ sha: pins ? pins.get(pkgName).sha : undefined,
328
+ dest,
329
+ force: flags.force,
330
+ deps: realDeps,
331
+ log,
332
+ forceFlagHint: "--force",
333
+ stashLabel: "cinatra setup --force (devExtensions)",
334
+ });
335
+ results.push({ ...r, kind, dest });
336
+ }
337
+ return { results };
338
+ }