@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.
Files changed (76) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/esm/cli/commands/migrate.command.mjs +10 -0
  3. package/esm/cli/commands/migrate.command.mjs.map +1 -1
  4. package/esm/cli/commands/release/release-command.action.mjs +245 -0
  5. package/esm/cli/commands/release/release-command.action.mjs.map +1 -0
  6. package/esm/cli/commands/release.command.mjs +66 -0
  7. package/esm/cli/commands/release.command.mjs.map +1 -0
  8. package/esm/cli/framework-cli-commands.mjs +2 -0
  9. package/esm/cli/framework-cli-commands.mjs.map +1 -1
  10. package/esm/database/migrate-action.mjs +24 -2
  11. package/esm/database/migrate-action.mjs.map +1 -1
  12. package/esm/dev-server/loader/load-hook.d.mts.map +1 -1
  13. package/esm/dev-server/loader/load-hook.mjs +6 -0
  14. package/esm/dev-server/loader/load-hook.mjs.map +1 -1
  15. package/esm/index.d.mts +3 -2
  16. package/esm/index.mjs +2 -2
  17. package/esm/production/route-registration-snapshot.mjs +10 -3
  18. package/esm/production/route-registration-snapshot.mjs.map +1 -1
  19. package/esm/release/activate.mjs +261 -0
  20. package/esm/release/activate.mjs.map +1 -0
  21. package/esm/release/candidate.mjs +107 -0
  22. package/esm/release/candidate.mjs.map +1 -0
  23. package/esm/release/cascade-migration-provider.mjs +32 -0
  24. package/esm/release/cascade-migration-provider.mjs.map +1 -0
  25. package/esm/release/config.mjs +60 -0
  26. package/esm/release/config.mjs.map +1 -0
  27. package/esm/release/gate.mjs +67 -0
  28. package/esm/release/gate.mjs.map +1 -0
  29. package/esm/release/migrations-proof.mjs +100 -0
  30. package/esm/release/migrations-proof.mjs.map +1 -0
  31. package/esm/release/prune.mjs +42 -0
  32. package/esm/release/prune.mjs.map +1 -0
  33. package/esm/release/report.mjs +25 -0
  34. package/esm/release/report.mjs.map +1 -0
  35. package/esm/release/runner.mjs +55 -0
  36. package/esm/release/runner.mjs.map +1 -0
  37. package/esm/release/semver.mjs +53 -0
  38. package/esm/release/semver.mjs.map +1 -0
  39. package/esm/release/stage.mjs +122 -0
  40. package/esm/release/stage.mjs.map +1 -0
  41. package/esm/release/store.mjs +131 -0
  42. package/esm/release/store.mjs.map +1 -0
  43. package/esm/release/types.d.mts +102 -0
  44. package/esm/release/types.d.mts.map +1 -0
  45. package/esm/release/verify.mjs +94 -0
  46. package/esm/release/verify.mjs.map +1 -0
  47. package/esm/router/index.d.mts +2 -2
  48. package/esm/router/index.mjs +2 -2
  49. package/esm/router/router.d.mts +4 -2
  50. package/esm/router/router.d.mts.map +1 -1
  51. package/esm/router/router.mjs +13 -5
  52. package/esm/router/router.mjs.map +1 -1
  53. package/esm/router/types.d.mts +26 -0
  54. package/esm/router/types.d.mts.map +1 -1
  55. package/esm/router/unguarded-route-diagnostics.d.mts +6 -0
  56. package/esm/router/unguarded-route-diagnostics.d.mts.map +1 -1
  57. package/esm/router/unguarded-route-diagnostics.mjs +10 -2
  58. package/esm/router/unguarded-route-diagnostics.mjs.map +1 -1
  59. package/esm/tooling/check/security-conventions.mjs +19 -1
  60. package/esm/tooling/check/security-conventions.mjs.map +1 -1
  61. package/esm/tooling/inspect/collect-routes.mjs +3 -2
  62. package/esm/tooling/inspect/collect-routes.mjs.map +1 -1
  63. package/esm/validation/init.mjs +11 -4
  64. package/esm/validation/init.mjs.map +1 -1
  65. package/esm/validation/translate-rule.mjs +63 -0
  66. package/esm/validation/translate-rule.mjs.map +1 -0
  67. package/esm/warlock-config/index.d.mts +2 -1
  68. package/esm/warlock-config/types.d.mts +8 -0
  69. package/esm/warlock-config/types.d.mts.map +1 -1
  70. package/llms-full.txt +94 -2
  71. package/llms.txt +1 -0
  72. package/package.json +13 -13
  73. package/skills/deploy-release/SKILL.md +76 -0
  74. package/skills/index.md +1 -1
  75. package/skills/register-route/SKILL.md +12 -0
  76. 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":"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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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"}
@@ -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"}
@@ -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 };
@@ -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 };
@@ -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, and
225
- * only that flag, so browser-facing consumers can leave it out.
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;;;;EA8HJ,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;;;;EA4D/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;;;;;;;;;EAYJ,kBAAkB,UAAS,iCAAsC;;;;;;;;;EAgCjE;;;;EAOA,KAAK,QAAQ;;;;;EAqDb,cAAc,QAAQ;;;;EAiKtB,SAAS,cAAc,SAAQ;;;;UAkC9B;;qBA+EG,QAAM"}
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"}
@@ -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, and
558
- * only that flag, so browser-facing consumers can leave it out.
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;