@warlock.js/core 5.35.0 → 5.37.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/CHANGELOG.md +53 -0
- package/esm/cli/commands/migrate.command.mjs +10 -0
- package/esm/cli/commands/migrate.command.mjs.map +1 -1
- package/esm/cli/commands/release/release-command.action.mjs +245 -0
- package/esm/cli/commands/release/release-command.action.mjs.map +1 -0
- package/esm/cli/commands/release.command.mjs +66 -0
- package/esm/cli/commands/release.command.mjs.map +1 -0
- package/esm/cli/framework-cli-commands.mjs +2 -0
- package/esm/cli/framework-cli-commands.mjs.map +1 -1
- package/esm/database/migrate-action.mjs +24 -2
- package/esm/database/migrate-action.mjs.map +1 -1
- package/esm/dev-server/loader/load-hook.d.mts.map +1 -1
- package/esm/dev-server/loader/load-hook.mjs +6 -0
- package/esm/dev-server/loader/load-hook.mjs.map +1 -1
- package/esm/dev-server/module-loader.mjs +1 -1
- package/esm/dev-server/parse-imports.mjs +1 -1
- package/esm/dev-server/type-generator.mjs +1 -1
- package/esm/e2e/cdp.d.mts.map +1 -1
- package/esm/e2e/cdp.mjs +9 -2
- package/esm/e2e/cdp.mjs.map +1 -1
- package/esm/e2e/page.d.mts +9 -1
- package/esm/e2e/page.d.mts.map +1 -1
- package/esm/e2e/page.mjs +45 -7
- package/esm/e2e/page.mjs.map +1 -1
- package/esm/e2e/types.d.mts +18 -0
- package/esm/e2e/types.d.mts.map +1 -1
- package/esm/e2e/types.mjs.map +1 -1
- package/esm/errors/error-codes.d.mts +24 -0
- package/esm/errors/error-codes.d.mts.map +1 -1
- package/esm/errors/error-codes.mjs +24 -0
- package/esm/errors/error-codes.mjs.map +1 -1
- package/esm/generations/features/test.feature.mjs +7 -7
- package/esm/generations/features/test.feature.mjs.map +1 -1
- package/esm/generations/features/web.feature.mjs +3 -3
- package/esm/generations/features/web.feature.mjs.map +1 -1
- package/esm/index.d.mts +6 -3
- package/esm/index.mjs +4 -2
- package/esm/localization/index.mjs +3 -1
- package/esm/localization/translation-usage-graph.d.mts +49 -0
- package/esm/localization/translation-usage-graph.d.mts.map +1 -0
- package/esm/localization/translation-usage-graph.mjs +128 -0
- package/esm/localization/translation-usage-graph.mjs.map +1 -0
- package/esm/localization/translation-usage.d.mts +85 -0
- package/esm/localization/translation-usage.d.mts.map +1 -0
- package/esm/localization/translation-usage.mjs +307 -0
- package/esm/localization/translation-usage.mjs.map +1 -0
- package/esm/production/build-budgets.d.mts +18 -0
- package/esm/production/build-budgets.d.mts.map +1 -1
- package/esm/production/build-budgets.mjs.map +1 -1
- package/esm/production/route-registration-snapshot.mjs +12 -3
- package/esm/production/route-registration-snapshot.mjs.map +1 -1
- package/esm/release/activate.mjs +261 -0
- package/esm/release/activate.mjs.map +1 -0
- package/esm/release/candidate.mjs +107 -0
- package/esm/release/candidate.mjs.map +1 -0
- package/esm/release/cascade-migration-provider.mjs +32 -0
- package/esm/release/cascade-migration-provider.mjs.map +1 -0
- package/esm/release/config.mjs +60 -0
- package/esm/release/config.mjs.map +1 -0
- package/esm/release/gate.mjs +67 -0
- package/esm/release/gate.mjs.map +1 -0
- package/esm/release/migrations-proof.mjs +100 -0
- package/esm/release/migrations-proof.mjs.map +1 -0
- package/esm/release/prune.mjs +42 -0
- package/esm/release/prune.mjs.map +1 -0
- package/esm/release/report.mjs +25 -0
- package/esm/release/report.mjs.map +1 -0
- package/esm/release/runner.mjs +55 -0
- package/esm/release/runner.mjs.map +1 -0
- package/esm/release/semver.mjs +53 -0
- package/esm/release/semver.mjs.map +1 -0
- package/esm/release/stage.mjs +122 -0
- package/esm/release/stage.mjs.map +1 -0
- package/esm/release/store.mjs +131 -0
- package/esm/release/store.mjs.map +1 -0
- package/esm/release/types.d.mts +102 -0
- package/esm/release/types.d.mts.map +1 -0
- package/esm/release/verify.mjs +94 -0
- package/esm/release/verify.mjs.map +1 -0
- package/esm/router/index.d.mts +2 -2
- package/esm/router/index.mjs +2 -2
- package/esm/router/router.d.mts +4 -0
- package/esm/router/router.d.mts.map +1 -1
- package/esm/router/router.mjs +14 -3
- package/esm/router/router.mjs.map +1 -1
- package/esm/router/types.d.mts +45 -0
- package/esm/router/types.d.mts.map +1 -1
- package/esm/router/unguarded-route-diagnostics.d.mts +6 -0
- package/esm/router/unguarded-route-diagnostics.d.mts.map +1 -1
- package/esm/router/unguarded-route-diagnostics.mjs +10 -2
- package/esm/router/unguarded-route-diagnostics.mjs.map +1 -1
- package/esm/tooling/check/api-route-references.d.mts +16 -0
- package/esm/tooling/check/api-route-references.d.mts.map +1 -0
- package/esm/tooling/check/api-route-references.mjs +146 -0
- package/esm/tooling/check/api-route-references.mjs.map +1 -0
- package/esm/tooling/check/conventions-step.d.mts +6 -3
- package/esm/tooling/check/conventions-step.d.mts.map +1 -1
- package/esm/tooling/check/conventions-step.mjs +19 -10
- package/esm/tooling/check/conventions-step.mjs.map +1 -1
- package/esm/tooling/check/run-check.d.mts.map +1 -1
- package/esm/tooling/check/run-check.mjs +4 -2
- package/esm/tooling/check/run-check.mjs.map +1 -1
- package/esm/tooling/check/security-conventions.mjs +19 -1
- package/esm/tooling/check/security-conventions.mjs.map +1 -1
- package/esm/tooling/check/translation-conventions.mjs +32 -0
- package/esm/tooling/check/translation-conventions.mjs.map +1 -0
- package/esm/tooling/inspect/collect-files.mjs +1 -1
- package/esm/tooling/inspect/collect-routes.mjs +3 -2
- package/esm/tooling/inspect/collect-routes.mjs.map +1 -1
- package/esm/upgrade/codemods/index.mjs +2 -0
- package/esm/upgrade/codemods/index.mjs.map +1 -1
- package/esm/upgrade/codemods/vite-vitest.mjs +170 -0
- package/esm/upgrade/codemods/vite-vitest.mjs.map +1 -0
- package/esm/upgrade/notes.mjs +4 -0
- package/esm/upgrade/notes.mjs.map +1 -1
- package/esm/validation/init.mjs +11 -4
- package/esm/validation/init.mjs.map +1 -1
- package/esm/validation/translate-rule.mjs +63 -0
- package/esm/validation/translate-rule.mjs.map +1 -0
- package/esm/warlock-config/index.d.mts +3 -2
- package/esm/warlock-config/types.d.mts +8 -0
- package/esm/warlock-config/types.d.mts.map +1 -1
- package/llms-full.txt +221 -18
- package/llms.txt +1 -0
- package/package.json +13 -13
- package/skills/deploy-release/SKILL.md +76 -0
- package/skills/index.md +1 -1
- package/skills/register-route/SKILL.md +25 -0
- package/skills/test-service/SKILL.md +8 -8
- package/skills/upgrade-from-5-29/SKILL.md +91 -0
- package/skills/use-localization/SKILL.md +4 -0
- package/skills/warlock-check/SKILL.md +11 -8
- package/skills/warlock-conventions/SKILL.md +1 -1
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { appendLog, readManifest, writeManifest } from "./store.mjs";
|
|
2
|
+
|
|
3
|
+
//#region ../../packages/core/src/release/migrations-proof.ts
|
|
4
|
+
async function snapshot(provider) {
|
|
5
|
+
const tables = [...await provider.listTables()].sort();
|
|
6
|
+
const indexes = {};
|
|
7
|
+
for (const table of tables) indexes[table] = [...await provider.listIndexes(table)].sort();
|
|
8
|
+
return {
|
|
9
|
+
executed: await provider.listExecuted(),
|
|
10
|
+
tables,
|
|
11
|
+
indexes
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function difference(left, right) {
|
|
15
|
+
const other = new Set(right);
|
|
16
|
+
return left.filter((item) => !other.has(item));
|
|
17
|
+
}
|
|
18
|
+
/** What `--prove` would do, without touching the database. */
|
|
19
|
+
async function planMigrationProof(context, provider, target) {
|
|
20
|
+
const pending = await provider.listPending();
|
|
21
|
+
return {
|
|
22
|
+
target,
|
|
23
|
+
pending,
|
|
24
|
+
expected: context.config.migrations.expected ?? pending
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Snapshot the ledger, tables and indexes, run the pending migrations,
|
|
29
|
+
* snapshot again and compare the result with the expected set.
|
|
30
|
+
*
|
|
31
|
+
* The proof passes when exactly the expected migrations were applied, in
|
|
32
|
+
* order, the ledger grew by exactly those names, and every table listed in
|
|
33
|
+
* `release.migrations.expectedTables` exists afterwards. The full before and
|
|
34
|
+
* after snapshots go into the manifest, so the schema change is on record
|
|
35
|
+
* with the release.
|
|
36
|
+
*
|
|
37
|
+
* Run it against a target that is safe to migrate (a staging copy): the
|
|
38
|
+
* migrations really execute.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* const proof = await proveMigrations(context, provider, "staging");
|
|
42
|
+
*/
|
|
43
|
+
async function proveMigrations(context, provider, options) {
|
|
44
|
+
const plan = await planMigrationProof(context, provider, options.target);
|
|
45
|
+
const before = await snapshot(provider);
|
|
46
|
+
const problems = [];
|
|
47
|
+
let applied = [];
|
|
48
|
+
try {
|
|
49
|
+
applied = await provider.runPending();
|
|
50
|
+
} catch (error) {
|
|
51
|
+
problems.push(`Running migrations failed: ${error.message}`);
|
|
52
|
+
}
|
|
53
|
+
const after = await snapshot(provider);
|
|
54
|
+
const ledgerGrowth = difference(after.executed, before.executed);
|
|
55
|
+
if (JSON.stringify(applied) !== JSON.stringify(plan.expected)) problems.push(`Applied [${applied.join(", ")}] but expected [${plan.expected.join(", ")}].`);
|
|
56
|
+
if (JSON.stringify(ledgerGrowth) !== JSON.stringify(plan.expected)) problems.push(`The ledger gained [${ledgerGrowth.join(", ")}] but expected [${plan.expected.join(", ")}].`);
|
|
57
|
+
const removedFromLedger = difference(before.executed, after.executed);
|
|
58
|
+
if (removedFromLedger.length > 0) problems.push(`The ledger lost entries: ${removedFromLedger.join(", ")}.`);
|
|
59
|
+
for (const table of context.config.migrations.expectedTables ?? []) if (!after.tables.includes(table)) problems.push(`Expected table "${table}" is missing.`);
|
|
60
|
+
const indexesChanged = {};
|
|
61
|
+
for (const table of /* @__PURE__ */ new Set([...Object.keys(before.indexes), ...Object.keys(after.indexes)])) {
|
|
62
|
+
const was = before.indexes[table] ?? [];
|
|
63
|
+
const now = after.indexes[table] ?? [];
|
|
64
|
+
const added = difference(now, was);
|
|
65
|
+
const removed = difference(was, now);
|
|
66
|
+
if (added.length > 0 || removed.length > 0) indexesChanged[table] = {
|
|
67
|
+
added,
|
|
68
|
+
removed
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const proof = {
|
|
72
|
+
target: options.target,
|
|
73
|
+
provedAt: context.now().toISOString(),
|
|
74
|
+
passed: problems.length === 0,
|
|
75
|
+
before,
|
|
76
|
+
after,
|
|
77
|
+
expected: plan.expected,
|
|
78
|
+
applied,
|
|
79
|
+
tablesAdded: difference(after.tables, before.tables),
|
|
80
|
+
tablesRemoved: difference(before.tables, after.tables),
|
|
81
|
+
indexesChanged,
|
|
82
|
+
problems
|
|
83
|
+
};
|
|
84
|
+
const manifest = await readManifest(context, options.releaseId);
|
|
85
|
+
await writeManifest(context, {
|
|
86
|
+
...manifest,
|
|
87
|
+
migrations: proof
|
|
88
|
+
});
|
|
89
|
+
await appendLog(context, {
|
|
90
|
+
event: "migrations",
|
|
91
|
+
release: options.releaseId,
|
|
92
|
+
ok: proof.passed,
|
|
93
|
+
detail: proof.passed ? `${applied.length} migration(s) proved` : problems[0]
|
|
94
|
+
});
|
|
95
|
+
return proof;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
//#endregion
|
|
99
|
+
export { planMigrationProof, proveMigrations };
|
|
100
|
+
//# sourceMappingURL=migrations-proof.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"migrations-proof.mjs","names":[],"sources":["../../../../../../../../packages/core/src/release/migrations-proof.ts"],"sourcesContent":["import { appendLog, readManifest, writeManifest, type ReleaseContext } from \"./store\";\nimport type { MigrationProof, MigrationSnapshot } from \"./types\";\n\n/**\n * What the proof needs from a database. Kept driver-agnostic: the default\n * implementation (see `cascade-migration-provider.ts`) reads the migration\n * ledger and the schema through cascade, and tests pass a plain object.\n */\nexport type MigrationProofProvider = {\n /** Names in the migration ledger, in execution order */\n listExecuted(): Promise<string[]>;\n /** Registered migrations that have not run, in the order they will run */\n listPending(): Promise<string[]>;\n /** Tables or collections */\n listTables(): Promise<string[]>;\n /** Index names of one table or collection */\n listIndexes(table: string): Promise<string[]>;\n /** Run the pending migrations; resolves to the names that were applied */\n runPending(): Promise<string[]>;\n};\n\nexport type MigrationPlan = {\n target: string | null;\n pending: string[];\n expected: string[];\n};\n\nasync function snapshot(provider: MigrationProofProvider): Promise<MigrationSnapshot> {\n const tables = [...(await provider.listTables())].sort();\n const indexes: Record<string, string[]> = {};\n\n for (const table of tables) {\n indexes[table] = [...(await provider.listIndexes(table))].sort();\n }\n\n return { executed: await provider.listExecuted(), tables, indexes };\n}\n\nfunction difference(left: string[], right: string[]): string[] {\n const other = new Set(right);\n\n return left.filter((item) => !other.has(item));\n}\n\n/** What `--prove` would do, without touching the database. */\nexport async function planMigrationProof(\n context: ReleaseContext,\n provider: MigrationProofProvider,\n target: string | null,\n): Promise<MigrationPlan> {\n const pending = await provider.listPending();\n\n return { target, pending, expected: context.config.migrations.expected ?? pending };\n}\n\n/**\n * Snapshot the ledger, tables and indexes, run the pending migrations,\n * snapshot again and compare the result with the expected set.\n *\n * The proof passes when exactly the expected migrations were applied, in\n * order, the ledger grew by exactly those names, and every table listed in\n * `release.migrations.expectedTables` exists afterwards. The full before and\n * after snapshots go into the manifest, so the schema change is on record\n * with the release.\n *\n * Run it against a target that is safe to migrate (a staging copy): the\n * migrations really execute.\n *\n * @example\n * const proof = await proveMigrations(context, provider, \"staging\");\n */\nexport async function proveMigrations(\n context: ReleaseContext,\n provider: MigrationProofProvider,\n options: { target: string | null; releaseId: string },\n): Promise<MigrationProof> {\n const plan = await planMigrationProof(context, provider, options.target);\n const before = await snapshot(provider);\n const problems: string[] = [];\n let applied: string[] = [];\n\n try {\n applied = await provider.runPending();\n } catch (error) {\n problems.push(`Running migrations failed: ${(error as Error).message}`);\n }\n\n const after = await snapshot(provider);\n const ledgerGrowth = difference(after.executed, before.executed);\n\n if (JSON.stringify(applied) !== JSON.stringify(plan.expected)) {\n problems.push(`Applied [${applied.join(\", \")}] but expected [${plan.expected.join(\", \")}].`);\n }\n\n if (JSON.stringify(ledgerGrowth) !== JSON.stringify(plan.expected)) {\n problems.push(\n `The ledger gained [${ledgerGrowth.join(\", \")}] but expected [${plan.expected.join(\", \")}].`,\n );\n }\n\n const removedFromLedger = difference(before.executed, after.executed);\n\n if (removedFromLedger.length > 0) {\n problems.push(`The ledger lost entries: ${removedFromLedger.join(\", \")}.`);\n }\n\n for (const table of context.config.migrations.expectedTables ?? []) {\n if (!after.tables.includes(table)) problems.push(`Expected table \"${table}\" is missing.`);\n }\n\n const indexesChanged: MigrationProof[\"indexesChanged\"] = {};\n\n for (const table of new Set([...Object.keys(before.indexes), ...Object.keys(after.indexes)])) {\n const was = before.indexes[table] ?? [];\n const now = after.indexes[table] ?? [];\n const added = difference(now, was);\n const removed = difference(was, now);\n\n if (added.length > 0 || removed.length > 0) indexesChanged[table] = { added, removed };\n }\n\n const proof: MigrationProof = {\n target: options.target,\n provedAt: context.now().toISOString(),\n passed: problems.length === 0,\n before,\n after,\n expected: plan.expected,\n applied,\n tablesAdded: difference(after.tables, before.tables),\n tablesRemoved: difference(before.tables, after.tables),\n indexesChanged,\n problems,\n };\n\n const manifest = await readManifest(context, options.releaseId);\n\n await writeManifest(context, { ...manifest, migrations: proof });\n await appendLog(context, {\n event: \"migrations\",\n release: options.releaseId,\n ok: proof.passed,\n detail: proof.passed ? `${applied.length} migration(s) proved` : problems[0],\n });\n\n return proof;\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { appendLog, listManifests, readPointer, releaseDir } from "./store.mjs";
|
|
2
|
+
import { rm } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
//#region ../../packages/core/src/release/prune.ts
|
|
5
|
+
/**
|
|
6
|
+
* Delete old releases, keeping the newest `keep`. The release `current` names
|
|
7
|
+
* and the one `previous` names are always kept, whatever their age. This is
|
|
8
|
+
* the only operation that removes a release.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* await pruneReleases(context, { keep: 5 });
|
|
12
|
+
*/
|
|
13
|
+
async function pruneReleases(context, options) {
|
|
14
|
+
if (!Number.isInteger(options.keep) || options.keep < 1) throw new Error("--keep must be a whole number of at least 1.");
|
|
15
|
+
const manifests = await listManifests(context);
|
|
16
|
+
const pinned = new Set([await readPointer(context, "current"), await readPointer(context, "previous")].filter((id) => id !== null));
|
|
17
|
+
const kept = new Set(manifests.slice(0, options.keep).map((manifest) => manifest.id));
|
|
18
|
+
const removed = [];
|
|
19
|
+
for (const manifest of manifests) {
|
|
20
|
+
if (kept.has(manifest.id) || pinned.has(manifest.id)) continue;
|
|
21
|
+
removed.push(manifest.id);
|
|
22
|
+
if (!options.dryRun) await rm(releaseDir(context, manifest.id), {
|
|
23
|
+
recursive: true,
|
|
24
|
+
force: true
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
if (!options.dryRun && removed.length > 0) await appendLog(context, {
|
|
28
|
+
event: "prune",
|
|
29
|
+
release: null,
|
|
30
|
+
ok: true,
|
|
31
|
+
detail: `removed ${removed.join(", ")}`
|
|
32
|
+
});
|
|
33
|
+
return {
|
|
34
|
+
kept: manifests.filter((manifest) => !removed.includes(manifest.id)).map((manifest) => manifest.id),
|
|
35
|
+
removed,
|
|
36
|
+
dryRun: options.dryRun === true
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
//#endregion
|
|
41
|
+
export { pruneReleases };
|
|
42
|
+
//# sourceMappingURL=prune.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"prune.mjs","names":[],"sources":["../../../../../../../../packages/core/src/release/prune.ts"],"sourcesContent":["import { rm } from \"node:fs/promises\";\nimport { appendLog, listManifests, readPointer, releaseDir, type ReleaseContext } from \"./store\";\n\nexport type PruneResult = {\n kept: string[];\n removed: string[];\n dryRun: boolean;\n};\n\n/**\n * Delete old releases, keeping the newest `keep`. The release `current` names\n * and the one `previous` names are always kept, whatever their age. This is\n * the only operation that removes a release.\n *\n * @example\n * await pruneReleases(context, { keep: 5 });\n */\nexport async function pruneReleases(\n context: ReleaseContext,\n options: { keep: number; dryRun?: boolean },\n): Promise<PruneResult> {\n if (!Number.isInteger(options.keep) || options.keep < 1) {\n throw new Error(\"--keep must be a whole number of at least 1.\");\n }\n\n const manifests = await listManifests(context);\n const pinned = new Set(\n [await readPointer(context, \"current\"), await readPointer(context, \"previous\")].filter(\n (id): id is string => id !== null,\n ),\n );\n const kept = new Set(manifests.slice(0, options.keep).map((manifest) => manifest.id));\n const removed: string[] = [];\n\n for (const manifest of manifests) {\n if (kept.has(manifest.id) || pinned.has(manifest.id)) continue;\n\n removed.push(manifest.id);\n\n if (!options.dryRun) {\n await rm(releaseDir(context, manifest.id), { recursive: true, force: true });\n }\n }\n\n if (!options.dryRun && removed.length > 0) {\n await appendLog(context, {\n event: \"prune\",\n release: null,\n ok: true,\n detail: `removed ${removed.join(\", \")}`,\n });\n }\n\n return {\n kept: manifests\n .filter((manifest) => !removed.includes(manifest.id))\n .map((manifest) => manifest.id),\n removed,\n dryRun: options.dryRun === true,\n };\n}\n"],"mappings":";;;;;;;;;;;;AAiBA,eAAsB,cACpB,SACA,SACsB;CACtB,IAAI,CAAC,OAAO,UAAU,QAAQ,IAAI,KAAK,QAAQ,OAAO,GACpD,MAAM,IAAI,MAAM,8CAA8C;CAGhE,MAAM,YAAY,MAAM,cAAc,OAAO;CAC7C,MAAM,SAAS,IAAI,IACjB,CAAC,MAAM,YAAY,SAAS,SAAS,GAAG,MAAM,YAAY,SAAS,UAAU,CAAC,CAAC,CAAC,QAC7E,OAAqB,OAAO,IAC/B,CACF;CACA,MAAM,OAAO,IAAI,IAAI,UAAU,MAAM,GAAG,QAAQ,IAAI,CAAC,CAAC,KAAK,aAAa,SAAS,EAAE,CAAC;CACpF,MAAM,UAAoB,CAAC;CAE3B,KAAK,MAAM,YAAY,WAAW;EAChC,IAAI,KAAK,IAAI,SAAS,EAAE,KAAK,OAAO,IAAI,SAAS,EAAE,GAAG;EAEtD,QAAQ,KAAK,SAAS,EAAE;EAExB,IAAI,CAAC,QAAQ,QACX,MAAM,GAAG,WAAW,SAAS,SAAS,EAAE,GAAG;GAAE,WAAW;GAAM,OAAO;EAAK,CAAC;CAE/E;CAEA,IAAI,CAAC,QAAQ,UAAU,QAAQ,SAAS,GACtC,MAAM,UAAU,SAAS;EACvB,OAAO;EACP,SAAS;EACT,IAAI;EACJ,QAAQ,WAAW,QAAQ,KAAK,IAAI;CACtC,CAAC;CAGH,OAAO;EACL,MAAM,UACH,QAAQ,aAAa,CAAC,QAAQ,SAAS,SAAS,EAAE,CAAC,CAAC,CACpD,KAAK,aAAa,SAAS,EAAE;EAChC;EACA,QAAQ,QAAQ,WAAW;CAC7B;AACF"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { listManifests, readLog, readPointer } from "./store.mjs";
|
|
2
|
+
|
|
3
|
+
//#region ../../packages/core/src/release/report.ts
|
|
4
|
+
/** Every release, newest first, with the two pointers. */
|
|
5
|
+
async function listReleases(context) {
|
|
6
|
+
return {
|
|
7
|
+
current: await readPointer(context, "current"),
|
|
8
|
+
previous: await readPointer(context, "previous"),
|
|
9
|
+
releases: await listManifests(context)
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/** The active release with the last entries of the release log. */
|
|
13
|
+
async function releaseStatus(context, logLimit = 10) {
|
|
14
|
+
const listing = await listReleases(context);
|
|
15
|
+
return {
|
|
16
|
+
current: listing.current,
|
|
17
|
+
previous: listing.previous,
|
|
18
|
+
active: listing.releases.find((release) => release.id === listing.current) ?? null,
|
|
19
|
+
log: (await readLog(context)).slice(-logLimit)
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
//#endregion
|
|
24
|
+
export { listReleases, releaseStatus };
|
|
25
|
+
//# sourceMappingURL=report.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"report.mjs","names":[],"sources":["../../../../../../../../packages/core/src/release/report.ts"],"sourcesContent":["import { readLog, listManifests, readPointer, type ReleaseContext } from \"./store\";\nimport type { ReleaseLogEntry, ReleaseManifest } from \"./types\";\n\nexport type ReleaseListing = {\n current: string | null;\n previous: string | null;\n releases: ReleaseManifest[];\n};\n\nexport type ReleaseStatusReport = {\n current: string | null;\n previous: string | null;\n active: ReleaseManifest | null;\n log: ReleaseLogEntry[];\n};\n\n/** Every release, newest first, with the two pointers. */\nexport async function listReleases(context: ReleaseContext): Promise<ReleaseListing> {\n return {\n current: await readPointer(context, \"current\"),\n previous: await readPointer(context, \"previous\"),\n releases: await listManifests(context),\n };\n}\n\n/** The active release with the last entries of the release log. */\nexport async function releaseStatus(\n context: ReleaseContext,\n logLimit = 10,\n): Promise<ReleaseStatusReport> {\n const listing = await listReleases(context);\n\n return {\n current: listing.current,\n previous: listing.previous,\n active: listing.releases.find((release) => release.id === listing.current) ?? null,\n log: (await readLog(context)).slice(-logLimit),\n };\n}\n"],"mappings":";;;;AAiBA,eAAsB,aAAa,SAAkD;CACnF,OAAO;EACL,SAAS,MAAM,YAAY,SAAS,SAAS;EAC7C,UAAU,MAAM,YAAY,SAAS,UAAU;EAC/C,UAAU,MAAM,cAAc,OAAO;CACvC;AACF;;AAGA,eAAsB,cACpB,SACA,WAAW,IACmB;CAC9B,MAAM,UAAU,MAAM,aAAa,OAAO;CAE1C,OAAO;EACL,SAAS,QAAQ;EACjB,UAAU,QAAQ;EAClB,QAAQ,QAAQ,SAAS,MAAM,YAAY,QAAQ,OAAO,QAAQ,OAAO,KAAK;EAC9E,MAAM,MAAM,QAAQ,OAAO,EAAC,CAAE,MAAM,CAAC,QAAQ;CAC/C;AACF"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
//#region ../../packages/core/src/release/runner.ts
|
|
4
|
+
/**
|
|
5
|
+
* Default runner: the command goes through the shell, so config entries such
|
|
6
|
+
* as `NODE_ENV=production npx warlock build` work as written. Output is
|
|
7
|
+
* captured (the tail is kept) rather than streamed.
|
|
8
|
+
*
|
|
9
|
+
* On timeout only the child this function started is signalled.
|
|
10
|
+
*/
|
|
11
|
+
const shellRunner = (command, options) => new Promise((resolve) => {
|
|
12
|
+
const child = spawn(command, {
|
|
13
|
+
cwd: options.cwd,
|
|
14
|
+
env: {
|
|
15
|
+
...process.env,
|
|
16
|
+
...options.env
|
|
17
|
+
},
|
|
18
|
+
shell: true,
|
|
19
|
+
stdio: [
|
|
20
|
+
"ignore",
|
|
21
|
+
"pipe",
|
|
22
|
+
"pipe"
|
|
23
|
+
]
|
|
24
|
+
});
|
|
25
|
+
let output = "";
|
|
26
|
+
let timedOut = false;
|
|
27
|
+
const append = (chunk) => {
|
|
28
|
+
output = (output + chunk.toString("utf8")).slice(-8e3);
|
|
29
|
+
};
|
|
30
|
+
child.stdout.on("data", append);
|
|
31
|
+
child.stderr.on("data", append);
|
|
32
|
+
const timer = options.timeoutMs ? setTimeout(() => {
|
|
33
|
+
timedOut = true;
|
|
34
|
+
child.kill("SIGTERM");
|
|
35
|
+
}, options.timeoutMs) : void 0;
|
|
36
|
+
child.on("error", (error) => {
|
|
37
|
+
if (timer) clearTimeout(timer);
|
|
38
|
+
resolve({
|
|
39
|
+
code: 127,
|
|
40
|
+
output: `${output}${error.message}`.slice(-8e3)
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
child.on("close", (code) => {
|
|
44
|
+
if (timer) clearTimeout(timer);
|
|
45
|
+
resolve({
|
|
46
|
+
code: code ?? 1,
|
|
47
|
+
output,
|
|
48
|
+
timedOut
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
//#endregion
|
|
54
|
+
export { shellRunner };
|
|
55
|
+
//# sourceMappingURL=runner.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runner.mjs","names":[],"sources":["../../../../../../../../packages/core/src/release/runner.ts"],"sourcesContent":["import { spawn } from \"node:child_process\";\nimport type { CommandResult, CommandRunner } from \"./types\";\n\nconst OUTPUT_LIMIT = 8000;\n\n/**\n * Default runner: the command goes through the shell, so config entries such\n * as `NODE_ENV=production npx warlock build` work as written. Output is\n * captured (the tail is kept) rather than streamed.\n *\n * On timeout only the child this function started is signalled.\n */\nexport const shellRunner: CommandRunner = (command, options) =>\n new Promise<CommandResult>((resolve) => {\n const child = spawn(command, {\n cwd: options.cwd,\n env: { ...process.env, ...options.env },\n shell: true,\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n });\n let output = \"\";\n let timedOut = false;\n const append = (chunk: Buffer) => {\n output = (output + chunk.toString(\"utf8\")).slice(-OUTPUT_LIMIT);\n };\n\n child.stdout.on(\"data\", append);\n child.stderr.on(\"data\", append);\n\n const timer = options.timeoutMs\n ? setTimeout(() => {\n timedOut = true;\n child.kill(\"SIGTERM\");\n }, options.timeoutMs)\n : undefined;\n\n child.on(\"error\", (error) => {\n if (timer) clearTimeout(timer);\n resolve({ code: 127, output: `${output}${error.message}`.slice(-OUTPUT_LIMIT) });\n });\n\n child.on(\"close\", (code) => {\n if (timer) clearTimeout(timer);\n resolve({ code: code ?? 1, output, timedOut });\n });\n });\n"],"mappings":";;;;;;;;;;AAYA,MAAa,eAA8B,SAAS,YAClD,IAAI,SAAwB,YAAY;CACtC,MAAM,QAAQ,MAAM,SAAS;EAC3B,KAAK,QAAQ;EACb,KAAK;GAAE,GAAG,QAAQ;GAAK,GAAG,QAAQ;EAAI;EACtC,OAAO;EACP,OAAO;GAAC;GAAU;GAAQ;EAAM;CAClC,CAAC;CACD,IAAI,SAAS;CACb,IAAI,WAAW;CACf,MAAM,UAAU,UAAkB;EAChC,UAAU,SAAS,MAAM,SAAS,MAAM,EAAC,CAAE,MAAM,IAAa;CAChE;CAEA,MAAM,OAAO,GAAG,QAAQ,MAAM;CAC9B,MAAM,OAAO,GAAG,QAAQ,MAAM;CAE9B,MAAM,QAAQ,QAAQ,YAClB,iBAAiB;EACf,WAAW;EACX,MAAM,KAAK,SAAS;CACtB,GAAG,QAAQ,SAAS,IACpB;CAEJ,MAAM,GAAG,UAAU,UAAU;EAC3B,IAAI,OAAO,aAAa,KAAK;EAC7B,QAAQ;GAAE,MAAM;GAAK,QAAQ,GAAG,SAAS,MAAM,UAAU,MAAM,IAAa;EAAE,CAAC;CACjF,CAAC;CAED,MAAM,GAAG,UAAU,SAAS;EAC1B,IAAI,OAAO,aAAa,KAAK;EAC7B,QAAQ;GAAE,MAAM,QAAQ;GAAG;GAAQ;EAAS,CAAC;CAC/C,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
//#region ../../packages/core/src/release/semver.ts
|
|
2
|
+
function parseVersion(version) {
|
|
3
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(version.trim());
|
|
4
|
+
if (!match) throw new Error(`"${version}" is not a valid semantic version (expected MAJOR.MINOR.PATCH).`);
|
|
5
|
+
return {
|
|
6
|
+
core: [
|
|
7
|
+
Number(match[1]),
|
|
8
|
+
Number(match[2]),
|
|
9
|
+
Number(match[3])
|
|
10
|
+
],
|
|
11
|
+
prerelease: match[4] ? match[4].split(".") : []
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function comparePrerelease(a, b) {
|
|
15
|
+
if (a.length === 0 && b.length === 0) return 0;
|
|
16
|
+
if (a.length === 0) return 1;
|
|
17
|
+
if (b.length === 0) return -1;
|
|
18
|
+
for (let index = 0; index < Math.max(a.length, b.length); index++) {
|
|
19
|
+
const left = a[index];
|
|
20
|
+
const right = b[index];
|
|
21
|
+
if (left === void 0) return -1;
|
|
22
|
+
if (right === void 0) return 1;
|
|
23
|
+
if (left === right) continue;
|
|
24
|
+
const leftNumeric = /^\d+$/.test(left);
|
|
25
|
+
const rightNumeric = /^\d+$/.test(right);
|
|
26
|
+
if (leftNumeric && rightNumeric) return Number(left) < Number(right) ? -1 : 1;
|
|
27
|
+
if (leftNumeric) return -1;
|
|
28
|
+
if (rightNumeric) return 1;
|
|
29
|
+
return left < right ? -1 : 1;
|
|
30
|
+
}
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Compare two semantic versions. Returns a negative number when `a < b`,
|
|
35
|
+
* zero when equal and a positive number when `a > b`. Build metadata is
|
|
36
|
+
* ignored. Throws on a string that is not a semantic version.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* compareVersions("1.2.0", "1.10.0"); // < 0
|
|
40
|
+
*/
|
|
41
|
+
function compareVersions(a, b) {
|
|
42
|
+
const left = parseVersion(a);
|
|
43
|
+
const right = parseVersion(b);
|
|
44
|
+
for (let index = 0; index < 3; index++) {
|
|
45
|
+
const difference = left.core[index] - right.core[index];
|
|
46
|
+
if (difference !== 0) return difference < 0 ? -1 : 1;
|
|
47
|
+
}
|
|
48
|
+
return comparePrerelease(left.prerelease, right.prerelease);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
//#endregion
|
|
52
|
+
export { compareVersions };
|
|
53
|
+
//# sourceMappingURL=semver.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"semver.mjs","names":[],"sources":["../../../../../../../../packages/core/src/release/semver.ts"],"sourcesContent":["type ParsedVersion = { core: [number, number, number]; prerelease: string[] };\n\nfunction parseVersion(version: string): ParsedVersion {\n const match = /^v?(\\d+)\\.(\\d+)\\.(\\d+)(?:-([0-9A-Za-z.-]+))?(?:\\+[0-9A-Za-z.-]+)?$/.exec(\n version.trim(),\n );\n\n if (!match) {\n throw new Error(`\"${version}\" is not a valid semantic version (expected MAJOR.MINOR.PATCH).`);\n }\n\n return {\n core: [Number(match[1]), Number(match[2]), Number(match[3])],\n prerelease: match[4] ? match[4].split(\".\") : [],\n };\n}\n\nfunction comparePrerelease(a: string[], b: string[]): number {\n // A version without a prerelease tag is greater than one with it.\n if (a.length === 0 && b.length === 0) return 0;\n if (a.length === 0) return 1;\n if (b.length === 0) return -1;\n\n for (let index = 0; index < Math.max(a.length, b.length); index++) {\n const left = a[index];\n const right = b[index];\n\n if (left === undefined) return -1;\n if (right === undefined) return 1;\n if (left === right) continue;\n\n const leftNumeric = /^\\d+$/.test(left);\n const rightNumeric = /^\\d+$/.test(right);\n\n if (leftNumeric && rightNumeric) return Number(left) < Number(right) ? -1 : 1;\n if (leftNumeric) return -1;\n if (rightNumeric) return 1;\n\n return left < right ? -1 : 1;\n }\n\n return 0;\n}\n\n/**\n * Compare two semantic versions. Returns a negative number when `a < b`,\n * zero when equal and a positive number when `a > b`. Build metadata is\n * ignored. Throws on a string that is not a semantic version.\n *\n * @example\n * compareVersions(\"1.2.0\", \"1.10.0\"); // < 0\n */\nexport function compareVersions(a: string, b: string): number {\n const left = parseVersion(a);\n const right = parseVersion(b);\n\n for (let index = 0; index < 3; index++) {\n const difference = left.core[index]! - right.core[index]!;\n\n if (difference !== 0) return difference < 0 ? -1 : 1;\n }\n\n return comparePrerelease(left.prerelease, right.prerelease);\n}\n"],"mappings":";AAEA,SAAS,aAAa,SAAgC;CACpD,MAAM,QAAQ,qEAAqE,KACjF,QAAQ,KAAK,CACf;CAEA,IAAI,CAAC,OACH,MAAM,IAAI,MAAM,IAAI,QAAQ,gEAAgE;CAG9F,OAAO;EACL,MAAM;GAAC,OAAO,MAAM,EAAE;GAAG,OAAO,MAAM,EAAE;GAAG,OAAO,MAAM,EAAE;EAAC;EAC3D,YAAY,MAAM,KAAK,MAAM,EAAE,CAAC,MAAM,GAAG,IAAI,CAAC;CAChD;AACF;AAEA,SAAS,kBAAkB,GAAa,GAAqB;CAE3D,IAAI,EAAE,WAAW,KAAK,EAAE,WAAW,GAAG,OAAO;CAC7C,IAAI,EAAE,WAAW,GAAG,OAAO;CAC3B,IAAI,EAAE,WAAW,GAAG,OAAO;CAE3B,KAAK,IAAI,QAAQ,GAAG,QAAQ,KAAK,IAAI,EAAE,QAAQ,EAAE,MAAM,GAAG,SAAS;EACjE,MAAM,OAAO,EAAE;EACf,MAAM,QAAQ,EAAE;EAEhB,IAAI,SAAS,QAAW,OAAO;EAC/B,IAAI,UAAU,QAAW,OAAO;EAChC,IAAI,SAAS,OAAO;EAEpB,MAAM,cAAc,QAAQ,KAAK,IAAI;EACrC,MAAM,eAAe,QAAQ,KAAK,KAAK;EAEvC,IAAI,eAAe,cAAc,OAAO,OAAO,IAAI,IAAI,OAAO,KAAK,IAAI,KAAK;EAC5E,IAAI,aAAa,OAAO;EACxB,IAAI,cAAc,OAAO;EAEzB,OAAO,OAAO,QAAQ,KAAK;CAC7B;CAEA,OAAO;AACT;;;;;;;;;AAUA,SAAgB,gBAAgB,GAAW,GAAmB;CAC5D,MAAM,OAAO,aAAa,CAAC;CAC3B,MAAM,QAAQ,aAAa,CAAC;CAE5B,KAAK,IAAI,QAAQ,GAAG,QAAQ,GAAG,SAAS;EACtC,MAAM,aAAa,KAAK,KAAK,SAAU,MAAM,KAAK;EAElD,IAAI,eAAe,GAAG,OAAO,aAAa,IAAI,KAAK;CACrD;CAEA,OAAO,kBAAkB,KAAK,YAAY,MAAM,UAAU;AAC5D"}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { appendLog, readManifest, releaseDir, writeManifest } from "./store.mjs";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { access, cp, mkdir, readdir, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
5
|
+
|
|
6
|
+
//#region ../../packages/core/src/release/stage.ts
|
|
7
|
+
async function exists(file) {
|
|
8
|
+
try {
|
|
9
|
+
await access(file);
|
|
10
|
+
return true;
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
async function countFiles(directory) {
|
|
16
|
+
let total = 0;
|
|
17
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
18
|
+
const location = path.join(directory, entry.name);
|
|
19
|
+
if (entry.isDirectory()) total += await countFiles(location);
|
|
20
|
+
else if (entry.isFile()) total += 1;
|
|
21
|
+
}
|
|
22
|
+
return total;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Copy the built output, and what is needed to run it, into the candidate's
|
|
26
|
+
* `staged` directory.
|
|
27
|
+
*
|
|
28
|
+
* Staged holds `dist` (or the configured output directory), `package.json`,
|
|
29
|
+
* the lockfile, the configured `include` entries and a `release.json` with
|
|
30
|
+
* the release identity. It is assembled under a temporary name and renamed
|
|
31
|
+
* into place, so a half-copied `staged` never exists. Staging requires a
|
|
32
|
+
* passing gate.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* await stageRelease(context, "1.4.0-a1b2c3d");
|
|
36
|
+
*/
|
|
37
|
+
async function stageRelease(context, id) {
|
|
38
|
+
const manifest = await readManifest(context, id);
|
|
39
|
+
if (manifest.status === "active" || manifest.status === "superseded") throw new Error(`Release "${id}" is ${manifest.status}; it cannot be staged again.`);
|
|
40
|
+
if (!manifest.gate?.passed) throw new Error(`Release "${id}" has not passed the gate. Run \`warlock release gate\` first.`);
|
|
41
|
+
const directory = releaseDir(context, id);
|
|
42
|
+
const source = path.join(directory, "source");
|
|
43
|
+
const distribution = path.join(source, context.config.distDir);
|
|
44
|
+
if (!await exists(distribution)) throw new Error(`The build output "${context.config.distDir}" is missing in the candidate. Add a build check to the gate.`);
|
|
45
|
+
const temporary = path.join(directory, `.staged-${randomBytes(6).toString("hex")}`);
|
|
46
|
+
const staged = path.join(directory, "staged");
|
|
47
|
+
await mkdir(temporary, { recursive: true });
|
|
48
|
+
try {
|
|
49
|
+
await cp(distribution, path.join(temporary, context.config.distDir), { recursive: true });
|
|
50
|
+
const entries = [
|
|
51
|
+
"package.json",
|
|
52
|
+
manifest.lockfile?.file,
|
|
53
|
+
...context.config.include
|
|
54
|
+
];
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
if (!entry) continue;
|
|
57
|
+
const from = path.resolve(source, entry);
|
|
58
|
+
if (from !== source && !from.startsWith(`${source}${path.sep}`)) throw new Error(`release.include entry "${entry}" points outside the project.`);
|
|
59
|
+
if (!await exists(from)) {
|
|
60
|
+
if (entry === "package.json") throw new Error("The candidate has no package.json.");
|
|
61
|
+
if (entry === manifest.lockfile?.file) continue;
|
|
62
|
+
throw new Error(`release.include entry "${entry}" does not exist in the candidate.`);
|
|
63
|
+
}
|
|
64
|
+
const to = path.join(temporary, path.relative(source, from));
|
|
65
|
+
await mkdir(path.dirname(to), { recursive: true });
|
|
66
|
+
await cp(from, to, { recursive: true });
|
|
67
|
+
}
|
|
68
|
+
if (context.config.linkNodeModules && await exists(path.join(source, "node_modules"))) await symlink(path.join(context.root, "node_modules"), path.join(temporary, "node_modules"), "dir");
|
|
69
|
+
await writeFile(path.join(temporary, "release.json"), `${JSON.stringify({
|
|
70
|
+
id,
|
|
71
|
+
version: manifest.version,
|
|
72
|
+
commit: manifest.commit
|
|
73
|
+
}, null, 2)}\n`, "utf8");
|
|
74
|
+
const files = await countFiles(temporary);
|
|
75
|
+
await rm(staged, {
|
|
76
|
+
recursive: true,
|
|
77
|
+
force: true
|
|
78
|
+
});
|
|
79
|
+
await rename(temporary, staged);
|
|
80
|
+
const updated = {
|
|
81
|
+
...manifest,
|
|
82
|
+
status: "staged",
|
|
83
|
+
staged: {
|
|
84
|
+
at: context.now().toISOString(),
|
|
85
|
+
files
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
await writeManifest(context, updated);
|
|
89
|
+
await appendLog(context, {
|
|
90
|
+
event: "stage",
|
|
91
|
+
release: id,
|
|
92
|
+
ok: true,
|
|
93
|
+
detail: `${files} files`
|
|
94
|
+
});
|
|
95
|
+
return updated;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
await rm(temporary, {
|
|
98
|
+
recursive: true,
|
|
99
|
+
force: true
|
|
100
|
+
});
|
|
101
|
+
await appendLog(context, {
|
|
102
|
+
event: "stage",
|
|
103
|
+
release: id,
|
|
104
|
+
ok: false,
|
|
105
|
+
detail: error.message
|
|
106
|
+
});
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** True when the staged directory holds the build output. */
|
|
111
|
+
async function hasStagedArtefacts(context, id) {
|
|
112
|
+
const staged = path.join(releaseDir(context, id), "staged");
|
|
113
|
+
try {
|
|
114
|
+
return (await stat(path.join(staged, context.config.distDir))).isDirectory();
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
//#endregion
|
|
121
|
+
export { hasStagedArtefacts, stageRelease };
|
|
122
|
+
//# sourceMappingURL=stage.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stage.mjs","names":[],"sources":["../../../../../../../../packages/core/src/release/stage.ts"],"sourcesContent":["import { randomBytes } from \"node:crypto\";\nimport { access, cp, mkdir, readdir, rename, rm, stat, symlink, writeFile } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { appendLog, readManifest, releaseDir, writeManifest, type ReleaseContext } from \"./store\";\nimport type { ReleaseManifest } from \"./types\";\n\nasync function exists(file: string): Promise<boolean> {\n try {\n await access(file);\n return true;\n } catch {\n return false;\n }\n}\n\nasync function countFiles(directory: string): Promise<number> {\n let total = 0;\n\n for (const entry of await readdir(directory, { withFileTypes: true })) {\n const location = path.join(directory, entry.name);\n\n if (entry.isDirectory()) total += await countFiles(location);\n else if (entry.isFile()) total += 1;\n }\n\n return total;\n}\n\n/**\n * Copy the built output, and what is needed to run it, into the candidate's\n * `staged` directory.\n *\n * Staged holds `dist` (or the configured output directory), `package.json`,\n * the lockfile, the configured `include` entries and a `release.json` with\n * the release identity. It is assembled under a temporary name and renamed\n * into place, so a half-copied `staged` never exists. Staging requires a\n * passing gate.\n *\n * @example\n * await stageRelease(context, \"1.4.0-a1b2c3d\");\n */\nexport async function stageRelease(context: ReleaseContext, id: string): Promise<ReleaseManifest> {\n const manifest = await readManifest(context, id);\n\n if (manifest.status === \"active\" || manifest.status === \"superseded\") {\n throw new Error(`Release \"${id}\" is ${manifest.status}; it cannot be staged again.`);\n }\n\n if (!manifest.gate?.passed) {\n throw new Error(`Release \"${id}\" has not passed the gate. Run \\`warlock release gate\\` first.`);\n }\n\n const directory = releaseDir(context, id);\n const source = path.join(directory, \"source\");\n const distribution = path.join(source, context.config.distDir);\n\n if (!(await exists(distribution))) {\n throw new Error(\n `The build output \"${context.config.distDir}\" is missing in the candidate. Add a build check to the gate.`,\n );\n }\n\n const temporary = path.join(directory, `.staged-${randomBytes(6).toString(\"hex\")}`);\n const staged = path.join(directory, \"staged\");\n\n await mkdir(temporary, { recursive: true });\n\n try {\n await cp(distribution, path.join(temporary, context.config.distDir), { recursive: true });\n\n const entries = [\"package.json\", manifest.lockfile?.file, ...context.config.include];\n\n for (const entry of entries) {\n if (!entry) continue;\n\n const from = path.resolve(source, entry);\n\n // An include entry may not reach outside the candidate's source.\n if (from !== source && !from.startsWith(`${source}${path.sep}`)) {\n throw new Error(`release.include entry \"${entry}\" points outside the project.`);\n }\n\n if (!(await exists(from))) {\n if (entry === \"package.json\") throw new Error(\"The candidate has no package.json.\");\n if (entry === manifest.lockfile?.file) continue;\n throw new Error(`release.include entry \"${entry}\" does not exist in the candidate.`);\n }\n\n const to = path.join(temporary, path.relative(source, from));\n\n await mkdir(path.dirname(to), { recursive: true });\n await cp(from, to, { recursive: true });\n }\n\n if (context.config.linkNodeModules && (await exists(path.join(source, \"node_modules\")))) {\n await symlink(\n path.join(context.root, \"node_modules\"),\n path.join(temporary, \"node_modules\"),\n \"dir\",\n );\n }\n\n await writeFile(\n path.join(temporary, \"release.json\"),\n `${JSON.stringify({ id, version: manifest.version, commit: manifest.commit }, null, 2)}\\n`,\n \"utf8\",\n );\n\n const files = await countFiles(temporary);\n\n // Re-staging replaces this candidate's own staged copy only.\n await rm(staged, { recursive: true, force: true });\n await rename(temporary, staged);\n\n const updated: ReleaseManifest = {\n ...manifest,\n status: \"staged\",\n staged: { at: context.now().toISOString(), files },\n };\n\n await writeManifest(context, updated);\n await appendLog(context, { event: \"stage\", release: id, ok: true, detail: `${files} files` });\n\n return updated;\n } catch (error) {\n await rm(temporary, { recursive: true, force: true });\n await appendLog(context, {\n event: \"stage\",\n release: id,\n ok: false,\n detail: (error as Error).message,\n });\n throw error;\n }\n}\n\n/** True when the staged directory holds the build output. */\nexport async function hasStagedArtefacts(context: ReleaseContext, id: string): Promise<boolean> {\n const staged = path.join(releaseDir(context, id), \"staged\");\n\n try {\n return (await stat(path.join(staged, context.config.distDir))).isDirectory();\n } catch {\n return false;\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { appendFile, mkdir, readFile, readdir, readlink, rename, rm, symlink, writeFile } from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
//#region ../../packages/core/src/release/store.ts
|
|
6
|
+
const RELEASE_ID = /^[0-9A-Za-z][0-9A-Za-z._+-]*$/;
|
|
7
|
+
/** Reject ids that could escape the releases directory. */
|
|
8
|
+
function assertReleaseId(id) {
|
|
9
|
+
if (!RELEASE_ID.test(id) || id.includes("..")) throw new Error(`"${id}" is not a valid release id.`);
|
|
10
|
+
return id;
|
|
11
|
+
}
|
|
12
|
+
function baseDir(context) {
|
|
13
|
+
return path.resolve(context.root, context.config.directory);
|
|
14
|
+
}
|
|
15
|
+
function releasesDir(context) {
|
|
16
|
+
return path.join(baseDir(context), "releases");
|
|
17
|
+
}
|
|
18
|
+
function releaseDir(context, id) {
|
|
19
|
+
return path.join(releasesDir(context), assertReleaseId(id));
|
|
20
|
+
}
|
|
21
|
+
function pointerPath(context, name) {
|
|
22
|
+
return path.join(baseDir(context), name);
|
|
23
|
+
}
|
|
24
|
+
function logPath(context) {
|
|
25
|
+
return path.join(baseDir(context), "release.log");
|
|
26
|
+
}
|
|
27
|
+
/** Write a file so readers see either the old content or the new, never half. */
|
|
28
|
+
async function writeFileAtomic(file, content) {
|
|
29
|
+
const temporary = `${file}.${randomBytes(6).toString("hex")}.tmp`;
|
|
30
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
31
|
+
await writeFile(temporary, content, "utf8");
|
|
32
|
+
try {
|
|
33
|
+
await rename(temporary, file);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
await rm(temporary, { force: true });
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function readManifest(context, id) {
|
|
40
|
+
const file = path.join(releaseDir(context, id), "manifest.json");
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error.code === "ENOENT") throw new Error(`Release "${id}" does not exist (no manifest at ${file}).`);
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function writeManifest(context, manifest) {
|
|
49
|
+
await writeFileAtomic(path.join(releaseDir(context, manifest.id), "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
50
|
+
}
|
|
51
|
+
/** Every release, newest first. */
|
|
52
|
+
async function listManifests(context) {
|
|
53
|
+
let entries;
|
|
54
|
+
try {
|
|
55
|
+
entries = await readdir(releasesDir(context));
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error.code === "ENOENT") return [];
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
const manifests = [];
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (!RELEASE_ID.test(entry)) continue;
|
|
63
|
+
try {
|
|
64
|
+
manifests.push(await readManifest(context, entry));
|
|
65
|
+
} catch {}
|
|
66
|
+
}
|
|
67
|
+
return manifests.sort((a, b) => a.createdAt === b.createdAt ? b.id.localeCompare(a.id) : b.createdAt.localeCompare(a.createdAt));
|
|
68
|
+
}
|
|
69
|
+
/** The release id a pointer names, or `null` when it is not set. */
|
|
70
|
+
async function readPointer(context, name) {
|
|
71
|
+
try {
|
|
72
|
+
return path.basename(await readlink(pointerPath(context, name)));
|
|
73
|
+
} catch (error) {
|
|
74
|
+
const code = error.code;
|
|
75
|
+
if (code === "ENOENT" || code === "EINVAL") return null;
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Point `current` or `previous` at a release.
|
|
81
|
+
*
|
|
82
|
+
* The symlink is built under a temporary name and renamed over the old one,
|
|
83
|
+
* so the pointer always names a complete release and is never missing. The
|
|
84
|
+
* target is relative (`releases/<id>`), so the whole directory can move.
|
|
85
|
+
*/
|
|
86
|
+
async function switchPointer(context, name, id) {
|
|
87
|
+
assertReleaseId(id);
|
|
88
|
+
const pointer = pointerPath(context, name);
|
|
89
|
+
const temporary = `${pointer}.${randomBytes(6).toString("hex")}.tmp`;
|
|
90
|
+
await mkdir(path.dirname(pointer), { recursive: true });
|
|
91
|
+
await symlink(path.join("releases", id), temporary, "dir");
|
|
92
|
+
try {
|
|
93
|
+
await rename(temporary, pointer);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
await rm(temporary, { force: true });
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async function appendLog(context, entry) {
|
|
100
|
+
await mkdir(baseDir(context), { recursive: true });
|
|
101
|
+
await appendFile(logPath(context), `${JSON.stringify({
|
|
102
|
+
at: context.now().toISOString(),
|
|
103
|
+
...entry
|
|
104
|
+
})}\n`, "utf8");
|
|
105
|
+
}
|
|
106
|
+
async function readLog(context) {
|
|
107
|
+
try {
|
|
108
|
+
return (await readFile(logPath(context), "utf8")).split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error.code === "ENOENT") return [];
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The release a command works on: the id it was given, or the newest release
|
|
116
|
+
* that is not active yet.
|
|
117
|
+
*/
|
|
118
|
+
async function resolveRelease(context, id) {
|
|
119
|
+
if (id) return readManifest(context, id);
|
|
120
|
+
const latest = (await listManifests(context)).find((manifest) => [
|
|
121
|
+
"candidate",
|
|
122
|
+
"gated",
|
|
123
|
+
"staged"
|
|
124
|
+
].includes(manifest.status));
|
|
125
|
+
if (!latest) throw new Error("No release candidate found. Create one with `warlock release candidate`.");
|
|
126
|
+
return latest;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
//#endregion
|
|
130
|
+
export { appendLog, assertReleaseId, baseDir, listManifests, logPath, pointerPath, readLog, readManifest, readPointer, releaseDir, releasesDir, resolveRelease, switchPointer, writeFileAtomic, writeManifest };
|
|
131
|
+
//# sourceMappingURL=store.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.mjs","names":[],"sources":["../../../../../../../../packages/core/src/release/store.ts"],"sourcesContent":["import { randomBytes } from \"node:crypto\";\nimport {\n appendFile,\n mkdir,\n readdir,\n readFile,\n readlink,\n rename,\n rm,\n symlink,\n writeFile,\n} from \"node:fs/promises\";\nimport path from \"node:path\";\nimport type {\n CommandRunner,\n ReleaseLogEntry,\n ReleaseManifest,\n ResolvedReleaseConfig,\n} from \"./types\";\n\n/** Everything a release operation needs; tests replace `runner` and `now`. */\nexport type ReleaseContext = {\n /** Project root */\n root: string;\n config: ResolvedReleaseConfig;\n runner: CommandRunner;\n now: () => Date;\n nodeVersion: string;\n};\n\nexport type PointerName = \"current\" | \"previous\";\n\nconst RELEASE_ID = /^[0-9A-Za-z][0-9A-Za-z._+-]*$/;\n\n/** Reject ids that could escape the releases directory. */\nexport function assertReleaseId(id: string): string {\n if (!RELEASE_ID.test(id) || id.includes(\"..\")) {\n throw new Error(`\"${id}\" is not a valid release id.`);\n }\n\n return id;\n}\n\nexport function baseDir(context: ReleaseContext): string {\n return path.resolve(context.root, context.config.directory);\n}\n\nexport function releasesDir(context: ReleaseContext): string {\n return path.join(baseDir(context), \"releases\");\n}\n\nexport function releaseDir(context: ReleaseContext, id: string): string {\n return path.join(releasesDir(context), assertReleaseId(id));\n}\n\nexport function pointerPath(context: ReleaseContext, name: PointerName): string {\n return path.join(baseDir(context), name);\n}\n\nexport function logPath(context: ReleaseContext): string {\n return path.join(baseDir(context), \"release.log\");\n}\n\n/** Write a file so readers see either the old content or the new, never half. */\nexport async function writeFileAtomic(file: string, content: string): Promise<void> {\n const temporary = `${file}.${randomBytes(6).toString(\"hex\")}.tmp`;\n\n await mkdir(path.dirname(file), { recursive: true });\n await writeFile(temporary, content, \"utf8\");\n\n try {\n await rename(temporary, file);\n } catch (error) {\n await rm(temporary, { force: true });\n throw error;\n }\n}\n\nexport async function readManifest(context: ReleaseContext, id: string): Promise<ReleaseManifest> {\n const file = path.join(releaseDir(context, id), \"manifest.json\");\n\n try {\n return JSON.parse(await readFile(file, \"utf8\")) as ReleaseManifest;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === \"ENOENT\") {\n throw new Error(`Release \"${id}\" does not exist (no manifest at ${file}).`);\n }\n\n throw error;\n }\n}\n\nexport async function writeManifest(\n context: ReleaseContext,\n manifest: ReleaseManifest,\n): Promise<void> {\n await writeFileAtomic(\n path.join(releaseDir(context, manifest.id), \"manifest.json\"),\n `${JSON.stringify(manifest, null, 2)}\\n`,\n );\n}\n\n/** Every release, newest first. */\nexport async function listManifests(context: ReleaseContext): Promise<ReleaseManifest[]> {\n let entries: string[];\n\n try {\n entries = await readdir(releasesDir(context));\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === \"ENOENT\") return [];\n throw error;\n }\n\n const manifests: ReleaseManifest[] = [];\n\n for (const entry of entries) {\n if (!RELEASE_ID.test(entry)) continue;\n\n try {\n manifests.push(await readManifest(context, entry));\n } catch {\n // A directory without a readable manifest is not a release.\n }\n }\n\n return manifests.sort((a, b) =>\n a.createdAt === b.createdAt ? b.id.localeCompare(a.id) : b.createdAt.localeCompare(a.createdAt),\n );\n}\n\n/** The release id a pointer names, or `null` when it is not set. */\nexport async function readPointer(\n context: ReleaseContext,\n name: PointerName,\n): Promise<string | null> {\n try {\n return path.basename(await readlink(pointerPath(context, name)));\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code;\n\n if (code === \"ENOENT\" || code === \"EINVAL\") return null;\n throw error;\n }\n}\n\n/**\n * Point `current` or `previous` at a release.\n *\n * The symlink is built under a temporary name and renamed over the old one,\n * so the pointer always names a complete release and is never missing. The\n * target is relative (`releases/<id>`), so the whole directory can move.\n */\nexport async function switchPointer(\n context: ReleaseContext,\n name: PointerName,\n id: string,\n): Promise<void> {\n assertReleaseId(id);\n\n const pointer = pointerPath(context, name);\n const temporary = `${pointer}.${randomBytes(6).toString(\"hex\")}.tmp`;\n\n await mkdir(path.dirname(pointer), { recursive: true });\n await symlink(path.join(\"releases\", id), temporary, \"dir\");\n\n try {\n await rename(temporary, pointer);\n } catch (error) {\n await rm(temporary, { force: true });\n throw error;\n }\n}\n\nexport async function appendLog(\n context: ReleaseContext,\n entry: Omit<ReleaseLogEntry, \"at\">,\n): Promise<void> {\n await mkdir(baseDir(context), { recursive: true });\n await appendFile(\n logPath(context),\n `${JSON.stringify({ at: context.now().toISOString(), ...entry })}\\n`,\n \"utf8\",\n );\n}\n\nexport async function readLog(context: ReleaseContext): Promise<ReleaseLogEntry[]> {\n try {\n const content = await readFile(logPath(context), \"utf8\");\n\n return content\n .split(\"\\n\")\n .filter(Boolean)\n .map((line) => JSON.parse(line) as ReleaseLogEntry);\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === \"ENOENT\") return [];\n throw error;\n }\n}\n\n/**\n * The release a command works on: the id it was given, or the newest release\n * that is not active yet.\n */\nexport async function resolveRelease(\n context: ReleaseContext,\n id?: string,\n): Promise<ReleaseManifest> {\n if (id) return readManifest(context, id);\n\n const manifests = await listManifests(context);\n const latest = manifests.find((manifest) =>\n [\"candidate\", \"gated\", \"staged\"].includes(manifest.status),\n );\n\n if (!latest) {\n throw new Error(\"No release candidate found. Create one with `warlock release candidate`.\");\n }\n\n return latest;\n}\n"],"mappings":"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"}
|