@jskit-ai/jskit-catalog 0.2.9 → 0.2.10

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.
@@ -1,15 +1,8 @@
1
- import { spawn } from "node:child_process";
2
- import {
3
- access,
4
- mkdtemp,
5
- readFile,
6
- readdir,
7
- realpath,
8
- rename,
9
- rm,
10
- writeFile
11
- } from "node:fs/promises";
1
+ import { realpath, rename, rm, writeFile } from "node:fs/promises";
12
2
  import path from "node:path";
3
+ import { fileExists, toPosixPath } from "./files.mjs";
4
+ import { readOptionalFile, restoreProjectFiles, runNpmInstall, writeManifestUpdates } from "./project-install.mjs";
5
+ import { discoverProjectManifests, readJsonRecord, serializePackageJson } from "./project-manifests.mjs";
13
6
 
14
7
  const JSKIT_SCOPE_PREFIX = "@jskit-ai/";
15
8
  const CATALOG_PACKAGE_ID = "@jskit-ai/jskit-catalog";
@@ -19,142 +12,9 @@ const DEPENDENCY_FIELDS = Object.freeze([
19
12
  "peerDependencies",
20
13
  "optionalDependencies"
21
14
  ]);
22
- const SKIPPED_DIRECTORY_NAMES = new Set([
23
- ".git",
24
- ".cache",
25
- ".next",
26
- ".nuxt",
27
- ".output",
28
- ".turbo",
29
- "build",
30
- "coverage",
31
- "dist",
32
- "node_modules",
33
- "tmp"
34
- ]);
35
15
  const UPDATE_SCRIPT = "npx --yes @jskit-ai/jskit-catalog@latest update";
36
16
  const CHECK_SCRIPT = "jskit check";
37
17
 
