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,297 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readdirSync } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Generic dev-time git repo sync.
7
+ //
8
+ // Shared clone/fast-forward machinery used by BOTH `dev-apps.mjs` (the external
9
+ // WordPress plugin + Drupal module clones) and `cinatra-dev-extensions.mjs` (the
10
+ // cinatra extension checkouts). Lives in its own module so neither consumer has
11
+ // to import the other's surface.
12
+ //
13
+ // Five explicit states per target (never silently destroys local work):
14
+ // - absent OR empty non-git dir -> clone
15
+ // - clean git, correct origin + branch -> fetch + ff-only (force: reset)
16
+ // - dirty git, correct origin + branch -> skip + warn (force: stash+reset)
17
+ // - wrong origin OR wrong branch -> fail with remediation (never reset)
18
+ // - non-empty non-git dir -> fail with remediation
19
+ //
20
+ // Per-repo URL overrides via env: CINATRA_<NAME>_REPO_URL (HTTPS or SSH).
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /** "@cinatra-ai/wordpress-plugin" -> "CINATRA_WORDPRESS_PLUGIN_REPO_URL" */
24
+ export function envOverrideVarFor(pkgName) {
25
+ const base = String(pkgName)
26
+ .replace(/^@[^/]+\//, "")
27
+ .replace(/[^a-zA-Z0-9]+/g, "_")
28
+ .toUpperCase();
29
+ return `CINATRA_${base}_REPO_URL`;
30
+ }
31
+
32
+ /**
33
+ * Normalize a GitHub remote (HTTPS or SSH) to "owner/repo" (lowercased, no
34
+ * trailing slash, no .git) so HTTPS ↔ SSH forms of the same repo compare equal.
35
+ * Returns null for non-GitHub / unparseable URLs.
36
+ */
37
+ export function normalizeGitHubRemote(url) {
38
+ if (!url) return null;
39
+ const s = String(url).trim().replace(/\.git$/i, "");
40
+ const m =
41
+ s.match(/^git@github\.com:(.+)$/i) ||
42
+ s.match(/^ssh:\/\/(?:[^@/]+@)?github\.com\/(.+)$/i) ||
43
+ // Accept an optional `user@`/`token@` credential before the host so a
44
+ // credentialed GitHub URL still normalizes to owner/repo (otherwise it
45
+ // returned null → the origin check degraded to a raw path compare).
46
+ s.match(/^https?:\/\/(?:[^@/]+@)?github\.com\/(.+)$/i);
47
+ if (!m) return null;
48
+ return m[1].replace(/\/+$/, "").toLowerCase();
49
+ }
50
+
51
+ // A local (non-network) git remote: file:// or an absolute filesystem path.
52
+ // Used to confine the path-equality origin fallback to local remotes ONLY.
53
+ export function isLocalGitRemote(url) {
54
+ if (typeof url !== "string") return false;
55
+ const u = url.trim();
56
+ return u.startsWith("file://") || path.isAbsolute(u);
57
+ }
58
+
59
+ // Strip credentials embedded in a URL before logging (e.g. a
60
+ // `https://<token>@github.com/...` override leaks a PAT into CI/dev logs).
61
+ export function redactGitUrl(url) {
62
+ if (typeof url !== "string") return String(url);
63
+ return url.replace(/(\bhttps?:\/\/)[^@/\s]*@/gi, "$1***@").replace(/(\bssh:\/\/)[^@/\s]*@/gi, "$1***@");
64
+ }
65
+
66
+ // Remote allowlist: GitHub over https/ssh/scp (real extension + app repos) OR a
67
+ // local filesystem path / file:// (local mirrors + test fixtures). Anything else
68
+ // is refused BEFORE `git clone` so a malicious config can't make git contact an
69
+ // arbitrary remote.
70
+ export function isAllowedGitRemote(url) {
71
+ if (typeof url !== "string" || url.trim() === "") return false;
72
+ const u = url.trim();
73
+ if (/^https:\/\/([^/@\s]+@)?github\.com\//i.test(u)) return true;
74
+ if (/^ssh:\/\/git@github\.com\//i.test(u)) return true;
75
+ if (/^git@github\.com:/i.test(u)) return true;
76
+ if (u.startsWith("file://")) return true;
77
+ if (path.isAbsolute(u)) return true; // local bare repo / mirror
78
+ return false;
79
+ }
80
+
81
+ export function defaultRepoSyncDeps() {
82
+ return {
83
+ git: (args, cwd) =>
84
+ execFileSync("git", args, {
85
+ cwd,
86
+ encoding: "utf8",
87
+ stdio: ["ignore", "pipe", "pipe"],
88
+ }).toString(),
89
+ exists: (p) => existsSync(p),
90
+ readdir: (p) => readdirSync(p),
91
+ mkdirp: (p) => mkdirSync(p, { recursive: true }),
92
+ };
93
+ }
94
+
95
+ function dirIsEmpty(dir, deps) {
96
+ try {
97
+ return deps.readdir(dir).filter((n) => n !== ".DS_Store").length === 0;
98
+ } catch {
99
+ return true;
100
+ }
101
+ }
102
+
103
+ const COMMIT_SHA_RE = /^[0-9a-f]{40}$/;
104
+
105
+ /**
106
+ * Sync a single target repo. `deps` is injectable for tests. Returns
107
+ * { pkgName, action } or throws on a fail-state. `forceFlagHint` / `stashLabel`
108
+ * let each caller surface the right force-flag advice (dev-apps vs extensions).
109
+ *
110
+ * Pinned mode (`sha` set): the target is checked out DETACHED at exactly that
111
+ * commit instead of tracking `origin/<branch>`. Used by CI so the validated
112
+ * extension universe is the COMMITTED lock state, not whatever the companion
113
+ * repos' tips say at run time (cinatra#141). Pinned semantics per state:
114
+ * - absent/empty -> clone (delegates partial-state cleanup to
115
+ * `git clone`), ensure the commit is present
116
+ * (fetch the exact sha only when the cloned
117
+ * branch does not already contain it), then
118
+ * `checkout --detach <sha>` + assert HEAD==sha.
119
+ * A failure after the clone leaves a valid
120
+ * branch-mode checkout that the existing-git
121
+ * path below re-pins on retry.
122
+ * - existing git, clean -> verify origin (branch-name check does NOT
123
+ * apply — detached HEAD is the expected state),
124
+ * no-op when HEAD already equals the pin,
125
+ * otherwise fetch-if-missing + re-detach.
126
+ * - existing git, dirty -> HARD FAIL. Pinned mode never stashes or
127
+ * resets local work (no --force semantics).
128
+ * - wrong origin / non-git -> hard fail (unchanged from branch mode).
129
+ */
130
+ export function syncOneRepo({
131
+ pkgName,
132
+ url,
133
+ branch,
134
+ sha,
135
+ dest,
136
+ force,
137
+ deps,
138
+ log,
139
+ forceFlagHint = "--force",
140
+ stashLabel = "cinatra setup --force",
141
+ }) {
142
+ const { git } = deps;
143
+ // Pinned mode accepts ONLY a full lowercase 40-hex commit sha — anything
144
+ // else (branch name, short sha, flag-like string) is refused before any git
145
+ // invocation. The regex also subsumes the leading-dash argument guard.
146
+ if (sha !== undefined && (typeof sha !== "string" || !COMMIT_SHA_RE.test(sha))) {
147
+ throw new Error(
148
+ `${pkgName}: pinned sync requires a full lowercase 40-hex commit sha (got "${sha}").`,
149
+ );
150
+ }
151
+ // Git argument-injection defense-in-depth: a `url`/`branch` (from package.json
152
+ // config or a CINATRA_*_REPO_URL env override) that begins with "-" would be
153
+ // parsed by git as an option, not a positional. execFileSync already blocks
154
+ // shell metachars; this blocks flag-like git args. A leading-dash repo URL or
155
+ // branch is never legitimate here.
156
+ for (const [label, val] of [["url", url], ["branch", branch]]) {
157
+ if (typeof val === "string" && val.startsWith("-")) {
158
+ throw new Error(`${pkgName}: refusing a "${label}" that begins with "-" ("${val}") — flag-like git arguments are not allowed.`);
159
+ }
160
+ }
161
+ // Remote allowlist: never let a config entry make git contact an arbitrary host.
162
+ if (!isAllowedGitRemote(url)) {
163
+ throw new Error(
164
+ `${pkgName}: refusing a git remote that is not GitHub or a local path: "${redactGitUrl(url)}". ` +
165
+ `Allowed: https/ssh github.com, file://, or an absolute local path.`,
166
+ );
167
+ }
168
+ const wantRemote = normalizeGitHubRemote(url);
169
+ const exists = deps.exists(dest);
170
+ const isGit = deps.exists(path.join(dest, ".git"));
171
+
172
+ // Ensure the pinned commit exists locally, fetching the EXACT sha only when
173
+ // the checkout does not already contain it (the common case — a recorded
174
+ // branch head — is already present after a branch clone/earlier fetch, so
175
+ // this avoids a per-repo network round-trip). GitHub serves reachable-sha
176
+ // fetches; an unreachable pin (force-pushed companion history) fails loud
177
+ // here — that is the bump-the-lock signal, never a silent fallback to tip.
178
+ const ensurePinnedCommit = () => {
179
+ let present = true;
180
+ try {
181
+ git(["cat-file", "-e", `${sha}^{commit}`], dest);
182
+ } catch {
183
+ present = false;
184
+ }
185
+ if (!present) git(["fetch", "origin", sha], dest);
186
+ git(["checkout", "--detach", sha], dest);
187
+ const head = git(["rev-parse", "HEAD"], dest).trim();
188
+ if (head !== sha) {
189
+ throw new Error(
190
+ `${pkgName}: pinned checkout verification failed — HEAD is ${head}, expected ${sha}.`,
191
+ );
192
+ }
193
+ };
194
+
195
+ // absent OR empty non-git dir -> clone
196
+ if (!exists || (!isGit && dirIsEmpty(dest, deps))) {
197
+ log(
198
+ sha
199
+ ? ` ${pkgName}: cloning ${redactGitUrl(url)} (pinned ${sha.slice(0, 12)}) -> ${dest}`
200
+ : ` ${pkgName}: cloning ${redactGitUrl(url)} (${branch}) -> ${dest}`,
201
+ );
202
+ deps.mkdirp(path.dirname(dest));
203
+ git(["clone", "--branch", branch, "--single-branch", "--", url, dest], path.dirname(dest));
204
+ if (sha) {
205
+ ensurePinnedCommit();
206
+ return { pkgName, action: "cloned", changed: true, pinnedSha: sha };
207
+ }
208
+ return { pkgName, action: "cloned" };
209
+ }
210
+
211
+ // non-empty non-git dir -> fail
212
+ if (!isGit) {
213
+ throw new Error(
214
+ `${pkgName}: "${dest}" is a non-empty, non-git directory. ` +
215
+ `Move it aside (or delete it), then re-run \`cinatra setup\`. ` +
216
+ `Expected a clean clone of ${redactGitUrl(url)}.`,
217
+ );
218
+ }
219
+
220
+ // git checkout: verify origin + branch (HTTPS ↔ SSH normalized)
221
+ const originRaw = git(["remote", "get-url", "origin"], dest).trim();
222
+ const haveRemote = normalizeGitHubRemote(originRaw);
223
+ const curBranch = git(["rev-parse", "--abbrev-ref", "HEAD"], dest).trim();
224
+
225
+ // For GitHub remotes, compare the normalized owner/repo. For local remotes
226
+ // `normalizeGitHubRemote` returns null for BOTH — comparing null===null would
227
+ // treat two DIFFERENT repos as the same origin — so fall back to a resolved-path
228
+ // comparison ONLY when BOTH sides are genuinely local (file:// / absolute path).
229
+ // A non-GitHub, non-local remote is impossible here (the allowlist rejected it).
230
+ const originMatches =
231
+ wantRemote !== null
232
+ ? haveRemote === wantRemote
233
+ : isLocalGitRemote(url) && isLocalGitRemote(originRaw) && path.resolve(originRaw) === path.resolve(url);
234
+
235
+ // Pinned mode skips the branch-name check (a detached HEAD reports "HEAD",
236
+ // and a pre-existing branch checkout is simply re-pinned below) — the origin
237
+ // check still applies in full.
238
+ if (!originMatches || (sha === undefined && curBranch !== branch)) {
239
+ // Wrong origin or branch is NEVER auto-reset, even with --force.
240
+ throw new Error(
241
+ `${pkgName}: "${dest}" tracks ${redactGitUrl(originRaw) || "(no origin)"} on branch "${curBranch}", ` +
242
+ `but ${redactGitUrl(url)} on "${branch}" is expected. ` +
243
+ `Fix the remote/branch or move the directory aside; this is never auto-reset. ` +
244
+ `(Use ${envOverrideVarFor(pkgName)} to point at a fork/SSH URL.)`,
245
+ );
246
+ }
247
+
248
+ // clean+correct origin+branch: check dirty
249
+ const dirty = git(["status", "--porcelain"], dest).trim() !== "";
250
+
251
+ if (sha) {
252
+ // Pinned mode has NO stash/reset path: a dirty tree is a hard fail (CI
253
+ // checkouts are always fresh; a local pinned run must never destroy work).
254
+ if (dirty) {
255
+ throw new Error(
256
+ `${pkgName}: "${dest}" has uncommitted changes — pinned sync never stashes or resets local work. ` +
257
+ `Clean the tree (or move the directory aside), then re-run.`,
258
+ );
259
+ }
260
+ const headBefore = git(["rev-parse", "HEAD"], dest).trim();
261
+ if (headBefore === sha) {
262
+ // The pinned contract is "AT the pin and DETACHED" — a warm checkout
263
+ // sitting on a branch that happens to point at the pin is still
264
+ // detached here (cheap; content unchanged, so `changed` stays false).
265
+ if (curBranch !== "HEAD") git(["checkout", "--detach", sha], dest);
266
+ return { pkgName, action: "pinned", changed: false, pinnedSha: sha };
267
+ }
268
+ log(` ${pkgName}: re-pinning ${headBefore.slice(0, 12)} -> ${sha.slice(0, 12)} (detached)`);
269
+ ensurePinnedCommit();
270
+ return { pkgName, action: "repinned", changed: true, pinnedSha: sha };
271
+ }
272
+ if (dirty) {
273
+ if (!force) {
274
+ log(
275
+ ` ${pkgName}: SKIP — uncommitted changes in ${dest}. ` +
276
+ `Commit or stash them, or re-run with ${forceFlagHint}.`,
277
+ );
278
+ return { pkgName, action: "skipped-dirty" };
279
+ }
280
+ log(` ${pkgName}: --force — stashing local changes, then hard-reset to origin/${branch}`);
281
+ git(["stash", "push", "--include-untracked", "-m", stashLabel], dest);
282
+ log(` ${pkgName}: local changes stashed as "${stashLabel}" — recover via: git -C ${dest} stash list && git -C ${dest} stash pop`);
283
+ }
284
+
285
+ const headBefore = git(["rev-parse", "HEAD"], dest).trim();
286
+ git(["fetch", "origin", branch], dest);
287
+ if (force) {
288
+ git(["reset", "--hard", `origin/${branch}`], dest);
289
+ return { pkgName, action: "force-reset" };
290
+ }
291
+ git(["merge", "--ff-only", `origin/${branch}`], dest);
292
+ const headAfter = git(["rev-parse", "HEAD"], dest).trim();
293
+ // `updated` covers BOTH a no-op pull and a real fast-forward. `changed`
294
+ // distinguishes them so a post-sync workspace re-link runs only when HEAD
295
+ // actually moved (a ff that may have added/changed deps), not on every warm run.
296
+ return { pkgName, action: "updated", changed: headBefore !== headAfter };
297
+ }
@@ -0,0 +1,258 @@
1
+ // Dependency-ordering gate for marketplace submission.
2
+ //
3
+ // Before an extension tarball is submitted to the Cinatra Marketplace, every
4
+ // `@cinatra-ai/*` EXTENSION EDGE it declares in its canonical `cinatra.dependencies`
5
+ // MUST already be published on `registry.cinatra.ai` — those extension packages live
6
+ // ONLY there, never on npmjs. Host-internal SDK/app peers (sdk-extensions, sdk-ui,
7
+ // mcp-client, …) are NOT extension edges: under model-B they are host-provided
8
+ // optional peers, intentionally never on the registry, so the gate SKIPS them
9
+ // (probing would 404/401). Submitting a package whose sibling-extension closure is
10
+ // not yet on the registry would produce a public extension repo that cannot
11
+ // `pnpm install` a sibling it needs. This gate fails BEFORE submit if a real edge
12
+ // is missing.
13
+ //
14
+ // Strict failure semantics:
15
+ // - 404 / no published versions / no version satisfying the range → MISSING
16
+ // (a real ordering violation — publish the closure first).
17
+ // - 401 / 403 → UNREADABLE
18
+ // (registry.cinatra.ai requires authentication by design — no read-scope
19
+ // token is set in this shell). This is NOT "missing" — we simply cannot
20
+ // verify, so the gate fails closed with a DISTINCT message rather than
21
+ // green-lighting blindly.
22
+ // - network / non-JSON / other non-2xx → ERROR.
23
+ //
24
+ // Queries ONLY the configured registry (no npmjs fallback — a silent fallback
25
+ // would mask an ordering violation or a registry outage).
26
+
27
+ import semver from "semver";
28
+
29
+ export const CINATRA_SCOPE = "@cinatra-ai/";
30
+ export const DEFAULT_REGISTRY_URL = "https://registry.cinatra.ai";
31
+
32
+ /**
33
+ * Extract every `@cinatra-ai/*` dependency (name + range + source field) from a
34
+ * package manifest's `dependencies` and `peerDependencies`.
35
+ * @param {{dependencies?:Record<string,string>, peerDependencies?:Record<string,string>}} manifest
36
+ * @returns {Array<{name:string, range:string, field:string}>}
37
+ */
38
+ export function extractCinatraDeps(manifest) {
39
+ const out = [];
40
+ const seen = new Set();
41
+ for (const field of ["dependencies", "peerDependencies"]) {
42
+ const deps = manifest?.[field];
43
+ if (!deps || typeof deps !== "object") continue;
44
+ for (const [name, range] of Object.entries(deps)) {
45
+ if (!name.startsWith(CINATRA_SCOPE)) continue;
46
+ const key = `${name}@${range}`;
47
+ if (seen.has(key)) continue;
48
+ seen.add(key);
49
+ out.push({ name, range: String(range ?? ""), field });
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /**
56
+ * Canonical cross-extension edge names from the manifest's `cinatra.dependencies`
57
+ * (array of `{packageName}`, array of strings, or a name→spec object).
58
+ * Only these @cinatra-ai/* deps are real marketplace dependencies that
59
+ * must be on the registry first; host-internal SDK/app packages (sdk-extensions,
60
+ * sdk-ui, mcp-client, …) are NEVER declared here — under model-B they are
61
+ * host-provided OPTIONAL peers, intentionally not on any registry, and the gate
62
+ * must SKIP them (probing would 404/401).
63
+ * @param {{cinatra?:{dependencies?:unknown}}} manifest
64
+ * @returns {string[]}
65
+ */
66
+ export function extractCinatraManifestDepNames(manifest) {
67
+ const c = manifest?.cinatra?.dependencies;
68
+ const out = new Set();
69
+ if (Array.isArray(c)) {
70
+ for (const e of c) {
71
+ if (e && typeof e === "object" && typeof e.packageName === "string") out.add(e.packageName);
72
+ else if (typeof e === "string") out.add(e.startsWith("@") ? e : `${CINATRA_SCOPE}${e}`);
73
+ }
74
+ } else if (c && typeof c === "object") {
75
+ for (const k of Object.keys(c)) out.add(k);
76
+ }
77
+ return [...out];
78
+ }
79
+
80
+ /**
81
+ * Select which @cinatra-ai/* deps the ordering gate must verify on the registry:
82
+ * ONLY the canonical extension edges declared in `cinatra.dependencies`. An npm
83
+ * dep/peer that is NOT a declared edge is host-internal (a host-provided peer) and
84
+ * is SKIPPED. An edge declared ONLY in `cinatra.dependencies` (no npm dep entry —
85
+ * e.g. linkedin→social-media, resend→email) is still probed with range "*".
86
+ * @param {object} manifest
87
+ * @returns {{toProbe:Array<{name:string,range:string,field:string}>, skippedNonManifestCinatraDeps:string[]}}
88
+ */
89
+ export function selectExtensionDepsToProbe(manifest) {
90
+ const edgeNames = new Set(extractCinatraManifestDepNames(manifest));
91
+ const npmDeps = extractCinatraDeps(manifest);
92
+ const toProbe = [];
93
+ const seen = new Set();
94
+ for (const d of npmDeps) {
95
+ if (edgeNames.has(d.name) && !seen.has(d.name)) {
96
+ toProbe.push(d);
97
+ seen.add(d.name);
98
+ }
99
+ }
100
+ for (const name of edgeNames) {
101
+ if (!seen.has(name)) {
102
+ toProbe.push({ name, range: "*", field: "cinatra.dependencies" });
103
+ seen.add(name);
104
+ }
105
+ }
106
+ const skippedNonManifestCinatraDeps = npmDeps.filter((d) => !edgeNames.has(d.name)).map((d) => d.name);
107
+ return { toProbe, skippedNonManifestCinatraDeps };
108
+ }
109
+
110
+ /** Build the npm-registry packument URL for a scoped package name. */
111
+ function packumentUrl(registryUrl, name) {
112
+ // Scoped names are URL-encoded with every slash escaped: @scope%2Fname.
113
+ return `${String(registryUrl).replace(/\/+$/, "")}/${name.replace(/\//g, "%2F")}`;
114
+ }
115
+
116
+ function authHeader(token) {
117
+ if (!token) return {};
118
+ const value = /^(Bearer|Basic)\s/i.test(token) ? token : `Bearer ${token}`;
119
+ return { authorization: value };
120
+ }
121
+
122
+ /** True when at least one published version satisfies the declared range. */
123
+ export function isRangeSatisfied(range, versions, distTags = {}) {
124
+ const r = String(range ?? "").trim();
125
+ // The companion repos convert workspace deps to peerDependencies "*"; any
126
+ // published version satisfies. Empty / "x" / "latest" behave the same.
127
+ if (r === "" || r === "*" || r === "x" || r === "latest") return versions.length > 0;
128
+ // A dist-tag reference (e.g. "next") is satisfied if that tag exists.
129
+ if (Object.prototype.hasOwnProperty.call(distTags, r)) return true;
130
+ try {
131
+ return versions.some((v) => semver.satisfies(v, r, { includePrerelease: false }));
132
+ } catch {
133
+ // Unparseable range (git/url/file spec) — existence is the best we can do;
134
+ // treat a published package as satisfying and let marketplace-side checks
135
+ // own the deeper validation.
136
+ return versions.length > 0;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Probe one `@cinatra-ai/*` dependency against the registry.
142
+ * @returns {Promise<{name,range,field,state:'satisfied'|'unsatisfied'|'missing'|'unreadable'|'error', detail?:string, status?:number, versions?:string[]}>}
143
+ */
144
+ export async function probeDep(dep, { registryUrl, token, fetchImpl }) {
145
+ const url = packumentUrl(registryUrl, dep.name);
146
+ let res;
147
+ try {
148
+ res = await fetchImpl(url, { headers: { accept: "application/json", ...authHeader(token) } });
149
+ } catch (err) {
150
+ return { ...dep, state: "error", detail: err instanceof Error ? err.message : String(err) };
151
+ }
152
+ if (res.status === 401 || res.status === 403) {
153
+ return { ...dep, state: "unreadable", status: res.status };
154
+ }
155
+ if (res.status === 404) {
156
+ return { ...dep, state: "missing", status: 404, detail: "not found on the registry" };
157
+ }
158
+ if (!res.ok) {
159
+ return { ...dep, state: "error", status: res.status, detail: `unexpected HTTP ${res.status}` };
160
+ }
161
+ let body;
162
+ try {
163
+ body = await res.json();
164
+ } catch {
165
+ return { ...dep, state: "error", detail: "registry returned a non-JSON packument" };
166
+ }
167
+ const versions = Object.keys(body?.versions ?? {});
168
+ const distTags = body?.["dist-tags"] ?? {};
169
+ if (versions.length === 0) {
170
+ return { ...dep, state: "missing", detail: "no published versions" };
171
+ }
172
+ return isRangeSatisfied(dep.range, versions, distTags)
173
+ ? { ...dep, state: "satisfied" }
174
+ : { ...dep, state: "unsatisfied", versions };
175
+ }
176
+
177
+ /**
178
+ * Check that every `@cinatra-ai/*` dependency of `manifest` is published on the
179
+ * registry. Resolves to a structured report; never throws on a gate violation
180
+ * (the caller decides). Throws only on a programming error.
181
+ */
182
+ export async function checkDependencyOrdering({
183
+ manifest,
184
+ registryUrl = DEFAULT_REGISTRY_URL,
185
+ token,
186
+ fetchImpl = globalThis.fetch,
187
+ } = {}) {
188
+ if (typeof fetchImpl !== "function") {
189
+ throw new Error("checkDependencyOrdering: no fetch implementation available");
190
+ }
191
+ // Probe ONLY canonical extension edges (cinatra.dependencies); host-internal
192
+ // @cinatra-ai/* peers are host-provided under model-B and never on the registry.
193
+ const { toProbe: deps, skippedNonManifestCinatraDeps } = selectExtensionDepsToProbe(manifest);
194
+ const results = [];
195
+ for (const dep of deps) {
196
+ results.push(await probeDep(dep, { registryUrl, token, fetchImpl }));
197
+ }
198
+ const missing = results.filter((r) => r.state === "missing" || r.state === "unsatisfied");
199
+ const unreadable = results.filter((r) => r.state === "unreadable");
200
+ const errored = results.filter((r) => r.state === "error");
201
+ const satisfied = results.filter((r) => r.state === "satisfied");
202
+ return {
203
+ ok: missing.length === 0 && unreadable.length === 0 && errored.length === 0,
204
+ registryUrl,
205
+ deps,
206
+ skippedNonManifestCinatraDeps,
207
+ results,
208
+ missing,
209
+ unreadable,
210
+ errored,
211
+ satisfied,
212
+ };
213
+ }
214
+
215
+ /** Render a human-readable failure message for a non-ok report. */
216
+ export function formatGateFailure(report) {
217
+ const lines = [];
218
+ if (report.missing.length > 0) {
219
+ lines.push(
220
+ `Dependency-ordering gate FAILED — ${report.missing.length} @cinatra-ai/* dependency(ies) not on ${report.registryUrl}:`,
221
+ );
222
+ for (const m of report.missing) {
223
+ lines.push(
224
+ ` • ${m.name}@${m.range} [${m.field}] — ${m.state === "unsatisfied" ? `no published version satisfies (have: ${(m.versions || []).join(", ") || "none"})` : m.detail || "missing"}`,
225
+ );
226
+ }
227
+ lines.push(
228
+ "Publish the missing @cinatra-ai/* dependency extension(s) (in dependency order) THROUGH the marketplace storefront FIRST, then re-submit. (These are dependency extensions, not the host SDK.)",
229
+ );
230
+ }
231
+ if (report.unreadable.length > 0) {
232
+ lines.push(
233
+ `Dependency-ordering gate could NOT verify — ${report.registryUrl} returned ${report.unreadable[0].status} (registry not readable):`,
234
+ );
235
+ for (const u of report.unreadable) lines.push(` • ${u.name}@${u.range} [${u.field}]`);
236
+ lines.push(
237
+ "registry.cinatra.ai requires authentication by design — export a read-scope CINATRA_REGISTRY_TOKEN, then re-run. " +
238
+ "(Use --skip-dependency-check only if you have independently confirmed the closure is published.)",
239
+ );
240
+ }
241
+ if (report.errored.length > 0) {
242
+ lines.push(`Dependency-ordering gate hit ${report.errored.length} registry error(s):`);
243
+ for (const e of report.errored) lines.push(` • ${e.name}@${e.range}: ${e.detail || `HTTP ${e.status}`}`);
244
+ }
245
+ return lines.join("\n");
246
+ }
247
+
248
+ /**
249
+ * Assert the dependency-ordering gate passes. Throws with a formatted message
250
+ * on any violation (missing / unreadable / error). Returns the report on pass.
251
+ */
252
+ export async function assertDependencyOrdering(opts) {
253
+ const report = await checkDependencyOrdering(opts);
254
+ if (!report.ok) {
255
+ throw new Error(formatGateFailure(report));
256
+ }
257
+ return report;
258
+ }