@warlock.js/core 5.36.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 +28 -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/index.d.mts +3 -2
- package/esm/index.mjs +2 -2
- package/esm/production/route-registration-snapshot.mjs +10 -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 -2
- package/esm/router/router.d.mts.map +1 -1
- package/esm/router/router.mjs +13 -5
- package/esm/router/router.mjs.map +1 -1
- package/esm/router/types.d.mts +26 -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/security-conventions.mjs +19 -1
- package/esm/tooling/check/security-conventions.mjs.map +1 -1
- package/esm/tooling/inspect/collect-routes.mjs +3 -2
- package/esm/tooling/inspect/collect-routes.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 +2 -1
- package/esm/warlock-config/types.d.mts +8 -0
- package/esm/warlock-config/types.d.mts.map +1 -1
- package/llms-full.txt +94 -2
- 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 +12 -0
- package/skills/warlock-conventions/SKILL.md +1 -1
|
@@ -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":";;;;;;AAMA,eAAe,OAAO,MAAgC;CACpD,IAAI;EACF,MAAM,OAAO,IAAI;EACjB,OAAO;CACT,QAAQ;EACN,OAAO;CACT;AACF;AAEA,eAAe,WAAW,WAAoC;CAC5D,IAAI,QAAQ;CAEZ,KAAK,MAAM,SAAS,MAAM,QAAQ,WAAW,EAAE,eAAe,KAAK,CAAC,GAAG;EACrE,MAAM,WAAW,KAAK,KAAK,WAAW,MAAM,IAAI;EAEhD,IAAI,MAAM,YAAY,GAAG,SAAS,MAAM,WAAW,QAAQ;OACtD,IAAI,MAAM,OAAO,GAAG,SAAS;CACpC;CAEA,OAAO;AACT;;;;;;;;;;;;;;AAeA,eAAsB,aAAa,SAAyB,IAAsC;CAChG,MAAM,WAAW,MAAM,aAAa,SAAS,EAAE;CAE/C,IAAI,SAAS,WAAW,YAAY,SAAS,WAAW,cACtD,MAAM,IAAI,MAAM,YAAY,GAAG,OAAO,SAAS,OAAO,6BAA6B;CAGrF,IAAI,CAAC,SAAS,MAAM,QAClB,MAAM,IAAI,MAAM,YAAY,GAAG,+DAA+D;CAGhG,MAAM,YAAY,WAAW,SAAS,EAAE;CACxC,MAAM,SAAS,KAAK,KAAK,WAAW,QAAQ;CAC5C,MAAM,eAAe,KAAK,KAAK,QAAQ,QAAQ,OAAO,OAAO;CAE7D,IAAI,CAAE,MAAM,OAAO,YAAY,GAC7B,MAAM,IAAI,MACR,qBAAqB,QAAQ,OAAO,QAAQ,8DAC9C;CAGF,MAAM,YAAY,KAAK,KAAK,WAAW,WAAW,YAAY,CAAC,CAAC,CAAC,SAAS,KAAK,GAAG;CAClF,MAAM,SAAS,KAAK,KAAK,WAAW,QAAQ;CAE5C,MAAM,MAAM,WAAW,EAAE,WAAW,KAAK,CAAC;CAE1C,IAAI;EACF,MAAM,GAAG,cAAc,KAAK,KAAK,WAAW,QAAQ,OAAO,OAAO,GAAG,EAAE,WAAW,KAAK,CAAC;EAExF,MAAM,UAAU;GAAC;GAAgB,SAAS,UAAU;GAAM,GAAG,QAAQ,OAAO;EAAO;EAEnF,KAAK,MAAM,SAAS,SAAS;GAC3B,IAAI,CAAC,OAAO;GAEZ,MAAM,OAAO,KAAK,QAAQ,QAAQ,KAAK;GAGvC,IAAI,SAAS,UAAU,CAAC,KAAK,WAAW,GAAG,SAAS,KAAK,KAAK,GAC5D,MAAM,IAAI,MAAM,0BAA0B,MAAM,8BAA8B;GAGhF,IAAI,CAAE,MAAM,OAAO,IAAI,GAAI;IACzB,IAAI,UAAU,gBAAgB,MAAM,IAAI,MAAM,oCAAoC;IAClF,IAAI,UAAU,SAAS,UAAU,MAAM;IACvC,MAAM,IAAI,MAAM,0BAA0B,MAAM,mCAAmC;GACrF;GAEA,MAAM,KAAK,KAAK,KAAK,WAAW,KAAK,SAAS,QAAQ,IAAI,CAAC;GAE3D,MAAM,MAAM,KAAK,QAAQ,EAAE,GAAG,EAAE,WAAW,KAAK,CAAC;GACjD,MAAM,GAAG,MAAM,IAAI,EAAE,WAAW,KAAK,CAAC;EACxC;EAEA,IAAI,QAAQ,OAAO,mBAAoB,MAAM,OAAO,KAAK,KAAK,QAAQ,cAAc,CAAC,GACnF,MAAM,QACJ,KAAK,KAAK,QAAQ,MAAM,cAAc,GACtC,KAAK,KAAK,WAAW,cAAc,GACnC,KACF;EAGF,MAAM,UACJ,KAAK,KAAK,WAAW,cAAc,GACnC,GAAG,KAAK,UAAU;GAAE;GAAI,SAAS,SAAS;GAAS,QAAQ,SAAS;EAAO,GAAG,MAAM,CAAC,EAAE,KACvF,MACF;EAEA,MAAM,QAAQ,MAAM,WAAW,SAAS;EAGxC,MAAM,GAAG,QAAQ;GAAE,WAAW;GAAM,OAAO;EAAK,CAAC;EACjD,MAAM,OAAO,WAAW,MAAM;EAE9B,MAAM,UAA2B;GAC/B,GAAG;GACH,QAAQ;GACR,QAAQ;IAAE,IAAI,QAAQ,IAAI,CAAC,CAAC,YAAY;IAAG;GAAM;EACnD;EAEA,MAAM,cAAc,SAAS,OAAO;EACpC,MAAM,UAAU,SAAS;GAAE,OAAO;GAAS,SAAS;GAAI,IAAI;GAAM,QAAQ,GAAG,MAAM;EAAQ,CAAC;EAE5F,OAAO;CACT,SAAS,OAAO;EACd,MAAM,GAAG,WAAW;GAAE,WAAW;GAAM,OAAO;EAAK,CAAC;EACpD,MAAM,UAAU,SAAS;GACvB,OAAO;GACP,SAAS;GACT,IAAI;GACJ,QAAS,MAAgB;EAC3B,CAAC;EACD,MAAM;CACR;AACF;;AAGA,eAAsB,mBAAmB,SAAyB,IAA8B;CAC9F,MAAM,SAAS,KAAK,KAAK,WAAW,SAAS,EAAE,GAAG,QAAQ;CAE1D,IAAI;EACF,QAAQ,MAAM,KAAK,KAAK,KAAK,QAAQ,QAAQ,OAAO,OAAO,CAAC,EAAC,CAAE,YAAY;CAC7E,QAAQ;EACN,OAAO;CACT;AACF"}
|
|
@@ -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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"}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
//#region ../../packages/core/src/release/types.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* One named command in the release gate. Checks run in order and the gate
|
|
4
|
+
* stops at the first one that fails.
|
|
5
|
+
*/
|
|
6
|
+
export type ReleaseCheck = {
|
|
7
|
+
/** Name shown in the report and recorded in the manifest */
|
|
8
|
+
name: string;
|
|
9
|
+
/** Shell command, run inside the candidate's frozen source */
|
|
10
|
+
command: string;
|
|
11
|
+
/** Kill the check after this many milliseconds. @default 1_800_000 */
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Release settings, read from the `release` key of `warlock.config.ts`.
|
|
16
|
+
*
|
|
17
|
+
* Every field is optional; the defaults describe a plain Warlock app that
|
|
18
|
+
* builds into `dist`.
|
|
19
|
+
*/
|
|
20
|
+
export type ReleaseConfig = {
|
|
21
|
+
/**
|
|
22
|
+
* Base directory for releases and the `current` / `previous` pointers,
|
|
23
|
+
* relative to the project root.
|
|
24
|
+
*
|
|
25
|
+
* @default ".warlock"
|
|
26
|
+
*/
|
|
27
|
+
directory?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Build output directory, relative to the candidate's source.
|
|
30
|
+
*
|
|
31
|
+
* @default "dist"
|
|
32
|
+
*/
|
|
33
|
+
distDir?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Extra files or directories (relative to the candidate's source) copied
|
|
36
|
+
* next to the build output when staging. `package.json` and the lockfile
|
|
37
|
+
* are always copied.
|
|
38
|
+
*/
|
|
39
|
+
include?: string[];
|
|
40
|
+
/**
|
|
41
|
+
* Link the project's `node_modules` into the frozen source so gate checks
|
|
42
|
+
* and the build can run without a second install.
|
|
43
|
+
*
|
|
44
|
+
* @default true
|
|
45
|
+
*/
|
|
46
|
+
linkNodeModules?: boolean;
|
|
47
|
+
/** The ordered gate checks. Replaces the defaults when set. */
|
|
48
|
+
gate?: {
|
|
49
|
+
checks?: ReleaseCheck[];
|
|
50
|
+
};
|
|
51
|
+
/** Migration proof settings */
|
|
52
|
+
migrations?: {
|
|
53
|
+
/**
|
|
54
|
+
* Named targets. `--target <name>` applies the target's `env` to this
|
|
55
|
+
* process before the database connects, so the proof runs against that
|
|
56
|
+
* database instead of the one the local environment points to.
|
|
57
|
+
*/
|
|
58
|
+
targets?: Record<string, {
|
|
59
|
+
env?: Record<string, string>;
|
|
60
|
+
}>;
|
|
61
|
+
/**
|
|
62
|
+
* Migration names that must be the ones executed, in order. Defaults to
|
|
63
|
+
* the pending set computed before the run.
|
|
64
|
+
*/
|
|
65
|
+
expected?: string[];
|
|
66
|
+
/** Tables or collections that must exist after the run. */
|
|
67
|
+
expectedTables?: string[];
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Command that makes the running application pick up the new release.
|
|
71
|
+
* Warlock never restarts anything on its own: leave it unset and activation
|
|
72
|
+
* only switches the pointer.
|
|
73
|
+
*
|
|
74
|
+
* It runs through the shell with `WARLOCK_RELEASE_ID`,
|
|
75
|
+
* `WARLOCK_RELEASE_DIR` and `WARLOCK_RELEASE_CURRENT` in its environment.
|
|
76
|
+
*/
|
|
77
|
+
restart?: {
|
|
78
|
+
command?: string;
|
|
79
|
+
timeoutMs?: number;
|
|
80
|
+
};
|
|
81
|
+
/** Post-activation health verification */
|
|
82
|
+
verify?: {
|
|
83
|
+
/** Health URL. Required by `release verify`. */
|
|
84
|
+
url?: string;
|
|
85
|
+
/** @default 200 */
|
|
86
|
+
expectedStatus?: number;
|
|
87
|
+
/** Attempts after the first one. @default 5 */
|
|
88
|
+
retries?: number;
|
|
89
|
+
/** Wait between attempts in milliseconds. @default 2000 */
|
|
90
|
+
intervalMs?: number;
|
|
91
|
+
/** Per-request timeout in milliseconds. @default 5000 */
|
|
92
|
+
timeoutMs?: number;
|
|
93
|
+
/** Dotted JSON path of the reported version. @default "version" */
|
|
94
|
+
versionPath?: string;
|
|
95
|
+
/** Dotted JSON path of the reported commit. @default "commit" */
|
|
96
|
+
commitPath?: string;
|
|
97
|
+
/** Which reported fields must match the active release. @default ["version", "commit"] */
|
|
98
|
+
match?: Array<"version" | "commit">;
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
//#endregion
|
|
102
|
+
//# sourceMappingURL=types.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.mts","names":[],"sources":["../../../../../../../../packages/core/src/release/types.ts"],"mappings":";;;;;YAIY;;EAEV;;EAEA;;EAEA;;;;;;;;YASU;;;;;;;EAOV;;;;;;EAMA;;;;;;EAMA;;;;;;;EAOA;;EAEA;IAAS,SAAS;;;EAElB;;;;;;IAME,UAAU;MAAiB,MAAM;;;;;;IAKjC;;IAEA;;;;;;;;;;EAUF;IAAY;IAAkB;;;EAE9B;;IAEE;;IAEA;;IAEA;;IAEA;;IAEA;;IAEA;;IAEA;;IAEA,QAAQ"}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { appendLog, readManifest, readPointer, writeManifest } from "./store.mjs";
|
|
2
|
+
|
|
3
|
+
//#region ../../packages/core/src/release/verify.ts
|
|
4
|
+
function readPath(value, dotted) {
|
|
5
|
+
let current = value;
|
|
6
|
+
for (const key of dotted.split(".")) {
|
|
7
|
+
if (typeof current !== "object" || current === null) return void 0;
|
|
8
|
+
current = current[key];
|
|
9
|
+
}
|
|
10
|
+
return current;
|
|
11
|
+
}
|
|
12
|
+
function sleep(ms) {
|
|
13
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
|
+
}
|
|
15
|
+
/** Why one attempt failed, or `null` when it matched the release. */
|
|
16
|
+
async function attempt(context, url, expected) {
|
|
17
|
+
const { verify } = context.config;
|
|
18
|
+
const controller = new AbortController();
|
|
19
|
+
const timer = setTimeout(() => controller.abort(), verify.timeoutMs);
|
|
20
|
+
try {
|
|
21
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
22
|
+
if (response.status !== verify.expectedStatus) return `status ${response.status}, expected ${verify.expectedStatus}`;
|
|
23
|
+
if (verify.match.length === 0) return null;
|
|
24
|
+
let body;
|
|
25
|
+
try {
|
|
26
|
+
body = await response.json();
|
|
27
|
+
} catch {
|
|
28
|
+
return "the response is not JSON, so the version cannot be compared";
|
|
29
|
+
}
|
|
30
|
+
for (const field of verify.match) {
|
|
31
|
+
const reported = readPath(body, field === "version" ? verify.versionPath : verify.commitPath);
|
|
32
|
+
const wanted = expected[field];
|
|
33
|
+
if (reported === void 0) return `the response reports no ${field} (looked at "${field === "version" ? verify.versionPath : verify.commitPath}")`;
|
|
34
|
+
if (!(field === "commit" && typeof reported === "string" ? reported.length >= 7 && wanted.startsWith(reported) : reported === wanted)) return `reported ${field} ${String(reported)}, expected ${wanted}`;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
} catch (error) {
|
|
38
|
+
return error.name === "AbortError" ? `no response within ${verify.timeoutMs}ms` : error.message;
|
|
39
|
+
} finally {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* GET the health URL until it answers with the expected status and reports
|
|
45
|
+
* the active release's version and commit, retrying with a pause between
|
|
46
|
+
* attempts. The outcome is recorded in the active release's manifest.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* const result = await verifyRelease(context);
|
|
50
|
+
*/
|
|
51
|
+
async function verifyRelease(context) {
|
|
52
|
+
const { verify } = context.config;
|
|
53
|
+
const id = await readPointer(context, "current");
|
|
54
|
+
if (!id) throw new Error("No active release to verify.");
|
|
55
|
+
if (!verify.url) throw new Error("Set release.verify.url to the health URL first.");
|
|
56
|
+
const manifest = await readManifest(context, id);
|
|
57
|
+
let reason = "not attempted";
|
|
58
|
+
let attempts = 0;
|
|
59
|
+
for (let index = 0; index <= verify.retries; index++) {
|
|
60
|
+
if (index > 0) await sleep(verify.intervalMs);
|
|
61
|
+
attempts += 1;
|
|
62
|
+
reason = await attempt(context, verify.url, {
|
|
63
|
+
version: manifest.version,
|
|
64
|
+
commit: manifest.commit
|
|
65
|
+
});
|
|
66
|
+
if (reason === null) break;
|
|
67
|
+
}
|
|
68
|
+
const ok = reason === null;
|
|
69
|
+
await writeManifest(context, {
|
|
70
|
+
...manifest,
|
|
71
|
+
verification: {
|
|
72
|
+
at: context.now().toISOString(),
|
|
73
|
+
ok,
|
|
74
|
+
attempts,
|
|
75
|
+
...ok ? {} : { reason: reason ?? void 0 }
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
await appendLog(context, {
|
|
79
|
+
event: "verify",
|
|
80
|
+
release: id,
|
|
81
|
+
ok,
|
|
82
|
+
detail: ok ? `${attempts} attempt(s)` : reason ?? void 0
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
id,
|
|
86
|
+
ok,
|
|
87
|
+
attempts,
|
|
88
|
+
...ok ? {} : { reason: reason ?? void 0 }
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
//#endregion
|
|
93
|
+
export { verifyRelease };
|
|
94
|
+
//# sourceMappingURL=verify.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"verify.mjs","names":[],"sources":["../../../../../../../../packages/core/src/release/verify.ts"],"sourcesContent":["import { appendLog, readManifest, readPointer, writeManifest, type ReleaseContext } from \"./store\";\n\nexport type VerifyResult = {\n id: string;\n ok: boolean;\n attempts: number;\n reason?: string;\n};\n\nfunction readPath(value: unknown, dotted: string): unknown {\n let current: unknown = value;\n\n for (const key of dotted.split(\".\")) {\n if (typeof current !== \"object\" || current === null) return undefined;\n current = (current as Record<string, unknown>)[key];\n }\n\n return current;\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n/** Why one attempt failed, or `null` when it matched the release. */\nasync function attempt(\n context: ReleaseContext,\n url: string,\n expected: { version: string; commit: string },\n): Promise<string | null> {\n const { verify } = context.config;\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), verify.timeoutMs);\n\n try {\n const response = await fetch(url, { signal: controller.signal });\n\n if (response.status !== verify.expectedStatus) {\n return `status ${response.status}, expected ${verify.expectedStatus}`;\n }\n\n if (verify.match.length === 0) return null;\n\n let body: unknown;\n\n try {\n body = await response.json();\n } catch {\n return \"the response is not JSON, so the version cannot be compared\";\n }\n\n for (const field of verify.match) {\n const reported = readPath(body, field === \"version\" ? verify.versionPath : verify.commitPath);\n const wanted = expected[field];\n\n if (reported === undefined) {\n return `the response reports no ${field} (looked at \"${\n field === \"version\" ? verify.versionPath : verify.commitPath\n }\")`;\n }\n\n // A reported short commit counts when it is a prefix of the full one.\n const matches =\n field === \"commit\" && typeof reported === \"string\"\n ? reported.length >= 7 && wanted.startsWith(reported)\n : reported === wanted;\n\n if (!matches) return `reported ${field} ${String(reported)}, expected ${wanted}`;\n }\n\n return null;\n } catch (error) {\n return (error as Error).name === \"AbortError\"\n ? `no response within ${verify.timeoutMs}ms`\n : (error as Error).message;\n } finally {\n clearTimeout(timer);\n }\n}\n\n/**\n * GET the health URL until it answers with the expected status and reports\n * the active release's version and commit, retrying with a pause between\n * attempts. The outcome is recorded in the active release's manifest.\n *\n * @example\n * const result = await verifyRelease(context);\n */\nexport async function verifyRelease(context: ReleaseContext): Promise<VerifyResult> {\n const { verify } = context.config;\n const id = await readPointer(context, \"current\");\n\n if (!id) throw new Error(\"No active release to verify.\");\n if (!verify.url) throw new Error(\"Set release.verify.url to the health URL first.\");\n\n const manifest = await readManifest(context, id);\n let reason: string | null = \"not attempted\";\n let attempts = 0;\n\n for (let index = 0; index <= verify.retries; index++) {\n if (index > 0) await sleep(verify.intervalMs);\n\n attempts += 1;\n reason = await attempt(context, verify.url, {\n version: manifest.version,\n commit: manifest.commit,\n });\n\n if (reason === null) break;\n }\n\n const ok = reason === null;\n\n await writeManifest(context, {\n ...manifest,\n verification: {\n at: context.now().toISOString(),\n ok,\n attempts,\n ...(ok ? {} : { reason: reason ?? undefined }),\n },\n });\n await appendLog(context, {\n event: \"verify\",\n release: id,\n ok,\n detail: ok ? `${attempts} attempt(s)` : (reason ?? undefined),\n });\n\n return { id, ok, attempts, ...(ok ? {} : { reason: reason ?? undefined }) };\n}\n"],"mappings":";;;AASA,SAAS,SAAS,OAAgB,QAAyB;CACzD,IAAI,UAAmB;CAEvB,KAAK,MAAM,OAAO,OAAO,MAAM,GAAG,GAAG;EACnC,IAAI,OAAO,YAAY,YAAY,YAAY,MAAM,OAAO;EAC5D,UAAW,QAAoC;CACjD;CAEA,OAAO;AACT;AAEA,SAAS,MAAM,IAA2B;CACxC,OAAO,IAAI,SAAS,YAAY,WAAW,SAAS,EAAE,CAAC;AACzD;;AAGA,eAAe,QACb,SACA,KACA,UACwB;CACxB,MAAM,EAAE,WAAW,QAAQ;CAC3B,MAAM,aAAa,IAAI,gBAAgB;CACvC,MAAM,QAAQ,iBAAiB,WAAW,MAAM,GAAG,OAAO,SAAS;CAEnE,IAAI;EACF,MAAM,WAAW,MAAM,MAAM,KAAK,EAAE,QAAQ,WAAW,OAAO,CAAC;EAE/D,IAAI,SAAS,WAAW,OAAO,gBAC7B,OAAO,UAAU,SAAS,OAAO,aAAa,OAAO;EAGvD,IAAI,OAAO,MAAM,WAAW,GAAG,OAAO;EAEtC,IAAI;EAEJ,IAAI;GACF,OAAO,MAAM,SAAS,KAAK;EAC7B,QAAQ;GACN,OAAO;EACT;EAEA,KAAK,MAAM,SAAS,OAAO,OAAO;GAChC,MAAM,WAAW,SAAS,MAAM,UAAU,YAAY,OAAO,cAAc,OAAO,UAAU;GAC5F,MAAM,SAAS,SAAS;GAExB,IAAI,aAAa,QACf,OAAO,2BAA2B,MAAM,eACtC,UAAU,YAAY,OAAO,cAAc,OAAO,WACnD;GASH,IAAI,EAJF,UAAU,YAAY,OAAO,aAAa,WACtC,SAAS,UAAU,KAAK,OAAO,WAAW,QAAQ,IAClD,aAAa,SAEL,OAAO,YAAY,MAAM,GAAG,OAAO,QAAQ,EAAE,aAAa;EAC1E;EAEA,OAAO;CACT,SAAS,OAAO;EACd,OAAQ,MAAgB,SAAS,eAC7B,sBAAsB,OAAO,UAAU,MACtC,MAAgB;CACvB,UAAU;EACR,aAAa,KAAK;CACpB;AACF;;;;;;;;;AAUA,eAAsB,cAAc,SAAgD;CAClF,MAAM,EAAE,WAAW,QAAQ;CAC3B,MAAM,KAAK,MAAM,YAAY,SAAS,SAAS;CAE/C,IAAI,CAAC,IAAI,MAAM,IAAI,MAAM,8BAA8B;CACvD,IAAI,CAAC,OAAO,KAAK,MAAM,IAAI,MAAM,iDAAiD;CAElF,MAAM,WAAW,MAAM,aAAa,SAAS,EAAE;CAC/C,IAAI,SAAwB;CAC5B,IAAI,WAAW;CAEf,KAAK,IAAI,QAAQ,GAAG,SAAS,OAAO,SAAS,SAAS;EACpD,IAAI,QAAQ,GAAG,MAAM,MAAM,OAAO,UAAU;EAE5C,YAAY;EACZ,SAAS,MAAM,QAAQ,SAAS,OAAO,KAAK;GAC1C,SAAS,SAAS;GAClB,QAAQ,SAAS;EACnB,CAAC;EAED,IAAI,WAAW,MAAM;CACvB;CAEA,MAAM,KAAK,WAAW;CAEtB,MAAM,cAAc,SAAS;EAC3B,GAAG;EACH,cAAc;GACZ,IAAI,QAAQ,IAAI,CAAC,CAAC,YAAY;GAC9B;GACA;GACA,GAAI,KAAK,CAAC,IAAI,EAAE,QAAQ,UAAU,OAAU;EAC9C;CACF,CAAC;CACD,MAAM,UAAU,SAAS;EACvB,OAAO;EACP,SAAS;EACT;EACA,QAAQ,KAAK,GAAG,SAAS,eAAgB,UAAU;CACrD,CAAC;CAED,OAAO;EAAE;EAAI;EAAI;EAAU,GAAI,KAAK,CAAC,IAAI,EAAE,QAAQ,UAAU,OAAU;CAAG;AAC5E"}
|
package/esm/router/index.d.mts
CHANGED
|
@@ -6,5 +6,5 @@ import { matchesDerivedRouteName, routeNameMethodSuffix } from "./route-name-met
|
|
|
6
6
|
import { RouteNameTakenError } from "./route-name-taken-error.mjs";
|
|
7
7
|
import { RouteRegistry } from "./route-registry.mjs";
|
|
8
8
|
import { Router, router } from "./router.mjs";
|
|
9
|
-
import { AUTH_GUARD_MARKER, isAuthGuardMiddleware, isUnguardedMutatingRoute, markAuthGuard, reportUnguardedMutatingRoutes, resetUnguardedRouteReports } from "./unguarded-route-diagnostics.mjs";
|
|
10
|
-
export { AUTH_GUARD_MARKER, GroupedRoutesOptions, HttpContext, Middleware, MiddlewareResponse, NamedApiRoute, NamedApiRoutesOptions, PartialPick, PositionalHandlerSuspect, RequestControllerContract, RequestHandler, RequestHandlerType, RequestHandlerValidation, RequestMethod, ResourceMethod, RestfulMiddleware, Route, RouteNameTakenError, RouteOptions, RouteRateLimitOptions, RouteRegistry, RouteResource, Router, RouterGroupCallback, RouterStacks, StaticMountOptions, ValidationFailureContext, ValidationFailureHandler, ValidationFailureResponse, clearPositionalHandlerSuspects, describePositionalHandlerSuspect, forgetPositionalHandlerSuspects, inspectHandlerSignature, isAuthGuardMiddleware, isUnguardedMutatingRoute, listPositionalHandlerSuspects, looksLikePositionalHandler, markAuthGuard, matchesDerivedRouteName, normalizeRequestPath, normalizeRoutePath, reportPositionalHandlerSuspects, reportUnguardedMutatingRoutes, resetUnguardedRouteReports, routeNameMethodSuffix, router };
|
|
9
|
+
import { AUTH_GUARD_MARKER, isAuthGuardMiddleware, isRequiredGuardRoute, isUnguardedMutatingRoute, markAuthGuard, reportUnguardedMutatingRoutes, resetUnguardedRouteReports } from "./unguarded-route-diagnostics.mjs";
|
|
10
|
+
export { AUTH_GUARD_MARKER, GroupedRoutesOptions, HttpContext, Middleware, MiddlewareResponse, NamedApiRoute, NamedApiRoutesOptions, PartialPick, PositionalHandlerSuspect, RequestControllerContract, RequestHandler, RequestHandlerType, RequestHandlerValidation, RequestMethod, ResourceMethod, RestfulMiddleware, Route, RouteNameTakenError, RouteOptions, RouteRateLimitOptions, RouteRegistry, RouteResource, Router, RouterGroupCallback, RouterStacks, StaticMountOptions, ValidationFailureContext, ValidationFailureHandler, ValidationFailureResponse, clearPositionalHandlerSuspects, describePositionalHandlerSuspect, forgetPositionalHandlerSuspects, inspectHandlerSignature, isAuthGuardMiddleware, isRequiredGuardRoute, isUnguardedMutatingRoute, listPositionalHandlerSuspects, looksLikePositionalHandler, markAuthGuard, matchesDerivedRouteName, normalizeRequestPath, normalizeRoutePath, reportPositionalHandlerSuspects, reportUnguardedMutatingRoutes, resetUnguardedRouteReports, routeNameMethodSuffix, router };
|
package/esm/router/index.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { clearPositionalHandlerSuspects, describePositionalHandlerSuspect, forge
|
|
|
4
4
|
import { matchesDerivedRouteName, routeNameMethodSuffix } from "./route-name-method-suffix.mjs";
|
|
5
5
|
import { RouteNameTakenError } from "./route-name-taken-error.mjs";
|
|
6
6
|
import { RouteRegistry } from "./route-registry.mjs";
|
|
7
|
-
import { AUTH_GUARD_MARKER, isAuthGuardMiddleware, isUnguardedMutatingRoute, markAuthGuard, reportUnguardedMutatingRoutes, resetUnguardedRouteReports } from "./unguarded-route-diagnostics.mjs";
|
|
7
|
+
import { AUTH_GUARD_MARKER, isAuthGuardMiddleware, isRequiredGuardRoute, isUnguardedMutatingRoute, markAuthGuard, reportUnguardedMutatingRoutes, resetUnguardedRouteReports } from "./unguarded-route-diagnostics.mjs";
|
|
8
8
|
import { Router, router } from "./router.mjs";
|
|
9
9
|
|
|
10
|
-
export { AUTH_GUARD_MARKER, RouteNameTakenError, RouteRegistry, Router, clearPositionalHandlerSuspects, describePositionalHandlerSuspect, forgetPositionalHandlerSuspects, inspectHandlerSignature, isAuthGuardMiddleware, isUnguardedMutatingRoute, listPositionalHandlerSuspects, looksLikePositionalHandler, markAuthGuard, matchesDerivedRouteName, normalizeRequestPath, normalizeRoutePath, reportPositionalHandlerSuspects, reportUnguardedMutatingRoutes, resetUnguardedRouteReports, routeNameMethodSuffix, router };
|
|
10
|
+
export { AUTH_GUARD_MARKER, RouteNameTakenError, RouteRegistry, Router, clearPositionalHandlerSuspects, describePositionalHandlerSuspect, forgetPositionalHandlerSuspects, inspectHandlerSignature, isAuthGuardMiddleware, isRequiredGuardRoute, isUnguardedMutatingRoute, listPositionalHandlerSuspects, looksLikePositionalHandler, markAuthGuard, matchesDerivedRouteName, normalizeRequestPath, normalizeRoutePath, reportPositionalHandlerSuspects, reportUnguardedMutatingRoutes, resetUnguardedRouteReports, routeNameMethodSuffix, router };
|
package/esm/router/router.d.mts
CHANGED
|
@@ -221,8 +221,10 @@ export declare class Router {
|
|
|
221
221
|
*
|
|
222
222
|
* Names are the registered names, including method suffixes. `all` stays
|
|
223
223
|
* intact as route metadata; consumers decide whether their use supports it.
|
|
224
|
-
* A route that opted out with `clientVisible: false` carries that flag
|
|
225
|
-
*
|
|
224
|
+
* A route that opted out with `clientVisible: false` carries that flag so
|
|
225
|
+
* browser-facing consumers can leave it out. `sites` is present only on a
|
|
226
|
+
* route that declared some, and `guarded: true` only on a route with an auth
|
|
227
|
+
* guard in its middleware chain (group middleware included).
|
|
226
228
|
*/
|
|
227
229
|
getNamedApiRoutes(options?: NamedApiRoutesOptions): readonly NamedApiRoute[];
|
|
228
230
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"router.d.mts","names":[],"sources":["../../../../../../../../packages/core/src/router/router.ts"],"mappings":";;;;;;qBAqKa;;;;UAIH;;;;;;;;;UAUA;;;;;UAMA;;;;;UAUA;;;;iBAKO;;;;YAKL;IACR,SAAS;IACT,WAAW;;;;;YAMH,gBAAgB,iBAAiB,QAAQ,QAAQ,QAAQ;;;;;YAOzD,QAAQ;;;;SASJ,eAAW;UAQlB;;;;EAOA,eAAe,WAAW,QAAQ,QAAQ,QAAQ;;;;EASlD,cAAc,WAAW,QAAQ,QAAQ,QAAQ;;;;EASjD,SAAS,cAAc,YAAY;;;;;;;;;;;;;;EAmBnC,UAAU,SAAS,sBAAsB,eAAc;;YAOpD,0BAA0B,QAAQ;;;;;;YAelC,mBACR,cACA,SAAS,gBACT,eAAc;;;;EAmBT,KACL,cACA,kBACA,oBACA,eAAc;;;;EAcT,WACL,cACA,kBACA,oBACA,eAAc;;;;EAcT,MACL,OAAO,wBACP,oBACA,eAAc;;;;EAUT,YACL,OAAO,wBACP,oBACA,eAAc;;;;EAUT,MACL,cACA,iBACA,UAAU,KAAK;EAEV,MAAM,SAAS;;;;EAoBf,IACL,QAAQ,iBACR,yBACA,SAAS,oBACT,UAAS;;;;
|
|
1
|
+
{"version":3,"file":"router.d.mts","names":[],"sources":["../../../../../../../../packages/core/src/router/router.ts"],"mappings":";;;;;;qBAqKa;;;;UAIH;;;;;;;;;UAUA;;;;;UAMA;;;;;UAUA;;;;iBAKO;;;;YAKL;IACR,SAAS;IACT,WAAW;;;;;YAMH,gBAAgB,iBAAiB,QAAQ,QAAQ,QAAQ;;;;;YAOzD,QAAQ;;;;SASJ,eAAW;UAQlB;;;;EAOA,eAAe,WAAW,QAAQ,QAAQ,QAAQ;;;;EASlD,cAAc,WAAW,QAAQ,QAAQ,QAAQ;;;;EASjD,SAAS,cAAc,YAAY;;;;;;;;;;;;;;EAmBnC,UAAU,SAAS,sBAAsB,eAAc;;YAOpD,0BAA0B,QAAQ;;;;;;YAelC,mBACR,cACA,SAAS,gBACT,eAAc;;;;EAmBT,KACL,cACA,kBACA,oBACA,eAAc;;;;EAcT,WACL,cACA,kBACA,oBACA,eAAc;;;;EAcT,MACL,OAAO,wBACP,oBACA,eAAc;;;;EAUT,YACL,OAAO,wBACP,oBACA,eAAc;;;;EAUT,MACL,cACA,iBACA,UAAU,KAAK;EAEV,MAAM,SAAS;;;;EAoBf,IACL,QAAQ,iBACR,yBACA,SAAS,oBACT,UAAS;;;;EAsIJ,IAAI,cAAc,SAAS,oBAAoB,UAAS;;;;EAOxD,IAAI,cAAc,SAAS,oBAAoB,UAAS;;;;EAOxD,KAAK,yBAAyB,SAAS,oBAAoB,UAAS;;;;EAOpE,IAAI,cAAc,SAAS,oBAAoB,UAAS;;;;EAOxD,OAAO,yBAAyB,SAAS,oBAAoB,UAAS;;;;EAOtE,MAAM,cAAc,SAAS,oBAAoB,UAAS;;;;EAO1D,KAAK,cAAc,SAAS,oBAAoB,UAAS;;;;EAOzD,QAAQ,cAAc,SAAS,oBAAoB,UAAS;;;;EAO5D,MAAM,cAAc,UAAS,eAAiB;;;;;;;;;;;EAc9C,gBACL,cACA,UAAU,eACV,UAAS;IACP,OAAO;IACP,SAAS;IACT,UAAU,QAAQ,OAAO,gBAAgB;MACvC,aAAa;;;;;;EAsGZ,MAAM,SAAS,sBAAsB,UAAU;;;;EAqE/C,OAAO,gBAAgB;;;;;;;;;;;;;EAgBvB,QAAQ,0BAA0B;;;;;;;EAU5B,eAAe,SAC1B,oBACA,gBAAgB,IAAI,QAAQ,KAC3B,QAAQ;;;;;;EAiCJ,yBAAyB;;;;;;;;;EA0BnB,2BAA2B,GACtC,aAAa,kBACb,eAAe,IAAI,QAAQ,KAC1B,QAAQ;;;;UAoDH;;;;EA4ED,QAAI;;;;;;;;;;;EAcJ,kBAAkB,UAAS,iCAAsC;;;;;;;;;EAkCjE;;;;EAOA,KAAK,QAAQ;;;;;EAqDb,cAAc,QAAQ;;;;EAiKtB,SAAS,cAAc,SAAQ;;;;UAkC9B;;qBA+EG,QAAM"}
|
package/esm/router/router.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { buildRequestLogSnapshot } from "../http/request-log-snapshot.mjs";
|
|
|
15
15
|
import { Request } from "../http/request.mjs";
|
|
16
16
|
import { describeRouteForLog } from "./describe-route-for-log.mjs";
|
|
17
17
|
import { logRequestLifecycle } from "./log-request-lifecycle.mjs";
|
|
18
|
-
import { reportUnguardedMutatingRoutes } from "./unguarded-route-diagnostics.mjs";
|
|
18
|
+
import { isRequiredGuardRoute, reportUnguardedMutatingRoutes } from "./unguarded-route-diagnostics.mjs";
|
|
19
19
|
import { RouteBuilder } from "./route-builder.mjs";
|
|
20
20
|
import { resolveRouteRateLimit, resolveStaticRateLimit } from "./resolve-route-rate-limit.mjs";
|
|
21
21
|
import { DEV_DISPATCH_METHODS, buildNotFoundBody, createDevRateLimiter } from "./dev-dispatch.mjs";
|
|
@@ -229,6 +229,8 @@ var Router = class Router {
|
|
|
229
229
|
if (middlewarePrecedence === "before") options.middleware = [...options.middleware || [], ...this.stacks.middleware];
|
|
230
230
|
else options.middleware = [...this.stacks.middleware, ...options.middleware || []];
|
|
231
231
|
if (options.public === void 0 && (this.stacks.public ?? 0) > 0) options.public = true;
|
|
232
|
+
const groupSites = this.stacks.sites?.at(-1);
|
|
233
|
+
if (options.sites === void 0 && groupSites !== void 0) options.sites = groupSites;
|
|
232
234
|
if (Array.isArray(handler)) {
|
|
233
235
|
const [controller, action] = handler;
|
|
234
236
|
if (typeof controller[action] !== "function") throw new WarlockError("WL3002", `Invalid controller action "${action}" for controller "${controller.constructor.name}"`);
|
|
@@ -391,7 +393,8 @@ var Router = class Router {
|
|
|
391
393
|
* Group routes with options
|
|
392
394
|
*/
|
|
393
395
|
group(options, callback) {
|
|
394
|
-
const { prefix, name = prefix ? trim(prefix.replace(/\//g, "."), ".") : void 0, middleware, public: isPublic } = options;
|
|
396
|
+
const { prefix, name = prefix ? trim(prefix.replace(/\//g, "."), ".") : void 0, middleware, public: isPublic, sites } = options;
|
|
397
|
+
if (sites) (this.stacks.sites ??= []).push(sites);
|
|
395
398
|
if (isPublic) this.stacks.public = (this.stacks.public ?? 0) + 1;
|
|
396
399
|
if (prefix) this.stacks.prefix.push(prefix);
|
|
397
400
|
if (name) this.stacks.name.push(name);
|
|
@@ -403,6 +406,7 @@ var Router = class Router {
|
|
|
403
406
|
if (name) this.stacks.name.pop();
|
|
404
407
|
if (middleware) this.stacks.middleware.splice(this.stacks.middleware.length - middleware.length, middleware.length);
|
|
405
408
|
if (isPublic) this.stacks.public = (this.stacks.public ?? 1) - 1;
|
|
409
|
+
if (sites) this.stacks.sites?.pop();
|
|
406
410
|
}
|
|
407
411
|
return this;
|
|
408
412
|
}
|
|
@@ -554,8 +558,10 @@ var Router = class Router {
|
|
|
554
558
|
*
|
|
555
559
|
* Names are the registered names, including method suffixes. `all` stays
|
|
556
560
|
* intact as route metadata; consumers decide whether their use supports it.
|
|
557
|
-
* A route that opted out with `clientVisible: false` carries that flag
|
|
558
|
-
*
|
|
561
|
+
* A route that opted out with `clientVisible: false` carries that flag so
|
|
562
|
+
* browser-facing consumers can leave it out. `sites` is present only on a
|
|
563
|
+
* route that declared some, and `guarded: true` only on a route with an auth
|
|
564
|
+
* guard in its middleware chain (group middleware included).
|
|
559
565
|
*/
|
|
560
566
|
getNamedApiRoutes(options = {}) {
|
|
561
567
|
return Object.freeze(this.routes.filter((route) => !route.isPage && typeof route.name === "string" && route.name.length > 0).map((route) => {
|
|
@@ -563,7 +569,9 @@ var Router = class Router {
|
|
|
563
569
|
name: route.name,
|
|
564
570
|
path: route.path,
|
|
565
571
|
method: route.method,
|
|
566
|
-
...route.clientVisible === false ? { clientVisible: false } : {}
|
|
572
|
+
...route.clientVisible === false ? { clientVisible: false } : {},
|
|
573
|
+
...route.sites === void 0 ? {} : { sites: Object.freeze([...route.sites]) },
|
|
574
|
+
...isRequiredGuardRoute(route) ? { guarded: true } : {}
|
|
567
575
|
};
|
|
568
576
|
const schema = route.handler?.responseSchema;
|
|
569
577
|
const response = schema ? options.resolveResponse?.(schema, route) : void 0;
|