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,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
|
+
}
|