@jskit-ai/release 0.0.0-stage → 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,7 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "../src/release.mjs";
3
+
4
+ main().catch((error) => {
5
+ process.stderr.write(`jskit-release failed: ${error instanceof Error ? error.message : String(error)}\n`);
6
+ process.exitCode = 1;
7
+ });
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@jskit-ai/release",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Selective npm release preparation, publication and dependency pinning for package repositories.",
5
+ "type": "module",
6
+ "files": [
7
+ "bin",
8
+ "src"
9
+ ],
10
+ "bin": {
11
+ "jskit-release": "bin/jskit-release.mjs"
12
+ },
13
+ "exports": {
14
+ ".": "./src/release.mjs",
15
+ "./package.json": "./package.json"
16
+ },
17
+ "scripts": {
18
+ "test": "node --test --test-concurrency=1"
19
+ },
20
+ "dependencies": {
21
+ "tar": "7.5.22"
22
+ },
23
+ "engines": {
24
+ "node": "^22.13.0 || ^24.0.0 || ^26.0.0"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/mobily-enterprises/jskit-ai.git",
32
+ "directory": "tooling/jskit-release"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/mobily-enterprises/jskit-ai/issues"
36
+ },
37
+ "homepage": "https://github.com/mobily-enterprises/jskit-ai/tree/main/tooling/jskit-release#readme",
38
+ "keywords": [
39
+ "jskit",
40
+ "release",
41
+ "npm",
42
+ "publish"
43
+ ]
44
+ }
@@ -0,0 +1,254 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { copyFile, lstat, mkdtemp, readdir, readFile, readlink } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { list } from "tar";
7
+
8
+ const execute = promisify(execFile);
9
+
10
+ async function npmJson(args, { cwd, env = process.env, allowMissing = false } = {}) {
11
+ let stdout;
12
+ try {
13
+ ({ stdout } = await execute("npm", args, {
14
+ cwd, env, encoding: "utf8", maxBuffer: 16 * 1024 * 1024, timeout: 120_000
15
+ }));
16
+ } catch (error) {
17
+ let detail;
18
+ try { detail = JSON.parse(error.stdout || ""); } catch { /* npm did not return JSON. */ }
19
+ if (allowMissing && error.code === 1 && detail?.error?.code === "E404") return null;
20
+ throw new Error(`npm ${args[0]} failed (${detail?.error?.code || error.code}). Registry state is unknown.`, { cause: error });
21
+ }
22
+ try {
23
+ const value = JSON.parse(stdout);
24
+ if (value === null) throw new Error("Missing JSON value.");
25
+ return value;
26
+ }
27
+ catch { throw new Error(`npm ${args[0]} returned invalid JSON. Registry state is unknown.`); }
28
+ }
29
+
30
+ function matchesIntegrity(bytes, integrity) {
31
+ for (const algorithm of ["sha512", "sha384", "sha256", "sha1"]) {
32
+ const hashes = String(integrity || "").split(/\s+/u)
33
+ .filter(value => value.startsWith(`${algorithm}-`))
34
+ .map(value => value.slice(algorithm.length + 1));
35
+ if (hashes.length) {
36
+ const actual = createHash(algorithm).update(bytes).digest("base64");
37
+ return hashes.includes(actual);
38
+ }
39
+ }
40
+ throw new Error("Registry metadata has no supported tarball integrity hash.");
41
+ }
42
+
43
+ async function archiveContents(file) {
44
+ const contents = new Map();
45
+ let invalid = "";
46
+ await list({
47
+ file, strict: true,
48
+ onReadEntry(entry) {
49
+ if (entry.type === "Directory") return;
50
+ const name = entry.path.replace(/^\.\//u, "");
51
+ if (!name.startsWith("package/") || name.split("/").includes("..") || contents.has(name)) {
52
+ invalid = `Invalid or duplicate package archive path: ${name}`;
53
+ return;
54
+ }
55
+ const type = entry.type === "OldFile" ? "File" : entry.type;
56
+ if (!["File", "SymbolicLink", "Link"].includes(type)) {
57
+ invalid = `Unsupported package archive entry: ${name} (${type})`;
58
+ return;
59
+ }
60
+ const record = { type, mode: entry.mode & 0o7777, link: entry.linkpath || "", hash: "" };
61
+ contents.set(name, record);
62
+ const hash = createHash("sha512");
63
+ entry.on("data", chunk => hash.update(chunk));
64
+ entry.on("end", () => { record.hash = hash.digest("base64"); });
65
+ }
66
+ });
67
+ if (invalid) throw new Error(invalid);
68
+ if (!contents.has("package/package.json")) throw new Error("Package archive has no package.json.");
69
+ return contents;
70
+ }
71
+
72
+ async function archiveManifest(file) {
73
+ let contents = "";
74
+ await list({
75
+ file, strict: true,
76
+ onReadEntry(entry) {
77
+ if (entry.path.replace(/^\.\//u, "") !== "package/package.json") return entry.resume();
78
+ entry.on("data", chunk => { contents += chunk.toString("utf8"); });
79
+ }
80
+ });
81
+ try { return JSON.parse(contents); } catch { throw new Error(`${file}: package archive has no readable package.json.`); }
82
+ }
83
+
84
+ // A package whose published artifact is not plain `npm pack` output declares
85
+ // `jskitRelease.pack`: a command run in the package directory that prints the
86
+ // tarball path (or JSON {"tarball": path}) as its last stdout line. Its other
87
+ // output is forwarded to stderr.
88
+ async function runPackCommand(record, env) {
89
+ const command = String(record.packageJson.jskitRelease?.pack || "");
90
+ const output = await new Promise((resolve, reject) => {
91
+ const child = spawn(command, { cwd: record.dir, env, shell: true, stdio: ["ignore", "pipe", "inherit"] });
92
+ let stdout = "";
93
+ child.stdout.on("data", chunk => { stdout += chunk; process.stderr.write(chunk); });
94
+ child.on("error", reject);
95
+ child.on("close", code => code === 0 ? resolve(stdout)
96
+ : reject(new Error(`${record.name}: pack command \`${command}\` failed (code=${code}).`)));
97
+ });
98
+ const line = output.trim().split("\n").at(-1)?.trim() || "";
99
+ let tarball = line;
100
+ try {
101
+ const value = JSON.parse(line);
102
+ tarball = typeof value === "string" ? value : value?.tarball;
103
+ } catch { /* A plain path. */ }
104
+ if (!tarball) throw new Error(`${record.name}: pack command \`${command}\` printed no tarball path.`);
105
+ return path.resolve(record.dir, tarball);
106
+ }
107
+
108
+ // Directory snapshots invalidate only invocation-local pack reuse. Equality with
109
+ // npm still comes from the actual package archive, never this source snapshot.
110
+ async function snapshotFiles(root, relative = "", files = new Map()) {
111
+ for (const entry of await readdir(path.join(root, relative), { withFileTypes: true })) {
112
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
113
+ const name = path.join(relative, entry.name);
114
+ if (entry.isDirectory()) await snapshotFiles(root, name, files);
115
+ else {
116
+ const file = path.join(root, name);
117
+ const info = await lstat(file);
118
+ files.set(name, entry.isSymbolicLink()
119
+ ? `link:${await readlink(file)}`
120
+ : `${info.mode & 0o7777}:${createHash("sha512").update(await readFile(file)).digest("base64")}`);
121
+ }
122
+ }
123
+ return files;
124
+ }
125
+
126
+ function sameSnapshot(left, right) {
127
+ return left.size === right.size && [...left].every(([name, value]) => right.get(name) === value);
128
+ }
129
+
130
+ function createPublicationInspection({ directory, registry, configPath = "", env = process.env }) {
131
+ const registryArgs = ["--registry", registry, "--workspaces=false", "--cache", path.join(directory, "npm-cache"), "--offline=false"];
132
+ if (configPath) registryArgs.push("--userconfig", configPath);
133
+ const metadata = new Map();
134
+ const published = new Map();
135
+ const candidates = new Map();
136
+ const stats = { packed: 0, lookups: 0, downloaded: 0, packReused: 0, metadataReused: 0, downloadReused: 0, packMs: 0, lookupMs: 0, downloadMs: 0 };
137
+
138
+ async function version(record, selectedVersion = record.packageJson.version) {
139
+ const spec = `${record.name}@${selectedVersion}`;
140
+ if (metadata.has(spec)) { stats.metadataReused++; return metadata.get(spec); }
141
+ stats.lookups++;
142
+ const started = performance.now();
143
+ let value = await npmJson(["view", spec, "--json", "--prefer-online", ...registryArgs], {
144
+ cwd: record.dir, env, allowMissing: true
145
+ });
146
+ stats.lookupMs += performance.now() - started;
147
+ if (Array.isArray(value) && value.length === 1) [value] = value;
148
+ if (value !== null && (value?.name !== record.name || value?.version !== selectedVersion || !value?.dist?.integrity || !value?.dist?.tarball)) {
149
+ throw new Error(`${spec}: registry returned invalid version metadata.`);
150
+ }
151
+ metadata.set(spec, value);
152
+ return value;
153
+ }
154
+
155
+ async function versions(record) {
156
+ stats.lookups++;
157
+ const started = performance.now();
158
+ const value = await npmJson(["view", record.name, "--json", "--prefer-online", ...registryArgs], {
159
+ cwd: record.dir, env, allowMissing: true
160
+ });
161
+ stats.lookupMs += performance.now() - started;
162
+ const result = value === null ? [] : value.versions;
163
+ if (!Array.isArray(result) || result.some(item => typeof item !== "string" || !/^\d+\.\d+\.\d+(?:[-+].+)?$/u.test(item))) {
164
+ throw new Error(`${record.name}: registry returned invalid version history.`);
165
+ }
166
+ // The response carries a mutable tag's version, but reuse is keyed by its
167
+ // exact immutable identity. Current versions not in that response are read
168
+ // explicitly by version(); latest is never the comparison baseline.
169
+ if (value !== null) {
170
+ if (value.name !== record.name || !result.includes(value.version) || !value.dist?.integrity || !value.dist?.tarball) {
171
+ throw new Error(`${record.name}: registry returned invalid package metadata.`);
172
+ }
173
+ metadata.set(`${record.name}@${value.version}`, value);
174
+ }
175
+ return result;
176
+ }
177
+
178
+ function packedRecord(packed, record, destination) {
179
+ const candidate = packed?.[0];
180
+ if (packed?.length !== 1 || candidate?.name !== record.name || candidate?.version !== record.packageJson.version ||
181
+ !candidate.filename || path.basename(candidate.filename) !== candidate.filename) {
182
+ throw new Error(`${record.name}@${record.packageJson.version}: npm pack returned an unexpected package.`);
183
+ }
184
+ return path.join(destination, candidate.filename);
185
+ }
186
+
187
+ async function pack(record) {
188
+ const snapshot = await snapshotFiles(record.dir);
189
+ const previous = candidates.get(record.dir);
190
+ if (previous && sameSnapshot(previous.snapshot, snapshot)) { stats.packReused++; return previous.tarball; }
191
+ const destination = await mkdtemp(path.join(directory, "candidate-"));
192
+ const started = performance.now();
193
+ let tarball;
194
+ if (record.packageJson.jskitRelease?.pack) {
195
+ const built = await runPackCommand(record, env);
196
+ const manifest = await archiveManifest(built);
197
+ tarball = packedRecord([{ name: manifest.name, version: manifest.version, filename: path.basename(built) }], record, destination);
198
+ await copyFile(built, tarball);
199
+ } else {
200
+ const packed = await npmJson(["pack", "--json", "--pack-destination", destination, ...registryArgs], { cwd: record.dir, env });
201
+ tarball = packedRecord(packed, record, destination);
202
+ }
203
+ stats.packMs += performance.now() - started;
204
+ stats.packed++;
205
+ candidates.set(record.dir, { snapshot: await snapshotFiles(record.dir), tarball });
206
+ return tarball;
207
+ }
208
+
209
+ async function compare(record, tarball) {
210
+ const spec = `${record.name}@${record.packageJson.version}`;
211
+ const value = await version(record);
212
+ if (value === null) return { state: "publish", difference: "" };
213
+ if (matchesIntegrity(await readFile(tarball), value.dist.integrity)) return { state: "skip", difference: "" };
214
+ let baseline = published.get(spec);
215
+ if (!baseline) {
216
+ const destination = await mkdtemp(path.join(directory, "published-"));
217
+ const started = performance.now();
218
+ const downloaded = await npmJson([
219
+ "pack", value.dist.tarball, "--json", "--ignore-scripts", "--prefer-online",
220
+ "--pack-destination", destination, ...registryArgs
221
+ ], { cwd: record.dir, env });
222
+ const file = packedRecord(downloaded, record, destination);
223
+ if (!matchesIntegrity(await readFile(file), value.dist.integrity)) {
224
+ throw new Error(`${spec}: downloaded tarball does not match registry integrity.`);
225
+ }
226
+ baseline = await archiveContents(file);
227
+ published.set(spec, baseline);
228
+ stats.downloadMs += performance.now() - started;
229
+ stats.downloaded++;
230
+ } else stats.downloadReused++;
231
+ const candidate = await archiveContents(tarball);
232
+ const difference = [...new Set([...candidate.keys(), ...baseline.keys()])].sort()
233
+ .find(name => JSON.stringify(candidate.get(name)) !== JSON.stringify(baseline.get(name))) || "";
234
+ return { state: difference ? "conflict" : "skip", difference };
235
+ }
236
+
237
+ return { version, versions, pack, compare, stats };
238
+ }
239
+
240
+ async function planPublication(records, options) {
241
+ const inspection = options.inspection || createPublicationInspection(options);
242
+ const plan = [];
243
+ for (const record of records) {
244
+ const tarball = await inspection.pack(record);
245
+ const { state, difference } = await inspection.compare(record, tarball);
246
+ const { name, version } = record.packageJson;
247
+ if (state === "conflict") throw new Error(`${name}@${version}: published contents differ at ${difference}. Prepare a new version.`);
248
+ plan.push({ name, version, tarball, state });
249
+ if (options.report !== false) process.stdout.write(`${state === "skip" ? "Skip matching release" : "Publish new release"}: ${name}@${version}\n`);
250
+ }
251
+ return plan;
252
+ }
253
+
254
+ export { createPublicationInspection, planPublication, snapshotFiles };
@@ -0,0 +1,767 @@
1
+ import { chmod, cp, lstat, mkdir, mkdtemp, readFile, readdir, readlink, rm, stat, symlink, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
5
+ import { createPublicationInspection, planPublication, snapshotFiles } from "./npm-publication.mjs";
6
+
7
+ // The release target is the working directory (or --cwd). Its publishable
8
+ // packages are every non-private package among the root, its workspaces and
9
+ // `file:` dependencies inside the repository. Repository-specific behavior is
10
+ // declared in the root package.json `jskitRelease` field:
11
+ // prepare command that regenerates derived files during preparation
12
+ // nestedManifests package subdirectories whose package.json pins follow the release
13
+ // and per package in its own `jskitRelease` field:
14
+ // pack command printing a custom tarball path (see npm-publication.mjs)
15
+ // versionFiles JSON files whose `version` must equal the package version
16
+ const USAGE = "Usage: jskit-release <prepare|publish|pin> [--cwd <dir>] [--dry-run] [--registry <url>] [--from <repoDir>...]";
17
+ const DEPENDENCY_FIELDS = Object.freeze([
18
+ "dependencies",
19
+ "devDependencies",
20
+ "peerDependencies",
21
+ "optionalDependencies"
22
+ ]);
23
+ const DEFAULT_REGISTRY = "https://registry.npmjs.org";
24
+
25
+ function parseArgs(argv = []) {
26
+ const args = [...argv];
27
+ const intent = String(args.shift() || "").trim();
28
+ if (!["prepare", "publish", "pin"].includes(intent)) {
29
+ throw new Error(USAGE);
30
+ }
31
+
32
+ const options = {
33
+ intent,
34
+ cwd: process.cwd(),
35
+ dryRun: false,
36
+ registry: DEFAULT_REGISTRY,
37
+ from: []
38
+ };
39
+
40
+ while (args.length > 0) {
41
+ const argument = String(args.shift() || "");
42
+ if (argument === "--dry-run") {
43
+ options.dryRun = true;
44
+ continue;
45
+ }
46
+ const [name, inline] = argument.startsWith("--") && argument.includes("=")
47
+ ? [argument.slice(0, argument.indexOf("=")), argument.slice(argument.indexOf("=") + 1)] : [argument, undefined];
48
+ if (name === "--cwd" || (name === "--from" && intent === "pin")) {
49
+ const value = String(inline ?? args.shift() ?? "").trim();
50
+ if (!value) throw new Error(`${name} requires a directory.`);
51
+ if (name === "--cwd") options.cwd = path.resolve(value);
52
+ else options.from.push(path.resolve(value));
53
+ continue;
54
+ }
55
+ if (argument === "--registry") {
56
+ options.registry = normalizeRegistryUrl(args.shift());
57
+ continue;
58
+ }
59
+ if (argument.startsWith("--registry=")) {
60
+ options.registry = normalizeRegistryUrl(argument.slice("--registry=".length));
61
+ continue;
62
+ }
63
+ throw new Error(`Unknown argument: ${argument}`);
64
+ }
65
+ if (intent === "pin" && options.from.length === 0) {
66
+ throw new Error("pin requires at least one --from <repoDir>.");
67
+ }
68
+
69
+ return options;
70
+ }
71
+
72
+ function normalizeRegistryUrl(value = DEFAULT_REGISTRY) {
73
+ const registry = String(value || "").trim();
74
+ if (!registry) {
75
+ throw new Error("--registry requires a URL.");
76
+ }
77
+ const withScheme = /^[a-z]+:\/\//iu.test(registry) ? registry : `https://${registry}`;
78
+ return withScheme.replace(/\/+$/u, "");
79
+ }
80
+
81
+ async function fileExists(absolutePath) {
82
+ try {
83
+ await stat(absolutePath);
84
+ return true;
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ async function readJsonFile(absolutePath) {
91
+ return JSON.parse(await readFile(absolutePath, "utf8"));
92
+ }
93
+
94
+ function serializeJson(value) {
95
+ return `${JSON.stringify(value, null, 2)}\n`;
96
+ }
97
+
98
+ function toPosixPath(value) {
99
+ return String(value || "").split(path.sep).join("/");
100
+ }
101
+
102
+ // Values written as an exact version or a ^/~ range follow released versions,
103
+ // keeping their prefix. Protocol specs (file:, workspace:, git, ...) and other
104
+ // ranges are left alone.
105
+ function followVersion(declared, version) {
106
+ const match = /^([~^]?)\d+\.\d+\.\d+$/u.exec(String(declared || ""));
107
+ return match ? `${match[1]}${version}` : null;
108
+ }
109
+
110
+ function bumpPatch(version) {
111
+ const match = /^(\d+)\.(\d+)\.(\d+)$/u.exec(String(version || "").trim());
112
+ if (!match) {
113
+ throw new Error(`Package versions must use x.y.z: ${version}`);
114
+ }
115
+ return `${match[1]}.${match[2]}.${Number(match[3]) + 1}`;
116
+ }
117
+
118
+ async function expandWorkspacePattern(repoRoot, pattern) {
119
+ const segments = String(pattern || "").replace(/^\.\//u, "").split("/").filter(Boolean);
120
+ if (!segments.length || segments.includes("**")) return [];
121
+ let directories = [repoRoot];
122
+ for (const segment of segments) {
123
+ const matcher = new RegExp(`^${segment.replace(/[.+^${}()|[\]\\]/gu, "\\$&").replace(/\*/gu, "[^/]*")}$`, "u");
124
+ const next = [];
125
+ for (const directory of directories) {
126
+ for (const entry of await readdir(directory, { withFileTypes: true }).catch(() => [])) {
127
+ if (entry.isDirectory() && entry.name !== "node_modules" && matcher.test(entry.name)) next.push(path.join(directory, entry.name));
128
+ }
129
+ }
130
+ directories = next;
131
+ }
132
+ return directories;
133
+ }
134
+
135
+ // Every manifest the repository owns: the root, its workspaces and `file:`
136
+ // dependency directories inside the repository (followed transitively).
137
+ async function discoverManifests(repoRoot) {
138
+ const root = path.resolve(repoRoot);
139
+ const rootJson = await readJsonFile(path.join(root, "package.json"));
140
+ const patterns = Array.isArray(rootJson.workspaces) ? rootJson.workspaces : rootJson.workspaces?.packages || [];
141
+ const queue = [root];
142
+ for (const pattern of patterns) {
143
+ if (!String(pattern).startsWith("!")) queue.push(...(await expandWorkspacePattern(root, pattern)).sort());
144
+ }
145
+ const seen = new Set();
146
+ const manifests = [];
147
+ while (queue.length > 0) {
148
+ const dir = queue.shift();
149
+ const packageJsonPath = path.join(dir, "package.json");
150
+ if (seen.has(dir) || !(await fileExists(packageJsonPath))) continue;
151
+ seen.add(dir);
152
+ const packageJson = await readJsonFile(packageJsonPath);
153
+ manifests.push({ dir, packageJsonPath, packageJson });
154
+ for (const field of DEPENDENCY_FIELDS) {
155
+ for (const spec of Object.values(packageJson[field] || {})) {
156
+ if (!String(spec).startsWith("file:")) continue;
157
+ const target = path.resolve(dir, String(spec).slice("file:".length));
158
+ if (target === root || target.startsWith(`${root}${path.sep}`)) queue.push(target);
159
+ }
160
+ }
161
+ }
162
+ return manifests;
163
+ }
164
+
165
+ async function discoverReleasePackages(repoRoot) {
166
+ const records = [];
167
+ for (const manifest of await discoverManifests(repoRoot)) {
168
+ const name = String(manifest.packageJson.name || "").trim();
169
+ if (!name || manifest.packageJson.private === true) continue;
170
+ if (records.some(record => record.name === name)) throw new Error(`${name} is declared by more than one package directory.`);
171
+ records.push({ name, ...manifest, localDependencies: new Set() });
172
+ }
173
+ return records.sort((left, right) => left.name.localeCompare(right.name));
174
+ }
175
+
176
+ async function releaseConfig(repoRoot) {
177
+ const config = (await readJsonFile(path.join(repoRoot, "package.json"))).jskitRelease || {};
178
+ return {
179
+ prepare: String(config.prepare || ""),
180
+ nestedManifests: Array.isArray(config.nestedManifests) ? config.nestedManifests.map(String) : []
181
+ };
182
+ }
183
+
184
+ function collectPublishDependencies(packageJson, localNames) {
185
+ const dependencies = new Set();
186
+ for (const field of DEPENDENCY_FIELDS) {
187
+ for (const dependencyName of Object.keys(packageJson?.[field] || {})) {
188
+ if (localNames.has(dependencyName)) {
189
+ dependencies.add(dependencyName);
190
+ }
191
+ }
192
+ }
193
+ return dependencies;
194
+ }
195
+
196
+ function hydrateLocalDependencies(records) {
197
+ const localNames = new Set(records.map((record) => record.name));
198
+ for (const record of records) {
199
+ record.localDependencies = collectPublishDependencies(record.packageJson, localNames);
200
+ }
201
+ }
202
+
203
+ function currentVersionMap(records) {
204
+ return new Map(records.map((record) => {
205
+ const version = String(record.packageJson.version || "").trim();
206
+ if (!/^(\d+)\.(\d+)\.(\d+)$/u.test(version)) {
207
+ throw new Error(`${record.name} must use an exact x.y.z version.`);
208
+ }
209
+ return [record.name, version];
210
+ }));
211
+ }
212
+
213
+ function updatePackageDependencyVersions(packageJson, versions, changes = []) {
214
+ let changed = false;
215
+ for (const field of DEPENDENCY_FIELDS) {
216
+ const dependencies = packageJson?.[field];
217
+ if (!dependencies || typeof dependencies !== "object") {
218
+ continue;
219
+ }
220
+ for (const dependencyName of Object.keys(dependencies)) {
221
+ const declared = dependencies[dependencyName];
222
+ const next = versions.has(dependencyName) ? followVersion(declared, versions.get(dependencyName)) : null;
223
+ if (next === null || declared === next) {
224
+ continue;
225
+ }
226
+ dependencies[dependencyName] = next;
227
+ changes.push(`${field}.${dependencyName} ${declared} -> ${next}`);
228
+ changed = true;
229
+ }
230
+ }
231
+
232
+ return changed;
233
+ }
234
+
235
+ async function collectPatternPackageJsonPaths(packageRoot, nestedManifests = ["patterns", "examples"]) {
236
+ const results = [];
237
+ const directories = [];
238
+ for (const name of nestedManifests) {
239
+ const directory = path.join(packageRoot, name);
240
+ if (await fileExists(directory)) directories.push(directory);
241
+ }
242
+ while (directories.length > 0) {
243
+ const directory = directories.pop();
244
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
245
+ const entryPath = path.join(directory, entry.name);
246
+ if (entry.isDirectory() && entry.name !== "node_modules") {
247
+ directories.push(entryPath);
248
+ } else if (entry.isFile() && entry.name === "package.json") {
249
+ results.push(entryPath);
250
+ }
251
+ }
252
+ }
253
+ return results.sort();
254
+ }
255
+
256
+ function updatePatternPackageJsonContents(contents, versions) {
257
+ const packageJson = JSON.parse(contents);
258
+ const changed = updatePackageDependencyVersions(packageJson, versions);
259
+ return { changed, contents: serializeJson(packageJson) };
260
+ }
261
+
262
+ async function prepareManifests(records, versions, repoRoot, config) {
263
+ const changed = new Set();
264
+ for (const record of records) {
265
+ const dependenciesChanged = updatePackageDependencyVersions(record.packageJson, versions);
266
+ const versionChanged = record.packageJson.version !== versions.get(record.name);
267
+ record.packageJson.version = versions.get(record.name);
268
+ if (dependenciesChanged || versionChanged) {
269
+ await writeFile(record.packageJsonPath, serializeJson(record.packageJson), "utf8");
270
+ changed.add(record.packageJsonPath);
271
+ }
272
+ for (const file of versionFilePaths(record)) {
273
+ const json = await readJsonFile(file);
274
+ if (json.version === record.packageJson.version) continue;
275
+ json.version = record.packageJson.version;
276
+ await writeFile(file, serializeJson(json), "utf8");
277
+ changed.add(file);
278
+ }
279
+ for (const patternPath of await collectPatternPackageJsonPaths(record.dir, config.nestedManifests)) {
280
+ const update = updatePatternPackageJsonContents(await readFile(patternPath, "utf8"), versions);
281
+ if (update.changed) {
282
+ await writeFile(patternPath, update.contents, "utf8");
283
+ changed.add(patternPath);
284
+ }
285
+ }
286
+ }
287
+ const rootPackageJsonPath = path.join(repoRoot, "package.json");
288
+ const rootPackageJson = await readJsonFile(rootPackageJsonPath);
289
+ if (updatePackageDependencyVersions(rootPackageJson, versions)) {
290
+ await writeFile(rootPackageJsonPath, serializeJson(rootPackageJson), "utf8");
291
+ changed.add(rootPackageJsonPath);
292
+ }
293
+ return changed;
294
+ }
295
+
296
+ function versionFilePaths(record) {
297
+ const files = record.packageJson.jskitRelease?.versionFiles || [];
298
+ return files.map(file => path.join(record.dir, String(file)));
299
+ }
300
+
301
+ function topologicalPublishOrder(records) {
302
+ const names = new Set(records.map((record) => record.name));
303
+ const inDegree = new Map([...names].map((name) => [name, 0]));
304
+ const consumers = new Map([...names].map((name) => [name, new Set()]));
305
+
306
+ for (const record of records) {
307
+ for (const dependencyName of record.localDependencies) {
308
+ if (!names.has(dependencyName)) {
309
+ continue;
310
+ }
311
+ consumers.get(dependencyName).add(record.name);
312
+ inDegree.set(record.name, inDegree.get(record.name) + 1);
313
+ }
314
+ }
315
+
316
+ const queue = [...inDegree]
317
+ .filter(([, degree]) => degree === 0)
318
+ .map(([name]) => name)
319
+ .sort();
320
+ const ordered = [];
321
+ while (queue.length > 0) {
322
+ const name = queue.shift();
323
+ ordered.push(name);
324
+ for (const consumer of [...consumers.get(name)].sort()) {
325
+ const degree = inDegree.get(consumer) - 1;
326
+ inDegree.set(consumer, degree);
327
+ if (degree === 0) {
328
+ queue.push(consumer);
329
+ queue.sort();
330
+ }
331
+ }
332
+ }
333
+
334
+ if (ordered.length !== records.length) {
335
+ const cycleMembers = [...inDegree]
336
+ .filter(([, degree]) => degree > 0)
337
+ .map(([name]) => name)
338
+ .sort();
339
+ throw new Error(`Package dependencies must be acyclic: ${cycleMembers.join(", ")}`);
340
+ }
341
+ return ordered;
342
+ }
343
+
344
+ function collectVersionMismatches(packageJson, versions, source) {
345
+ const mismatches = [];
346
+ for (const field of DEPENDENCY_FIELDS) {
347
+ for (const [dependencyName, declaredVersion] of Object.entries(packageJson?.[field] || {})) {
348
+ const expected = versions.has(dependencyName) ? followVersion(declaredVersion, versions.get(dependencyName)) : null;
349
+ if (expected !== null && declaredVersion !== expected) {
350
+ mismatches.push(`${source}#${field}.${dependencyName} is ${declaredVersion}; expected ${expected}`);
351
+ }
352
+ }
353
+ }
354
+ return mismatches;
355
+ }
356
+
357
+ async function validateReleaseState(records, repoRoot) {
358
+ const config = await releaseConfig(repoRoot);
359
+ const versions = currentVersionMap(records);
360
+ const mismatches = [];
361
+ for (const record of records) {
362
+ mismatches.push(...collectVersionMismatches(record.packageJson, versions, record.name));
363
+ for (const file of versionFilePaths(record)) {
364
+ const { version } = await readJsonFile(file);
365
+ if (version !== record.packageJson.version) {
366
+ mismatches.push(`${toPosixPath(path.relative(repoRoot, file))} has version ${version}; expected ${record.packageJson.version}`);
367
+ }
368
+ }
369
+ for (const patternPath of await collectPatternPackageJsonPaths(record.dir, config.nestedManifests)) {
370
+ const contents = await readFile(patternPath, "utf8");
371
+ if (updatePatternPackageJsonContents(contents, versions).changed) {
372
+ mismatches.push(`${toPosixPath(path.relative(repoRoot, patternPath))} has stale package versions`);
373
+ }
374
+ }
375
+ }
376
+
377
+ const rootPackageJson = await readJsonFile(path.join(repoRoot, "package.json"));
378
+ mismatches.push(...collectVersionMismatches(rootPackageJson, versions, "package.json"));
379
+ if (mismatches.length > 0) {
380
+ throw new Error(`Release source is not coordinated:\n- ${mismatches.join("\n- ")}`);
381
+ }
382
+ return versions;
383
+ }
384
+
385
+ async function runCommand(command, args, { cwd, env = process.env } = {}) {
386
+ await new Promise((resolve, reject) => {
387
+ const child = spawn(command, args, { cwd, env, stdio: "inherit" });
388
+ child.on("error", reject);
389
+ child.on("close", (code, signal) => {
390
+ if (code === 0) {
391
+ resolve();
392
+ return;
393
+ }
394
+ reject(new Error(`${command} ${args.join(" ")} failed (code=${code}, signal=${signal || "none"})`));
395
+ });
396
+ });
397
+ }
398
+
399
+ function compareVersions(left, right) {
400
+ const a = left.split(".").map(Number);
401
+ const b = right.split(".").map(Number);
402
+ for (let index = 0; index < 3; index++) if (a[index] !== b[index]) return a[index] - b[index];
403
+ return 0;
404
+ }
405
+
406
+ async function assertNoNewerVersions(records, inspection) {
407
+ for (const record of records) {
408
+ const history = (await inspection.versions(record)).filter(version => /^\d+\.\d+\.\d+$/u.test(version));
409
+ const current = record.packageJson.version;
410
+ const newer = history.filter(version => compareVersions(version, current) > 0).sort(compareVersions).at(-1);
411
+ if (newer) throw new Error(`${record.name}: checkout version ${current} is behind published ${newer}. Update the checkout before preparing or publishing a release.`);
412
+ }
413
+ }
414
+
415
+ async function lockfileNeedsUpdate(records, repoRoot) {
416
+ const file = path.join(repoRoot, "package-lock.json");
417
+ if (!await fileExists(file)) return true;
418
+ const lock = await readJsonFile(file);
419
+ const manifests = [path.join(repoRoot, "package.json"), ...records.map(record => record.packageJsonPath)];
420
+ for (const manifestPath of manifests) {
421
+ const manifest = await readJsonFile(manifestPath);
422
+ const key = toPosixPath(path.relative(repoRoot, path.dirname(manifestPath)));
423
+ const installed = lock.packages?.[key];
424
+ if (!installed || installed.version !== manifest.version) return true;
425
+ for (const field of DEPENDENCY_FIELDS) {
426
+ const expected = Object.entries(manifest[field] || {});
427
+ if (expected.length !== Object.keys(installed[field] || {}).length ||
428
+ expected.some(([name, value]) => installed[field]?.[name] !== value)) return true;
429
+ }
430
+ }
431
+ return false;
432
+ }
433
+
434
+ // Runs the repository's declared prepare command in the staged source and
435
+ // returns the files it changed.
436
+ async function runPrepareCommand(source, config) {
437
+ if (!config.prepare) return new Set();
438
+ const before = await snapshotFiles(source);
439
+ const result = spawnSync(config.prepare, { cwd: source, shell: true, encoding: "utf8", stdio: "pipe" });
440
+ if (result.error) throw result.error;
441
+ if (result.status !== 0) {
442
+ throw new Error(`Release prepare command \`${config.prepare}\` failed with exit code ${result.status}. ${result.stderr || result.stdout || ""}`);
443
+ }
444
+ const after = await snapshotFiles(source);
445
+ return new Set([...new Set([...before.keys(), ...after.keys()])]
446
+ .filter(file => before.get(file) !== after.get(file)).map(file => path.join(source, file)));
447
+ }
448
+
449
+ // Ignored outputs (builds, custom pack artifacts) produced while planning stay
450
+ // in the isolated source; only files Git would track are applied.
451
+ function withoutIgnored(repoRoot, files) {
452
+ if (!files.length) return files;
453
+ const result = spawnSync("git", ["check-ignore", "--stdin"], {
454
+ cwd: repoRoot, input: files.join("\n"), encoding: "utf8"
455
+ });
456
+ if (result.status !== 0 && result.status !== 1) return files;
457
+ const ignored = new Set(result.stdout.split("\n").filter(Boolean));
458
+ return files.filter(file => !ignored.has(file));
459
+ }
460
+
461
+ async function prepareRelease({ cwd: repoRoot, dryRun, registry, report = false }) {
462
+ const started = performance.now();
463
+ const directory = await mkdtemp(path.join(tmpdir(), "jskit-prepare-"));
464
+ const source = path.join(directory, "source");
465
+ try {
466
+ // Both modes plan in isolation. Included ignored files are copied too; only
467
+ // Git, installed dependencies and root scratch/navigation data are omitted.
468
+ await cp(repoRoot, source, {
469
+ recursive: true, verbatimSymlinks: true,
470
+ filter: file => {
471
+ const parts = path.relative(repoRoot, file).split(path.sep);
472
+ return !parts.some(part => part === ".git" || part === "node_modules") &&
473
+ ![".tmp", ".genesis"].includes(parts[0]);
474
+ }
475
+ });
476
+ const before = await snapshotFiles(source);
477
+ if (await fileExists(path.join(repoRoot, "node_modules"))) {
478
+ await symlink(path.join(repoRoot, "node_modules"), path.join(source, "node_modules"), "dir");
479
+ }
480
+ const config = await releaseConfig(source);
481
+ const staged = await discoverReleasePackages(source);
482
+ if (!staged.length) throw new Error(`No publishable packages found in ${repoRoot}.`);
483
+ hydrateLocalDependencies(staged);
484
+ topologicalPublishOrder(staged);
485
+ const current = currentVersionMap(staged);
486
+ const versions = new Map(current);
487
+ const selected = new Map();
488
+ const configPath = String(process.env.NPM_TOKEN || "").trim() ? await createNpmUserConfig(registry, directory, staged) : "";
489
+ const inspection = createPublicationInspection({ directory, registry, configPath });
490
+
491
+ await assertNoNewerVersions(staged, inspection);
492
+
493
+ async function selectChanged(dependencyChanges, generatedChanges) {
494
+ let bumped = 0;
495
+ for (const record of staged) {
496
+ if (selected.has(record.name)) continue;
497
+ if (await inspection.version(record) === null) {
498
+ selected.set(record.name, "unpublished current version");
499
+ continue;
500
+ }
501
+ const { state, difference } = await inspection.compare(record, await inspection.pack(record));
502
+ if (state === "skip") continue;
503
+ const file = path.join(record.dir, difference.replace(/^package\//u, ""));
504
+ const reason = dependencyChanges.has(file) ? "dependency update"
505
+ : generatedChanges.has(file) ? "generated artifact change" : "direct content change";
506
+ const next = bumpPatch(current.get(record.name));
507
+ if (await inspection.version(record, next) !== null) {
508
+ throw new Error(`${record.name}: chosen next version ${next} is already published. Update the checkout before preparing a release.`);
509
+ }
510
+ versions.set(record.name, next);
511
+ selected.set(record.name, reason);
512
+ bumped++;
513
+ }
514
+ return bumped;
515
+ }
516
+
517
+ let changes = await prepareManifests(staged, versions, source, config);
518
+ // Selection only grows, and each published package can be bumped once.
519
+ // Regeneration (for JSKIT: catalog self-version and pattern-owner versions)
520
+ // runs through the repository's declared prepare command.
521
+ for (let pass = 0; pass <= staged.length; pass++) {
522
+ const generated = await runPrepareCommand(source, config);
523
+ if (!await selectChanged(changes, generated)) break;
524
+ if (pass === staged.length) throw new Error("Selective release preparation did not converge.");
525
+ changes = await prepareManifests(staged, versions, source, config);
526
+ }
527
+ await validateReleaseState(staged, source);
528
+ const order = topologicalPublishOrder(staged);
529
+ const byName = new Map(staged.map(record => [record.name, record]));
530
+ const plan = await planPublication(order.map(name => byName.get(name)), { inspection, report });
531
+ const after = await snapshotFiles(source);
532
+ const changedFiles = withoutIgnored(repoRoot, [...new Set([...before.keys(), ...after.keys()])]
533
+ .filter(file => before.get(file) !== after.get(file)));
534
+ for (const record of staged) {
535
+ const old = current.get(record.name);
536
+ const next = versions.get(record.name);
537
+ const selection = selected.get(record.name);
538
+ const state = !selection ? "Keep published" : old === next ? "Keep unpublished" : "New version";
539
+ process.stdout.write(`${state}: ${record.name}@${old}${old !== next ? ` -> ${next}` : ""}${selection ? ` (${selection})` : ""}\n`);
540
+ }
541
+ const missing = plan.filter(entry => entry.state === "publish").length;
542
+ process.stdout.write(`${missing}/${staged.length} packages need publication; ${changedFiles.length} source/generated files would change.\n`);
543
+ const updateLockfile = await lockfileNeedsUpdate(staged, source);
544
+ if (updateLockfile) process.stdout.write("Lockfile refresh required (package-lock-only; no dependency reinstall).\n");
545
+ if (!dryRun && changedFiles.length) {
546
+ // Fail before applying if another writer changed any of these paths.
547
+ const live = await snapshotFiles(repoRoot);
548
+ for (const file of changedFiles) {
549
+ if (live.get(file) !== before.get(file)) throw new Error(`${file} changed during preparation. Retry after that work finishes.`);
550
+ }
551
+ for (const file of changedFiles) {
552
+ const destination = path.join(repoRoot, file);
553
+ if (!after.has(file)) await rm(destination, { force: true });
554
+ else {
555
+ await mkdir(path.dirname(destination), { recursive: true });
556
+ const stagedFile = path.join(source, file);
557
+ const info = await lstat(stagedFile);
558
+ if (info.isSymbolicLink()) {
559
+ await rm(destination, { force: true });
560
+ await symlink(await readlink(stagedFile), destination);
561
+ } else {
562
+ await cp(stagedFile, destination);
563
+ await chmod(destination, info.mode & 0o7777);
564
+ }
565
+ }
566
+ }
567
+ }
568
+ if (!dryRun && updateLockfile) {
569
+ await runCommand("npm", ["install", "--package-lock-only", "--ignore-scripts"], { cwd: repoRoot });
570
+ }
571
+ process.stdout.write(`${dryRun ? "Prepare dry-run complete" : "Release source prepared"}. ${(performance.now() - started).toFixed(0)} ms; ` +
572
+ `${inspection.stats.packed} packs, ${inspection.stats.lookups} registry lookups, ${inspection.stats.downloaded} downloads; ` +
573
+ `${inspection.stats.packReused} packs, ${inspection.stats.metadataReused} lookups, ${inspection.stats.downloadReused} downloads reused.\n`);
574
+ process.stdout.write(`Subprocess time: packing ${inspection.stats.packMs.toFixed(0)} ms, registry metadata ${inspection.stats.lookupMs.toFixed(0)} ms, verified downloads ${inspection.stats.downloadMs.toFixed(0)} ms.\n`);
575
+ return { plan: plan.filter(entry => entry.state === "publish"), changedFiles };
576
+ } finally {
577
+ await rm(directory, { recursive: true, force: true });
578
+ }
579
+ }
580
+
581
+ async function createNpmUserConfig(registry, directory, records = []) {
582
+ const token = String(process.env.NPM_TOKEN || "").trim();
583
+ if (!token) {
584
+ throw new Error("NPM_TOKEN is required for publish.");
585
+ }
586
+ const registryUrl = new URL(registry);
587
+ const registryWithSlash = `${registryUrl.origin}${registryUrl.pathname}`.replace(/\/+$/u, "/");
588
+ const authHost = `${registryUrl.host}${registryUrl.pathname}`.replace(/\/+$/u, "");
589
+ const configPath = path.join(directory, "npmrc");
590
+ const scopes = [...new Set(records.map(record => /^(@[^/]+)\//u.exec(record.name)?.[1]).filter(Boolean))].sort();
591
+ await writeFile(
592
+ configPath,
593
+ `${scopes.map(scope => `${scope}:registry=${registryWithSlash}\n`).join("")}registry=${registryWithSlash}\n//${authHost}/:_authToken=${token}\n`,
594
+ { encoding: "utf8", mode: 0o600 }
595
+ );
596
+ return configPath;
597
+ }
598
+
599
+ function git(repoRoot, args) {
600
+ return execFileSync("git", args, {
601
+ cwd: repoRoot,
602
+ encoding: "utf8",
603
+ stdio: ["ignore", "pipe", "pipe"],
604
+ timeout: 120_000
605
+ }).trim();
606
+ }
607
+
608
+ function assertMainReleaseCheckout(repoRoot) {
609
+ if (git(repoRoot, ["branch", "--show-current"]) !== "main") {
610
+ throw new Error("Publish requires the main branch.");
611
+ }
612
+ if (git(repoRoot, ["status", "--porcelain", "--untracked-files=all"])) {
613
+ throw new Error("Publish requires a clean checkout; commit all release changes first.");
614
+ }
615
+ const head = git(repoRoot, ["rev-parse", "HEAD"]);
616
+ const remoteMain = git(repoRoot, ["ls-remote", "--exit-code", "origin", "refs/heads/main"]).split(/\s+/u)[0];
617
+ if (head !== remoteMain) {
618
+ throw new Error("Publish requires HEAD to match origin's main branch; reconcile and push main first.");
619
+ }
620
+ }
621
+
622
+ // Moved from Vibe64 Online's deploy orchestrator: main can move while a
623
+ // release runs, so the commit is replayed on it and the push retried.
624
+ function commitAndPush(repoRoot, message) {
625
+ if (!git(repoRoot, ["status", "--porcelain"])) return false;
626
+ git(repoRoot, ["add", "-A"]);
627
+ git(repoRoot, ["commit", "--quiet", "-m", message]);
628
+ for (let attempt = 1; ; attempt++) {
629
+ const push = spawnSync("git", ["push", "--quiet", "origin", "HEAD:main"], { cwd: repoRoot, encoding: "utf8" });
630
+ if (push.status === 0) break;
631
+ if (attempt >= 3) throw new Error(`git push failed in ${repoRoot}: ${push.stderr || push.stdout}`);
632
+ process.stdout.write(`${path.basename(repoRoot)}: main moved; rebasing the release commit and retrying\n`);
633
+ git(repoRoot, ["pull", "--quiet", "--rebase", "origin", "main"]);
634
+ }
635
+ process.stdout.write(`${path.basename(repoRoot)}: pushed ${git(repoRoot, ["rev-parse", "--short", "HEAD"])} ${message.split("\n")[0]}\n`);
636
+ return true;
637
+ }
638
+
639
+ function releaseCommitMessage(entries) {
640
+ if (!entries.length) return "Refresh release metadata";
641
+ const names = entries.map(entry => `${entry.name}@${entry.version}`);
642
+ const subject = names.length <= 4 ? names.join(", ") : `${names.slice(0, 3).join(", ")} and ${names.length - 3} more`;
643
+ return `Release ${subject}${names.length > 4 ? `\n\n${names.join("\n")}` : ""}`;
644
+ }
645
+
646
+ async function publishRelease(records, { cwd: repoRoot, dryRun, registry }) {
647
+ if (!dryRun) {
648
+ assertMainReleaseCheckout(repoRoot);
649
+ }
650
+ await validateReleaseState(records, repoRoot);
651
+ const order = topologicalPublishOrder(records);
652
+ const recordsByName = new Map(records.map((record) => [record.name, record]));
653
+ const directory = await mkdtemp(path.join(tmpdir(), "jskit-release-"));
654
+ try {
655
+ const configPath = !dryRun || String(process.env.NPM_TOKEN || "").trim()
656
+ ? await createNpmUserConfig(registry, directory, records) : "";
657
+ if (!dryRun) await runCommand("npm", ["whoami", "--registry", registry, "--userconfig", configPath]);
658
+ const inspection = createPublicationInspection({ directory, registry, configPath });
659
+ await assertNoNewerVersions(records, inspection);
660
+ const plan = await planPublication(order.map(name => recordsByName.get(name)), { inspection });
661
+ for (const entry of plan) {
662
+ if (dryRun || entry.state === "skip") continue;
663
+ const args = [
664
+ "publish",
665
+ entry.tarball,
666
+ "--access",
667
+ "public",
668
+ "--tag",
669
+ "latest",
670
+ "--registry",
671
+ registry,
672
+ "--workspaces=false",
673
+ "--userconfig", configPath
674
+ ];
675
+ await runCommand("npm", args);
676
+ }
677
+ } finally {
678
+ await rm(directory, { recursive: true, force: true });
679
+ }
680
+ process.stdout.write(dryRun ? "Publish dry-run complete.\n" : "Publish complete.\n");
681
+ }
682
+
683
+ // publish: prepare the clean, current main checkout, commit and push the
684
+ // prepared release, then publish it. A dry run only reports the plan.
685
+ async function publishCommand(options) {
686
+ if (options.dryRun) {
687
+ await prepareRelease({ ...options, report: true });
688
+ process.stdout.write("Publish dry-run complete.\n");
689
+ return;
690
+ }
691
+ assertMainReleaseCheckout(options.cwd);
692
+ const { plan } = await prepareRelease(options);
693
+ commitAndPush(options.cwd, releaseCommitMessage(plan));
694
+ const records = await discoverReleasePackages(options.cwd);
695
+ hydrateLocalDependencies(records);
696
+ await publishRelease(records, options);
697
+ }
698
+
699
+ // npm can take minutes to serve a just-published version everywhere.
700
+ // Moved from Vibe64 Online's deploy orchestrator.
701
+ async function installReleasedPins(repoRoot, { attempts = 20, delayMs = Number(process.env.JSKIT_RELEASE_RETRY_DELAY_MS || 30_000) } = {}) {
702
+ for (let attempt = 1; ; attempt++) {
703
+ const result = spawnSync("npm", ["install", "--no-audit", "--no-fund", "--prefer-online"], { cwd: repoRoot, encoding: "utf8" });
704
+ if (result.status === 0) return;
705
+ const output = `${result.stdout || ""}${result.stderr || ""}`;
706
+ if (!/ETARGET|notarget|E404/u.test(output) || attempt >= attempts) {
707
+ process.stderr.write(output);
708
+ throw new Error(`npm install failed in ${repoRoot}.`);
709
+ }
710
+ process.stdout.write(`npm does not serve the new versions yet; retrying in ${Math.round(delayMs / 1000)} s (${attempt}/${attempts})\n`);
711
+ await new Promise(resolve => setTimeout(resolve, delayMs));
712
+ }
713
+ }
714
+
715
+ // pin: make every dependency on a package published from the given
716
+ // repositories follow that package's current version, keeping ^/~, then
717
+ // install, commit and push. Moved from Vibe64 Online's deploy orchestrator.
718
+ async function pinCommand({ cwd: repoRoot, from, dryRun }) {
719
+ const versions = new Map();
720
+ for (const dir of from) {
721
+ for (const record of await discoverReleasePackages(dir)) versions.set(record.name, record.packageJson.version);
722
+ }
723
+ if (!dryRun) assertMainReleaseCheckout(repoRoot);
724
+ const changes = [];
725
+ for (const manifest of await discoverManifests(repoRoot)) {
726
+ const manifestChanges = [];
727
+ if (!updatePackageDependencyVersions(manifest.packageJson, versions, manifestChanges)) continue;
728
+ const relative = toPosixPath(path.relative(repoRoot, manifest.packageJsonPath));
729
+ changes.push(...manifestChanges.map(change => `${relative}: ${change}`));
730
+ if (!dryRun) await writeFile(manifest.packageJsonPath, serializeJson(manifest.packageJson), "utf8");
731
+ }
732
+ if (!changes.length) {
733
+ process.stdout.write(`${path.basename(repoRoot)}: pins already match the released versions\n`);
734
+ return;
735
+ }
736
+ for (const change of changes) process.stdout.write(`${path.basename(repoRoot)}: ${change}\n`);
737
+ if (dryRun) {
738
+ process.stdout.write("Pin dry-run complete.\n");
739
+ return;
740
+ }
741
+ await installReleasedPins(repoRoot);
742
+ commitAndPush(repoRoot, "Consume released packages");
743
+ }
744
+
745
+ async function main(argv = process.argv.slice(2)) {
746
+ const options = parseArgs(argv);
747
+ if (options.intent === "prepare") await prepareRelease(options);
748
+ else if (options.intent === "publish") await publishCommand(options);
749
+ else await pinCommand(options);
750
+ }
751
+
752
+ export {
753
+ assertMainReleaseCheckout,
754
+ bumpPatch,
755
+ collectPublishDependencies,
756
+ collectPatternPackageJsonPaths,
757
+ collectVersionMismatches,
758
+ commitAndPush,
759
+ discoverManifests,
760
+ discoverReleasePackages,
761
+ main,
762
+ parseArgs,
763
+ topologicalPublishOrder,
764
+ updatePackageDependencyVersions,
765
+ updatePatternPackageJsonContents,
766
+ validateReleaseState
767
+ };
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.