38
- function toPosixPath(value = "") {
39
- return String(value || "").split(path.sep).join("/");
40
- }
41
-
42
- function normalizeWorkspacePatterns(value) {
43
- if (Array.isArray(value)) {
44
- return value;
45
- }
46
- if (value && typeof value === "object" && Array.isArray(value.packages)) {
47
- return value.packages;
48
- }
49
- return [];
50
- }
51
-
52
- function globToRegExp(pattern = "") {
53
- const source = toPosixPath(pattern).replace(/^\.\//u, "").replace(/\/$/u, "");
54
- let expression = "^";
55
-
56
- for (let index = 0; index < source.length; index += 1) {
57
- const character = source[index];
58
- if (character === "*") {
59
- if (source[index + 1] === "*") {
60
- index += 1;
61
- if (source[index + 1] === "/") {
62
- index += 1;
63
- expression += "(?:.*/)?";
64
- } else {
65
- expression += ".*";
66
- }
67
- } else {
68
- expression += "[^/]*";
69
- }
70
- continue;
71
- }
72
- if (character === "?") {
73
- expression += "[^/]";
74
- continue;
75
- }
76
- expression += /[.+^${}()|[\]\\]/u.test(character) ? `\\${character}` : character;
77
- }
78
-
79
- return new RegExp(`${expression}$`, "u");
80
- }
81
-
82
- function createWorkspaceMatcher(workspaces) {
83
- const patterns = normalizeWorkspacePatterns(workspaces)
84
- .map((entry) => String(entry || "").trim())
85
- .filter(Boolean);
86
- const includes = patterns
87
- .filter((entry) => !entry.startsWith("!"))
88
- .map(globToRegExp);
89
- const excludes = patterns
90
- .filter((entry) => entry.startsWith("!"))
91
- .map((entry) => globToRegExp(entry.slice(1)));
92
-
93
- return function matchesWorkspace(relativeDirectory = "") {
94
- const normalized = toPosixPath(relativeDirectory).replace(/^\.\//u, "").replace(/\/$/u, "");
95
- return includes.some((matcher) => matcher.test(normalized)) &&
96
- !excludes.some((matcher) => matcher.test(normalized));
97
- };
98
- }
99
-
100
- async function fileExists(absolutePath) {
101
- try {
102
- await access(absolutePath);
103
- return true;
104
- } catch {
105
- return false;
106
- }
107
- }
108
-
109
- async function readJsonRecord(absolutePath) {
110
- const contents = await readFile(absolutePath, "utf8");
111
- let value;
112
- try {
113
- value = JSON.parse(contents);
114
- } catch (error) {
115
- throw new Error(`${absolutePath} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
116
- }
117
- if (!value || typeof value !== "object" || Array.isArray(value)) {
118
- throw new TypeError(`${absolutePath} must contain a JSON object.`);
119
- }
120
- return { absolutePath, contents, value };
121
- }
122
-
123
- async function collectNestedPackageJsonPaths(directory, results = []) {
124
- for (const entry of await readdir(directory, { withFileTypes: true }).catch(() => [])) {
125
- if (!entry.isDirectory() || SKIPPED_DIRECTORY_NAMES.has(entry.name) || entry.name.startsWith(".")) {
126
- continue;
127
- }
128
- const childDirectory = path.join(directory, entry.name);
129
- const manifestPath = path.join(childDirectory, "package.json");
130
- if (await fileExists(manifestPath)) {
131
- results.push(manifestPath);
132
- }
133
- await collectNestedPackageJsonPaths(childDirectory, results);
134
- }
135
- return results;
136
- }
137
-
138
- async function discoverProjectManifests(projectRoot, additionalManifests = []) {
139
- const root = path.resolve(projectRoot);
140
- const rootManifestPath = path.join(root, "package.json");
141
- if (!(await fileExists(rootManifestPath))) {
142
- throw new Error(`JSKIT project root has no package.json: ${root}`);
143
- }
144
- const rootManifest = await readJsonRecord(rootManifestPath);
145
- const matchesWorkspace = createWorkspaceMatcher(rootManifest.value.workspaces);
146
- const nestedPaths = await collectNestedPackageJsonPaths(root);
147
- const workspacePaths = nestedPaths
148
- .filter((manifestPath) => matchesWorkspace(path.relative(root, path.dirname(manifestPath))))
149
- .sort((left, right) => left.localeCompare(right));
150
- const manifests = [rootManifest];
151
- for (const manifestPath of new Set([...workspacePaths, ...additionalManifests.map((file) => path.resolve(root, file))])) {
152
- if (manifestPath === rootManifestPath) continue;
153
- manifests.push(await readJsonRecord(manifestPath));
154
- }
155
- return Object.freeze(manifests);
156
- }
157
-
158
18
  function normalizeCatalog(catalog = {}) {
159
19
  const releasePackages = catalog?.release?.packages;
160
20
  if (!releasePackages || typeof releasePackages !== "object" || Array.isArray(releasePackages)) {
@@ -177,12 +37,6 @@ function normalizeCatalog(catalog = {}) {
177
37
  return Object.freeze({ versions, singletonPackages });
178
38
  }
179
39
 
180
- function serializePackageJson(value, originalContents = "") {
181
- const indentMatch = /\n([ \t]+)"/u.exec(String(originalContents || ""));
182
- const indentation = indentMatch?.[1] || " ";
183
- return `${JSON.stringify(value, null, indentation)}\n`;
184
- }
185
-
186
40
  function collectManifestDeclarations(manifests, projectRoot) {
187
41
  const declarations = [];
188
42
  for (const manifest of manifests) {
@@ -464,68 +318,6 @@ async function checkInstalledGraph({ catalog, manifestPaths = [], sourcePackages
464
318
  return { ok: issues.length === 0, issues, installations };
465
319
  }
466
320
 
467
- async function writeManifestUpdates(updates = []) {
468
- const temporaryPaths = [];
469
- const committed = [];
470
- try {
471
- for (const [index, update] of updates.entries()) {
472
- const temporaryPath = `${update.manifest.absolutePath}.jskit-${process.pid}-${index}.tmp`;
473
- await writeFile(temporaryPath, update.contents, "utf8");
474
- temporaryPaths.push(temporaryPath);
475
- }
476
- for (const [index, update] of updates.entries()) {
477
- await rename(temporaryPaths[index], update.manifest.absolutePath);
478
- committed.push(update);
479
- }
480
- } catch (error) {
481
- for (const update of committed) {
482
- await writeFile(update.manifest.absolutePath, update.manifest.contents, "utf8");
483
- }
484
- throw error;
485
- } finally {
486
- await Promise.all(temporaryPaths.map((temporaryPath) => rm(temporaryPath, { force: true })));
487
- }
488
- }
489
-
490
- async function readOptionalFile(absolutePath) {
491
- try {
492
- return Object.freeze({
493
- contents: await readFile(absolutePath, "utf8"),
494
- exists: true
495
- });
496
- } catch (error) {
497
- if (error?.code === "ENOENT") {
498
- return Object.freeze({
499
- contents: "",
500
- exists: false
501
- });
502
- }
503
- throw error;
504
- }
505
- }
506
-
507
- async function restoreProjectFiles(updates, packageLockPath, packageLockSnapshot) {
508
- const rollbackUpdates = updates.map((update) => Object.freeze({
509
- contents: update.manifest.contents,
510
- manifest: Object.freeze({
511
- ...update.manifest,
512
- contents: update.contents
513
- })
514
- }));
515
- await writeManifestUpdates(rollbackUpdates);
516
- if (packageLockSnapshot.exists) {
517
- const temporaryPath = `${packageLockPath}.jskit-${process.pid}-rollback.tmp`;
518
- try {
519
- await writeFile(temporaryPath, packageLockSnapshot.contents, "utf8");
520
- await rename(temporaryPath, packageLockPath);
521
- } finally {
522
- await rm(temporaryPath, { force: true });
523
- }
524
- return;
525
- }
526
- await rm(packageLockPath, { force: true });
527
- }
528
-
529
321
  function removeJskitDependencyRecords(dependencies = {}, localPackageNames = new Set()) {
530
322
  for (const [packageId, record] of Object.entries(dependencies || {})) {
531
323
  if (packageId.startsWith(JSKIT_SCOPE_PREFIX) && !localPackageNames.has(packageId)) {
@@ -595,90 +387,12 @@ async function preparePackageLockForInstall(projectRoot, manifests, packageLockP
595
387
  }
596
388
  }
597
389
 
598
- async function spawnNpmInstall(projectRoot) {
599
- await new Promise((resolve, reject) => {
600
- const child = spawn("npm", ["install"], {
601
- cwd: projectRoot,
602
- env: process.env,
603
- stdio: "inherit"
604
- });
605
- child.on("error", reject);
606
- child.on("close", (code, signal) => {
607
- if (code === 0) {
608
- resolve();
609
- return;
610
- }
611
- reject(new Error(`npm install failed (code=${code}, signal=${signal || "none"}).`));
612
- });
613
- });
614
- }
615
-
616
- async function runNpmInstall(projectRoot) {
617
- const nodeModulesPath = path.join(projectRoot, "node_modules");
618
- const hadNodeModules = await fileExists(nodeModulesPath);
619
- const backupRoot = hadNodeModules
620
- ? await mkdtemp(path.join(projectRoot, ".jskit-update-"))
621
- : null;
622
- // Preserve the dependency directory name so development watchers keep excluding it.
623
- const backupPath = backupRoot ? path.join(backupRoot, "node_modules") : null;
624
- if (hadNodeModules) {
625
- try {
626
- await rename(nodeModulesPath, backupPath);
627
- } catch (error) {
628
- await rm(backupRoot, { force: true, recursive: true });
629
- throw error;
630
- }
631
- }
632
-
633
- let settled = false;
634
- const transaction = Object.freeze({
635
- async commit() {
636
- if (settled) {
637
- return;
638
- }
639
- if (backupRoot) {
640
- await rm(backupRoot, { force: true, recursive: true });
641
- }
642
- settled = true;
643
- },
644
- async rollback() {
645
- if (settled) {
646
- return;
647
- }
648
- try {
649
- await rm(nodeModulesPath, { force: true, recursive: true });
650
- if (backupPath) {
651
- await rename(backupPath, nodeModulesPath);
652
- }
653
- } catch (error) {
654
- if (backupPath) {
655
- throw new Error(`Could not restore node_modules; backup retained at ${backupPath}.`, { cause: error });
656
- }
657
- throw error;
658
- }
659
- if (backupRoot) {
660
- await rm(backupRoot, { force: true, recursive: true });
661
- }
662
- settled = true;
663
- }
664
- });
665
-
666
- try {
667
- await spawnNpmInstall(projectRoot);
668
- return transaction;
669
- } catch (error) {
670
- try {
671
- await transaction.rollback();
672
- } catch (rollbackError) {
673
- throw new AggregateError(
674
- [error, rollbackError],
675
- `npm install failed and the previous node_modules directory could not be restored. ${rollbackError.message}`
676
- );
677
- }
678
- throw error;
679
- }
680
- }
681
-
390
+ /**
391
+ * Pins a project's JSKIT dependencies to the catalog and installs them; a
392
+ * failed install or check restores the manifests, lockfile and node_modules.
393
+ * @param {object} options
394
+ * @param {typeof runNpmInstall} [options.installProject] installs the project and returns the transaction that commits or rolls back its node_modules
395
+ */
682
396
  async function updateProject({
683
397
  projectRoot = process.cwd(),
684
398
  catalog,
@@ -775,12 +489,14 @@ async function updateProject({
775
489
  if (!result.ok) {
776
490
  throw new Error(`JSKIT graph remains inconsistent after npm install:\n- ${result.issues.join("\n- ")}`);
777
491
  }
778
- await installTransaction?.commit?.();
492
+ await installTransaction.commit();
779
493
  }
780
494
  } catch (error) {
781
495
  const rollbackErrors = [];
782
496
  try {
783
- await installTransaction?.rollback?.();
497
+ if (installTransaction) {
498
+ await installTransaction.rollback();
499
+ }
784
500
  } catch (rollbackError) {
785
501
  rollbackErrors.push(rollbackError);
786
502
  }
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { access, readdir } from "node:fs/promises";
2
+ import { readdir } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
+ import { fileExists } from "./files.mjs";
5
6
  import {
6
7
  assertUniquePatternIds,
7
8
  discoverPackagePatterns
@@ -49,15 +50,6 @@ const FORBIDDEN_AUTHORING_FIELDS = new Set([
49
50
  "options"
50
51
  ]);
51
52
 
52
- async function fileExists(filePath) {
53
- try {
54
- await access(filePath);
55
- return true;
56
- } catch {
57
- return false;
58
- }
59
- }
60
-
61
53
  function requireStringArray(value, label) {
62
54
  if (!Array.isArray(value) || value.some((item) => !String(item || "").trim())) {
63
55
  throw new Error(`${label} must be an array of non-empty strings.`);
@@ -102,10 +94,12 @@ function validateProviderDefinition(value, label) {
102
94
  validateCapabilityMap(value.requires, `${label}.requires`);
103
95
  validateCapabilityMap(value.optional, `${label}.optional`);
104
96
  validateCapabilityMap(value.provides, `${label}.provides`);
97
+ // readable-check: allow method-probe - input validation: a package's provider export is checked before the catalog lists it
105
98
  if (typeof value.setup !== "function") {
106
99
  throw new Error(`${label}.setup must be a function.`);
107
100
  }
108
101
  for (const lifecycleMethod of ["boot", "shutdown"]) {
102
+ // readable-check: allow method-probe - input validation: a package's provider export is checked before the catalog lists it
109
103
  if (value[lifecycleMethod] != null && typeof value[lifecycleMethod] !== "function") {
110
104
  throw new Error(`${label}.${lifecycleMethod} must be a function or null.`);
111
105
  }
@@ -169,6 +163,7 @@ async function validateProviderList(packageRecord, side) {
169
163
  async function validateMigrations(packageRecord, migrationOwners) {
170
164
  const { packageJson, packageRoot } = packageRecord;
171
165
  const directories = packageJson.jskit?.migrations?.directories;
166
+ validateMigrationsAfter(packageJson);
172
167
  if (typeof directories === "undefined") {
173
168
  return;
174
169
  }
@@ -180,39 +175,75 @@ async function validateMigrations(packageRecord, migrationOwners) {
180
175
  throw new Error(`${packageJson.name} migration directory must stay inside its package.`);
181
176
  }
182
177
  const absoluteDirectory = path.resolve(packageRoot, relativeDirectory);
183
- const entries = await readdir(absoluteDirectory, { withFileTypes: true }).catch(() => null);
184
- if (!entries) {
185
- throw new Error(`${packageJson.name} migration directory is missing: ${relativeDirectory}.`);
178
+ const entries = await validateMigrationDirectory(packageJson.name, absoluteDirectory, relativeDirectory, migrationOwners);
179
+ // A constraints/ subdirectory runs after the application's migrations; see knexMigrationConfig.js.
180
+ if (entries.some((entry) => entry.isDirectory() && entry.name === "constraints")) {
181
+ await validateMigrationDirectory(
182
+ packageJson.name,
183
+ path.join(absoluteDirectory, "constraints"),
184
+ path.posix.join(relativeDirectory, "constraints"),
185
+ migrationOwners
186
+ );
186
187
  }
187
- const migrations = entries.filter((entry) => entry.isFile() && entry.name.endsWith(".cjs"));
188
- if (migrations.length < 1) {
189
- throw new Error(`${packageJson.name} migration directory contains no .cjs migrations.`);
188
+ }
189
+ }
190
+
191
+ // jskit.migrations.after names packages whose migrations the package's own follow; see
192
+ // knexMigrationConfig.js. A catalog package depends on each of them.
193
+ function validateMigrationsAfter(packageJson) {
194
+ const after = packageJson.jskit?.migrations?.after;
195
+ if (typeof after === "undefined") {
196
+ return;
197
+ }
198
+ if (typeof packageJson.jskit.migrations.directories === "undefined") {
199
+ throw new Error(`${packageJson.name}#jskit.migrations.after needs jskit.migrations.directories.`);
200
+ }
201
+ for (const prerequisite of requireStringArray(after, `${packageJson.name}#jskit.migrations.after`)) {
202
+ if (prerequisite === packageJson.name) {
203
+ throw new Error(`${packageJson.name}#jskit.migrations.after names the package itself.`);
190
204
  }
191
- for (const migration of migrations.sort((left, right) => left.name.localeCompare(right.name))) {
192
- const previous = migrationOwners.get(migration.name);
193
- if (previous) {
194
- throw new Error(`Migration filename ${migration.name} is owned by both ${previous} and ${packageJson.name}.`);
195
- }
196
- migrationOwners.set(migration.name, packageJson.name);
205
+ if (!Object.hasOwn(packageJson.dependencies || {}, prerequisite) &&
206
+ !Object.hasOwn(packageJson.peerDependencies || {}, prerequisite)) {
207
+ throw new Error(`${packageJson.name}#jskit.migrations.after names ${prerequisite}, which it does not depend on.`);
208
+ }
209
+ }
210
+ }
197
211
 
198
- const migrationPath = path.join(absoluteDirectory, migration.name);
199
- let namespace;
200
- try {
201
- namespace = await import(pathToFileURL(migrationPath).href);
202
- } catch (error) {
203
- throw new Error(
204
- `${packageJson.name} migration ${migration.name} could not load: ${error instanceof Error ? error.message : String(error)}`,
205
- { cause: error }
206
- );
207
- }
208
- const migrationModule = namespace.default && typeof namespace.default === "object"
209
- ? namespace.default
210
- : namespace;
211
- if (typeof migrationModule.up !== "function" || typeof migrationModule.down !== "function") {
212
- throw new Error(`${packageJson.name} migration ${migration.name} must export up() and down().`);
213
- }
212
+ async function validateMigrationDirectory(packageName, absoluteDirectory, relativeDirectory, migrationOwners) {
213
+ const entries = await readdir(absoluteDirectory, { withFileTypes: true }).catch(() => null);
214
+ if (!entries) {
215
+ throw new Error(`${packageName} migration directory is missing: ${relativeDirectory}.`);
216
+ }
217
+ const migrations = entries.filter((entry) => entry.isFile() && entry.name.endsWith(".cjs"));
218
+ if (migrations.length < 1) {
219
+ throw new Error(`${packageName} migration directory contains no .cjs migrations.`);
220
+ }
221
+ for (const migration of migrations.sort((left, right) => left.name.localeCompare(right.name))) {
222
+ const previous = migrationOwners.get(migration.name);
223
+ if (previous) {
224
+ throw new Error(`Migration filename ${migration.name} is owned by both ${previous} and ${packageName}.`);
225
+ }
226
+ migrationOwners.set(migration.name, packageName);
227
+
228
+ const migrationPath = path.join(absoluteDirectory, migration.name);
229
+ let namespace;
230
+ try {
231
+ namespace = await import(pathToFileURL(migrationPath).href);
232
+ } catch (error) {
233
+ throw new Error(
234
+ `${packageName} migration ${migration.name} could not load: ${error instanceof Error ? error.message : String(error)}`,
235
+ { cause: error }
236
+ );
237
+ }
238
+ const migrationModule = namespace.default && typeof namespace.default === "object"
239
+ ? namespace.default
240
+ : namespace;
241
+ // readable-check: allow method-probe - input validation: a package's migration module is checked before the catalog lists it
242
+ if (typeof migrationModule.up !== "function" || typeof migrationModule.down !== "function") {
243
+ throw new Error(`${packageName} migration ${migration.name} must export up() and down().`);
214
244
  }
215
245
  }
246
+ return entries;
216
247
  }
217
248
 
218
249
  async function validatePackage(packageRecord, localVersions, migrationOwners) {