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,538 @@
|
|
|
1
|
+
// -----------------------------------------------------------------------------
|
|
2
|
+
// Dev-only seed of the on-disk first-party extensions into the LOCAL bundled
|
|
3
|
+
// Verdaccio (cinatra#386).
|
|
4
|
+
//
|
|
5
|
+
// THE GAP: the repo ships ~80 first-party extension packages on disk at
|
|
6
|
+
// `extensions/<vendor>/<pkg>/` (e.g. `@cinatra-ai/blog-content-workflow`), but a
|
|
7
|
+
// fresh/bundled local Verdaccio (docker-compose `verdaccio` service at
|
|
8
|
+
// 127.0.0.1:4873) starts EMPTY — those packages were never published into it.
|
|
9
|
+
// The installer resolves REGISTRY-ONLY (pacote against the registry URL; no
|
|
10
|
+
// on-disk fallback), so a `GET /@cinatra-ai%2fblog-content-workflow` returns 404
|
|
11
|
+
// and the extension is uninstallable out of the box.
|
|
12
|
+
//
|
|
13
|
+
// THE FIX (the dev-side mirror of the production seeding contract): production
|
|
14
|
+
// installs resolve from immutable registry tarballs that a maintainer publishes
|
|
15
|
+
// to the production Verdaccio; here we do the dev equivalent — publish the
|
|
16
|
+
// on-disk first-party packages into the LOCAL registry during `cinatra setup
|
|
17
|
+
// dev`, so dev resolution matches the production "registry is the install
|
|
18
|
+
// backend" model WITHOUT teaching the installer a source-tree fallback (which
|
|
19
|
+
// would widen the install trust surface and let packaging defects hide until
|
|
20
|
+
// production).
|
|
21
|
+
//
|
|
22
|
+
// GUARDRAILS (codex-converged on cinatra#386):
|
|
23
|
+
// - DEV-ONLY: called only from the `mode === "dev"` block of `runSetup`,
|
|
24
|
+
// after the on-disk extension tree is materialized + manifests regenerated.
|
|
25
|
+
// Never on the prod setup path and never on any install path.
|
|
26
|
+
// - LOOPBACK-ONLY: the publish target is HARD-BOUND to a loopback host
|
|
27
|
+
// (127.0.0.1 / ::1 / localhost). A non-loopback registry URL is REJECTED
|
|
28
|
+
// before any publish — this seed must never push at a remote/production
|
|
29
|
+
// registry. Arbitrary env registry values are not honored as a publish
|
|
30
|
+
// target.
|
|
31
|
+
// - REACHABILITY-GATED: if the local registry is down/unreadable, warn and
|
|
32
|
+
// skip the whole step (loud-but-non-fatal — never abort setup).
|
|
33
|
+
// - TEMP AUTH ONLY: self-register a throwaway Verdaccio user, write the auth
|
|
34
|
+
// token only into a temp `--userconfig` file, and delete it in `finally`.
|
|
35
|
+
// The real `~/.npmrc` is never read or mutated.
|
|
36
|
+
// - IDEMPOTENT + NON-CLOBBERING: check the packument first; if `name@version`
|
|
37
|
+
// already exists, SKIP (never force-publish / unpublish / overwrite a
|
|
38
|
+
// dist-tag). Re-running setup is a cheap no-op.
|
|
39
|
+
// - VERSION-SKEW DETECTION: if `name@version` already exists but the local
|
|
40
|
+
// packed bytes differ from the registry tarball integrity, warn LOUDLY and
|
|
41
|
+
// set a non-zero exit code (don't republish the same version) — the operator
|
|
42
|
+
// must purge/reset the local Verdaccio or bump the extension version.
|
|
43
|
+
// - PRIVATE/SHAPE FILTER: only publish packages whose `package.json` has a
|
|
44
|
+
// valid `name` + `version` and is not `"private": true`.
|
|
45
|
+
// - FAILURE DISCIPLINE: a per-package publish failure warns, continues to the
|
|
46
|
+
// remaining packages, and leaves setup loud-but-non-fatal.
|
|
47
|
+
//
|
|
48
|
+
// This module is intentionally self-contained (node builtins + the `npm` binary
|
|
49
|
+
// that ships alongside node): like its `agents-install.mjs` sibling it cannot
|
|
50
|
+
// import the @cinatra-ai/registries TS source from a `.mjs` CLI script.
|
|
51
|
+
// -----------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
import {
|
|
54
|
+
existsSync,
|
|
55
|
+
mkdtempSync,
|
|
56
|
+
readFileSync,
|
|
57
|
+
readdirSync,
|
|
58
|
+
rmSync,
|
|
59
|
+
writeFileSync,
|
|
60
|
+
} from "node:fs";
|
|
61
|
+
import os from "node:os";
|
|
62
|
+
import path from "node:path";
|
|
63
|
+
import process from "node:process";
|
|
64
|
+
import { spawnSync } from "node:child_process";
|
|
65
|
+
import { createHash } from "node:crypto";
|
|
66
|
+
|
|
67
|
+
// The bundled local Verdaccio — the ONLY sanctioned publish target for this
|
|
68
|
+
// dev seed. Matches docker-compose `verdaccio` (host port 4873) and the
|
|
69
|
+
// `DEFAULT_REGISTRY_URL` the agents-install CLI already uses.
|
|
70
|
+
export const LOCAL_REGISTRY_URL = "http://127.0.0.1:4873";
|
|
71
|
+
|
|
72
|
+
// Hostnames that count as loopback. A publish target MUST resolve to one of
|
|
73
|
+
// these or the step refuses to run (never push at a remote/production registry).
|
|
74
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "[::1]", "localhost"]);
|
|
75
|
+
|
|
76
|
+
// Per-call ceilings so a loopback service that ACCEPTS a connection then stalls
|
|
77
|
+
// can never hang setup. A timeout is always treated as warn-and-continue (the
|
|
78
|
+
// whole step is loud-but-non-fatal). Generous because publish writes a tarball.
|
|
79
|
+
const HTTP_TIMEOUT_MS = 15_000;
|
|
80
|
+
const PUBLISH_TIMEOUT_MS = 60_000;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Compare two dotted numeric version cores (`major.minor.patch[...]`). Returns
|
|
84
|
+
* 1 if `a > b`, -1 if `a < b`, 0 if equal. Pre-release/build metadata is
|
|
85
|
+
* ignored (split off the first `-`/`+`); a non-numeric segment compares as 0.
|
|
86
|
+
* Deliberately tiny — this only decides "would npm reject a lower publish?",
|
|
87
|
+
* not full semver precedence.
|
|
88
|
+
*/
|
|
89
|
+
export function compareVersionCores(a, b) {
|
|
90
|
+
const core = (v) => String(v).split(/[-+]/)[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
91
|
+
const av = core(a);
|
|
92
|
+
const bv = core(b);
|
|
93
|
+
const len = Math.max(av.length, bv.length);
|
|
94
|
+
for (let i = 0; i < len; i++) {
|
|
95
|
+
const x = av[i] ?? 0;
|
|
96
|
+
const y = bv[i] ?? 0;
|
|
97
|
+
if (x > y) return 1;
|
|
98
|
+
if (x < y) return -1;
|
|
99
|
+
}
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* True iff any published version is >= `onDiskVersion`. When this holds, `npm
|
|
105
|
+
* publish` of the on-disk (lower-or-equal) version would be rejected as a lower
|
|
106
|
+
* `latest`, so we skip gracefully instead of forcing a loud failure. When only
|
|
107
|
+
* LOWER versions exist (e.g. after an on-disk version bump), this is false and
|
|
108
|
+
* the caller proceeds to publish the new version.
|
|
109
|
+
*/
|
|
110
|
+
export function registryHasAtLeast(packument, onDiskVersion) {
|
|
111
|
+
const versions = packument?.versions ? Object.keys(packument.versions) : [];
|
|
112
|
+
return versions.some((v) => compareVersionCores(v, onDiskVersion) >= 0);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* True iff `registryUrl` is a well-formed http(s) URL whose host is loopback.
|
|
117
|
+
* Defensive: a parse failure (or any non-loopback host) returns false so the
|
|
118
|
+
* caller refuses to publish.
|
|
119
|
+
*/
|
|
120
|
+
export function isLoopbackRegistryUrl(registryUrl) {
|
|
121
|
+
if (!registryUrl || typeof registryUrl !== "string") return false;
|
|
122
|
+
let parsed;
|
|
123
|
+
try {
|
|
124
|
+
parsed = new URL(registryUrl);
|
|
125
|
+
} catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
|
|
129
|
+
return LOOPBACK_HOSTS.has(parsed.hostname);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Enumerate the on-disk first-party extension package dirs under
|
|
134
|
+
* `<repoRoot>/extensions/<vendor>/<pkg>/` that carry a publishable
|
|
135
|
+
* `package.json` (valid name+version, not private). Returns sorted entries
|
|
136
|
+
* `{ dir, name, version, private }` for deterministic ordering.
|
|
137
|
+
*/
|
|
138
|
+
export function enumeratePublishableExtensions(repoRoot) {
|
|
139
|
+
const extensionsRoot = path.join(repoRoot, "extensions");
|
|
140
|
+
const out = [];
|
|
141
|
+
if (!existsSync(extensionsRoot)) return out;
|
|
142
|
+
|
|
143
|
+
let vendors;
|
|
144
|
+
try {
|
|
145
|
+
vendors = readdirSync(extensionsRoot, { withFileTypes: true });
|
|
146
|
+
} catch {
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const vendor of vendors) {
|
|
151
|
+
if (!vendor.isDirectory()) continue;
|
|
152
|
+
const vendorDir = path.join(extensionsRoot, vendor.name);
|
|
153
|
+
let pkgs;
|
|
154
|
+
try {
|
|
155
|
+
pkgs = readdirSync(vendorDir, { withFileTypes: true });
|
|
156
|
+
} catch {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
for (const pkg of pkgs) {
|
|
160
|
+
if (!pkg.isDirectory()) continue;
|
|
161
|
+
const dir = path.join(vendorDir, pkg.name);
|
|
162
|
+
const manifestPath = path.join(dir, "package.json");
|
|
163
|
+
if (!existsSync(manifestPath)) continue;
|
|
164
|
+
let manifest;
|
|
165
|
+
try {
|
|
166
|
+
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
167
|
+
} catch {
|
|
168
|
+
// Unreadable/invalid package.json — skip (a per-package warn happens
|
|
169
|
+
// at the call site only for things we actually try to publish).
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (manifest.private === true) continue;
|
|
173
|
+
if (typeof manifest.name !== "string" || !manifest.name) continue;
|
|
174
|
+
if (typeof manifest.version !== "string" || !manifest.version) continue;
|
|
175
|
+
out.push({
|
|
176
|
+
dir,
|
|
177
|
+
name: manifest.name,
|
|
178
|
+
version: manifest.version,
|
|
179
|
+
private: false,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Probe the registry root. Returns true on a 2xx HTTP response, false on any
|
|
190
|
+
* network error / non-2xx. Used to skip the whole step when Verdaccio is down.
|
|
191
|
+
*/
|
|
192
|
+
async function isRegistryReachable(registryUrl) {
|
|
193
|
+
try {
|
|
194
|
+
const res = await fetch(new URL("/", registryUrl), {
|
|
195
|
+
method: "GET",
|
|
196
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
197
|
+
});
|
|
198
|
+
return res.ok;
|
|
199
|
+
} catch {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Read the registry packument for `name`. Returns the parsed JSON on 200, or
|
|
206
|
+
* `null` on 404 / network error / non-200 (treated as "not present yet").
|
|
207
|
+
*/
|
|
208
|
+
async function fetchPackument(registryUrl, name) {
|
|
209
|
+
// Scoped names must be URL-escaped (`@scope/name` → `@scope%2fname`).
|
|
210
|
+
const escaped = name.replace("/", "%2f");
|
|
211
|
+
try {
|
|
212
|
+
const res = await fetch(new URL(`/${escaped}`, registryUrl), {
|
|
213
|
+
method: "GET",
|
|
214
|
+
headers: { accept: "application/json" },
|
|
215
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
216
|
+
});
|
|
217
|
+
if (res.status === 200) return await res.json();
|
|
218
|
+
return null;
|
|
219
|
+
} catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// The throwaway seed user. The password is DETERMINISTIC (and local-only): the
|
|
225
|
+
// only credential it ever guards is publish access to a loopback dev Verdaccio.
|
|
226
|
+
// Determinism is REQUIRED for idempotency — on a re-run the user already exists,
|
|
227
|
+
// so the second `cinatra setup dev` must be able to LOG BACK IN (anonymous
|
|
228
|
+
// adduser then returns 409) to get a fresh publish token. A random per-run
|
|
229
|
+
// password would lock the existing user out and break re-seeding.
|
|
230
|
+
const SEED_USER = "cinatra-dev-seed";
|
|
231
|
+
const SEED_PASSWORD = "cinatra-local-dev-seed-v1";
|
|
232
|
+
const SEED_EMAIL = "dev-seed@cinatra.local";
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Provision (or re-authenticate) the throwaway seed user on the LOOPBACK
|
|
236
|
+
* registry and return a fresh publish token. Two-step:
|
|
237
|
+
* 1. Anonymous PUT to the couchdb adduser endpoint — creates the user the
|
|
238
|
+
* first time (Verdaccio enables anonymous registration by default).
|
|
239
|
+
* 2. If the user already exists (409 "already registered"), PUT again WITH
|
|
240
|
+
* Basic auth (the npm-login path) to mint a fresh token.
|
|
241
|
+
* Returns `null` on any failure (the caller then skips the whole step
|
|
242
|
+
* loud-but-non-fatally). NEVER include a response body in surfaced text — it may
|
|
243
|
+
* reflect the password back.
|
|
244
|
+
*/
|
|
245
|
+
async function provisionSeedToken(registryUrl) {
|
|
246
|
+
const base = registryUrl.replace(/\/$/, "");
|
|
247
|
+
const url = `${base}/-/user/org.couchdb.user:${encodeURIComponent(SEED_USER)}`;
|
|
248
|
+
const body = {
|
|
249
|
+
_id: `org.couchdb.user:${SEED_USER}`,
|
|
250
|
+
name: SEED_USER,
|
|
251
|
+
password: SEED_PASSWORD,
|
|
252
|
+
email: SEED_EMAIL,
|
|
253
|
+
type: "user",
|
|
254
|
+
roles: [],
|
|
255
|
+
date: new Date().toISOString(),
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// Step 1 — anonymous create (first-run).
|
|
259
|
+
try {
|
|
260
|
+
const res = await fetch(url, {
|
|
261
|
+
method: "PUT",
|
|
262
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
263
|
+
body: JSON.stringify(body),
|
|
264
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
265
|
+
});
|
|
266
|
+
if (res.status === 201 || res.status === 200) {
|
|
267
|
+
const parsed = await res.json().catch(() => null);
|
|
268
|
+
if (parsed && typeof parsed.token === "string" && parsed.token) return parsed.token;
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
// Any non-409 failure (e.g. registration disabled) → give up.
|
|
272
|
+
if (res.status !== 409) return null;
|
|
273
|
+
} catch {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Step 2 — the user already exists; re-authenticate with Basic auth (npm
|
|
278
|
+
// login) using the deterministic seed password to mint a fresh token.
|
|
279
|
+
try {
|
|
280
|
+
const basic = Buffer.from(`${SEED_USER}:${SEED_PASSWORD}`).toString("base64");
|
|
281
|
+
const res = await fetch(url, {
|
|
282
|
+
method: "PUT",
|
|
283
|
+
headers: {
|
|
284
|
+
authorization: `Basic ${basic}`,
|
|
285
|
+
"content-type": "application/json",
|
|
286
|
+
accept: "application/json",
|
|
287
|
+
},
|
|
288
|
+
body: JSON.stringify({ name: SEED_USER, password: SEED_PASSWORD }),
|
|
289
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
290
|
+
});
|
|
291
|
+
if (res.status === 201 || res.status === 200) {
|
|
292
|
+
const parsed = await res.json().catch(() => null);
|
|
293
|
+
if (parsed && typeof parsed.token === "string" && parsed.token) return parsed.token;
|
|
294
|
+
}
|
|
295
|
+
return null;
|
|
296
|
+
} catch {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* `npm pack` the package dir into `outDir` and return the absolute tarball path
|
|
303
|
+
* (no publish). Used for version-skew integrity comparison. Returns null on
|
|
304
|
+
* failure.
|
|
305
|
+
*/
|
|
306
|
+
function packTarball(dir, outDir, registryUrl, userconfigPath) {
|
|
307
|
+
const result = spawnSync(
|
|
308
|
+
"npm",
|
|
309
|
+
[
|
|
310
|
+
"pack",
|
|
311
|
+
"--pack-destination",
|
|
312
|
+
outDir,
|
|
313
|
+
"--registry",
|
|
314
|
+
registryUrl,
|
|
315
|
+
// Pure-source first-party packages — no pack lifecycle hooks are needed,
|
|
316
|
+
// and ignoring scripts keeps a malicious/accidental prepack out of setup.
|
|
317
|
+
"--ignore-scripts",
|
|
318
|
+
],
|
|
319
|
+
{
|
|
320
|
+
cwd: dir,
|
|
321
|
+
encoding: "utf8",
|
|
322
|
+
env: { ...process.env, NPM_CONFIG_USERCONFIG: userconfigPath },
|
|
323
|
+
timeout: PUBLISH_TIMEOUT_MS,
|
|
324
|
+
},
|
|
325
|
+
);
|
|
326
|
+
if (result.status !== 0) return null;
|
|
327
|
+
// npm pack prints the produced filename on the last non-empty stdout line.
|
|
328
|
+
const lines = (result.stdout || "")
|
|
329
|
+
.split("\n")
|
|
330
|
+
.map((l) => l.trim())
|
|
331
|
+
.filter(Boolean);
|
|
332
|
+
const file = lines[lines.length - 1];
|
|
333
|
+
if (!file) return null;
|
|
334
|
+
const abs = path.join(outDir, path.basename(file));
|
|
335
|
+
return existsSync(abs) ? abs : null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** sha512 base64 integrity string (`sha512-<base64>`) for a tarball file. */
|
|
339
|
+
function tarballIntegrity(tarballPath) {
|
|
340
|
+
const bytes = readFileSync(tarballPath);
|
|
341
|
+
const digest = createHash("sha512").update(bytes).digest("base64");
|
|
342
|
+
return `sha512-${digest}`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Seed every on-disk first-party extension into the LOCAL bundled Verdaccio.
|
|
347
|
+
*
|
|
348
|
+
* Loud-but-non-fatal: returns a summary and may set `process.exitCode = 1` on a
|
|
349
|
+
* meaningful failure/skew, but NEVER throws past the caller and NEVER aborts
|
|
350
|
+
* setup. Returns `{ status, registryUrl, published, skipped, failed, skew }`.
|
|
351
|
+
*
|
|
352
|
+
* `status`:
|
|
353
|
+
* - "skipped-not-loopback" registry target is not loopback → refused
|
|
354
|
+
* - "skipped-unreachable" local Verdaccio not up → nothing to do
|
|
355
|
+
* - "skipped-no-auth" could not self-register a publish user
|
|
356
|
+
* - "skipped-empty" no publishable extensions on disk
|
|
357
|
+
* - "ok" ran (see counts)
|
|
358
|
+
*/
|
|
359
|
+
export async function seedLocalRegistryExtensions({
|
|
360
|
+
repoRoot,
|
|
361
|
+
registryUrl = LOCAL_REGISTRY_URL,
|
|
362
|
+
} = {}) {
|
|
363
|
+
const summary = {
|
|
364
|
+
status: "ok",
|
|
365
|
+
registryUrl,
|
|
366
|
+
published: [],
|
|
367
|
+
skipped: [],
|
|
368
|
+
failed: [],
|
|
369
|
+
skew: [],
|
|
370
|
+
divergentVersion: [],
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
// GUARDRAIL: loopback-only. Refuse any non-loopback publish target outright.
|
|
374
|
+
if (!isLoopbackRegistryUrl(registryUrl)) {
|
|
375
|
+
summary.status = "skipped-not-loopback";
|
|
376
|
+
console.warn(
|
|
377
|
+
`\n⚠ Local registry seed SKIPPED: publish target '${registryUrl}' is not a loopback ` +
|
|
378
|
+
`address. This dev seed only ever publishes to the local bundled Verdaccio.\n`,
|
|
379
|
+
);
|
|
380
|
+
return summary;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// GUARDRAIL: reachability. Verdaccio down → skip the whole step.
|
|
384
|
+
if (!(await isRegistryReachable(registryUrl))) {
|
|
385
|
+
summary.status = "skipped-unreachable";
|
|
386
|
+
console.log(
|
|
387
|
+
`- Local registry seed: skipped (Verdaccio not reachable at ${registryUrl}; ` +
|
|
388
|
+
`start the docker stack and re-run \`cinatra setup dev\` to seed bundled extensions).`,
|
|
389
|
+
);
|
|
390
|
+
return summary;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const extensions = enumeratePublishableExtensions(repoRoot);
|
|
394
|
+
if (extensions.length === 0) {
|
|
395
|
+
summary.status = "skipped-empty";
|
|
396
|
+
console.log("- Local registry seed: no publishable on-disk extensions found.");
|
|
397
|
+
return summary;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// GUARDRAIL: temp auth only. Self-register and write the token into a temp
|
|
401
|
+
// userconfig, removed in `finally` — the real ~/.npmrc is never touched.
|
|
402
|
+
const token = await provisionSeedToken(registryUrl);
|
|
403
|
+
if (!token) {
|
|
404
|
+
summary.status = "skipped-no-auth";
|
|
405
|
+
console.warn(
|
|
406
|
+
"\n⚠ Local registry seed SKIPPED: could not provision a publish user on the local " +
|
|
407
|
+
"Verdaccio (registration may be disabled). Bundled extensions will not be seeded.\n",
|
|
408
|
+
);
|
|
409
|
+
process.exitCode = 1;
|
|
410
|
+
return summary;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
let tmpDir;
|
|
414
|
+
try {
|
|
415
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), "cinatra-seed-registry-"));
|
|
416
|
+
const userconfigPath = path.join(tmpDir, ".npmrc");
|
|
417
|
+
const host = new URL(registryUrl).host;
|
|
418
|
+
writeFileSync(
|
|
419
|
+
userconfigPath,
|
|
420
|
+
`//${host}/:_authToken=${token}\nregistry=${registryUrl.replace(/\/$/, "")}/\n`,
|
|
421
|
+
{ mode: 0o600 },
|
|
422
|
+
);
|
|
423
|
+
for (const ext of extensions) {
|
|
424
|
+
const id = `${ext.name}@${ext.version}`;
|
|
425
|
+
const packument = await fetchPackument(registryUrl, ext.name);
|
|
426
|
+
const existing = packument?.versions?.[ext.version];
|
|
427
|
+
// IDEMPOTENT: already-present exact version → skip (or skew-check).
|
|
428
|
+
if (existing) {
|
|
429
|
+
// VERSION-SKEW DETECTION: compare local packed bytes to the registry
|
|
430
|
+
// tarball integrity. A mismatch means the on-disk source diverged from
|
|
431
|
+
// the already-published version — warn loudly, do NOT republish.
|
|
432
|
+
const registryIntegrity =
|
|
433
|
+
typeof existing.dist?.integrity === "string" ? existing.dist.integrity : null;
|
|
434
|
+
if (registryIntegrity) {
|
|
435
|
+
const packed = packTarball(ext.dir, tmpDir, registryUrl, userconfigPath);
|
|
436
|
+
if (packed) {
|
|
437
|
+
const localIntegrity = tarballIntegrity(packed);
|
|
438
|
+
try {
|
|
439
|
+
rmSync(packed, { force: true });
|
|
440
|
+
} catch {
|
|
441
|
+
/* ignore */
|
|
442
|
+
}
|
|
443
|
+
if (localIntegrity !== registryIntegrity) {
|
|
444
|
+
summary.skew.push(id);
|
|
445
|
+
console.warn(
|
|
446
|
+
`\n⚠ Local registry seed: ${id} is already published but the on-disk source ` +
|
|
447
|
+
`differs from the published tarball. NOT republishing the same version. ` +
|
|
448
|
+
`Purge/reset the local Verdaccio or bump the extension version to refresh it.\n`,
|
|
449
|
+
);
|
|
450
|
+
process.exitCode = 1;
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
summary.skipped.push(id);
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// DIVERGENT VERSION (dirty-registry tolerance): the package exists on the
|
|
460
|
+
// registry but NOT at the on-disk version, AND a version >= the on-disk
|
|
461
|
+
// version is already published (e.g. a higher version from a past ad-hoc
|
|
462
|
+
// publish). npm would reject the lower `latest` publish; this is not a
|
|
463
|
+
// fresh-instance failure, so record it as an informational skip (NON-fatal)
|
|
464
|
+
// and leave the existing version(s) in place.
|
|
465
|
+
//
|
|
466
|
+
// IMPORTANT: when only LOWER versions exist (e.g. the on-disk version was
|
|
467
|
+
// bumped since the last seed), this is FALSE — we fall through and publish
|
|
468
|
+
// the new version, so a version bump actually lands. On a truly fresh
|
|
469
|
+
// instance the packument is 404 → `packument` is null → we publish.
|
|
470
|
+
if (registryHasAtLeast(packument, ext.version)) {
|
|
471
|
+
const others = Object.keys(packument.versions).join(", ");
|
|
472
|
+
summary.divergentVersion.push(id);
|
|
473
|
+
console.log(
|
|
474
|
+
`- Local registry seed: ${id} not published — the local registry already has ` +
|
|
475
|
+
`${ext.name} at a version >= ${ext.version} (${others}). Leaving the existing version(s) in place.`,
|
|
476
|
+
);
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// PUBLISH: `npm publish <dir>` against the loopback registry with the temp
|
|
481
|
+
// userconfig. Per-package failure warns + continues (loud-but-non-fatal).
|
|
482
|
+
const result = spawnSync(
|
|
483
|
+
"npm",
|
|
484
|
+
[
|
|
485
|
+
"publish",
|
|
486
|
+
ext.dir,
|
|
487
|
+
"--registry",
|
|
488
|
+
registryUrl,
|
|
489
|
+
// default access keeps scoped packages public on Verdaccio.
|
|
490
|
+
"--access",
|
|
491
|
+
"public",
|
|
492
|
+
// Pure-source first-party packages — no publish lifecycle hooks are
|
|
493
|
+
// needed; ignoring scripts keeps a prepublish hook out of setup.
|
|
494
|
+
"--ignore-scripts",
|
|
495
|
+
],
|
|
496
|
+
{
|
|
497
|
+
encoding: "utf8",
|
|
498
|
+
env: { ...process.env, NPM_CONFIG_USERCONFIG: userconfigPath },
|
|
499
|
+
timeout: PUBLISH_TIMEOUT_MS,
|
|
500
|
+
},
|
|
501
|
+
);
|
|
502
|
+
if (result.status === 0) {
|
|
503
|
+
summary.published.push(id);
|
|
504
|
+
} else {
|
|
505
|
+
summary.failed.push(id);
|
|
506
|
+
const stderr = (result.stderr || "").trim().split("\n").slice(-3).join("\n");
|
|
507
|
+
console.warn(`\n⚠ Local registry seed: failed to publish ${id}\n ${stderr}\n`);
|
|
508
|
+
process.exitCode = 1;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
console.log(
|
|
513
|
+
`- Local registry seed: ${summary.published.length} published, ` +
|
|
514
|
+
`${summary.skipped.length} already present, ${summary.failed.length} failed` +
|
|
515
|
+
(summary.divergentVersion.length
|
|
516
|
+
? `, ${summary.divergentVersion.length} different-version (left as-is)`
|
|
517
|
+
: "") +
|
|
518
|
+
(summary.skew.length ? `, ${summary.skew.length} version-skew (NOT republished)` : "") +
|
|
519
|
+
` (bundled extensions → ${registryUrl}).`,
|
|
520
|
+
);
|
|
521
|
+
} catch (err) {
|
|
522
|
+
// Any unexpected escape is loud-but-non-fatal — setup is not rolled back.
|
|
523
|
+
console.warn(
|
|
524
|
+
`\n⚠ Local registry seed encountered an error:\n ${err && err.message ? err.message : err}\n`,
|
|
525
|
+
);
|
|
526
|
+
process.exitCode = 1;
|
|
527
|
+
} finally {
|
|
528
|
+
if (tmpDir) {
|
|
529
|
+
try {
|
|
530
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
531
|
+
} catch {
|
|
532
|
+
/* best-effort cleanup */
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return summary;
|
|
538
|
+
}
|