@warlock.js/core 4.10.0 → 4.12.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 (144) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/esm/application/application.d.mts +19 -0
  3. package/esm/application/application.d.mts.map +1 -1
  4. package/esm/application/application.mjs +25 -0
  5. package/esm/application/application.mjs.map +1 -1
  6. package/esm/application/boot-signal.d.mts +77 -0
  7. package/esm/application/boot-signal.d.mts.map +1 -0
  8. package/esm/application/boot-signal.mjs +64 -0
  9. package/esm/application/boot-signal.mjs.map +1 -0
  10. package/esm/application/index.d.mts +1 -0
  11. package/esm/application/index.mjs +1 -0
  12. package/esm/bootstrap.mjs +2 -2
  13. package/esm/bootstrap.mjs.map +1 -1
  14. package/esm/cli/cli-commands.manager.mjs +31 -8
  15. package/esm/cli/cli-commands.manager.mjs.map +1 -1
  16. package/esm/cli/cli-commands.utils.mjs +57 -1
  17. package/esm/cli/cli-commands.utils.mjs.map +1 -1
  18. package/esm/cli/commands/add.command.mjs +4 -2
  19. package/esm/cli/commands/add.command.mjs.map +1 -1
  20. package/esm/cli/commands/generate/generate.command.mjs +42 -21
  21. package/esm/cli/commands/generate/generate.command.mjs.map +1 -1
  22. package/esm/cli/commands/generate/generators/module.generator.mjs +1 -1
  23. package/esm/cli/commands/generate/generators/module.generator.mjs.map +1 -1
  24. package/esm/cli/commands/migrate.command.mjs +6 -2
  25. package/esm/cli/commands/migrate.command.mjs.map +1 -1
  26. package/esm/cli/commands/start-production.command.mjs +4 -25
  27. package/esm/cli/commands/start-production.command.mjs.map +1 -1
  28. package/esm/cli/parse-cli-args.mjs +86 -9
  29. package/esm/cli/parse-cli-args.mjs.map +1 -1
  30. package/esm/cli/types.d.mts +6 -1
  31. package/esm/cli/types.d.mts.map +1 -1
  32. package/esm/config/config-getter.mjs +5 -5
  33. package/esm/config/config-getter.mjs.map +1 -1
  34. package/esm/config/config-loader.mjs +2 -2
  35. package/esm/config/config-loader.mjs.map +1 -1
  36. package/esm/config/config-setter.d.mts +27 -0
  37. package/esm/config/config-setter.d.mts.map +1 -0
  38. package/esm/config/config-setter.mjs +10 -0
  39. package/esm/config/config-setter.mjs.map +1 -0
  40. package/esm/config/index.mjs +1 -0
  41. package/esm/connectors/access-connector.mjs +2 -2
  42. package/esm/connectors/access-connector.mjs.map +1 -1
  43. package/esm/connectors/ai-connector.mjs +2 -2
  44. package/esm/connectors/ai-connector.mjs.map +1 -1
  45. package/esm/connectors/cache-connector.mjs +2 -2
  46. package/esm/connectors/cache-connector.mjs.map +1 -1
  47. package/esm/connectors/database-connector.mjs +2 -2
  48. package/esm/connectors/database-connector.mjs.map +1 -1
  49. package/esm/connectors/herald-connector.mjs +2 -2
  50. package/esm/connectors/herald-connector.mjs.map +1 -1
  51. package/esm/connectors/http-connector.d.mts.map +1 -1
  52. package/esm/connectors/http-connector.mjs +8 -7
  53. package/esm/connectors/http-connector.mjs.map +1 -1
  54. package/esm/connectors/logger-connector.mjs +2 -2
  55. package/esm/connectors/logger-connector.mjs.map +1 -1
  56. package/esm/connectors/mail-connector.mjs +2 -2
  57. package/esm/connectors/mail-connector.mjs.map +1 -1
  58. package/esm/connectors/notifications-connector.mjs +2 -2
  59. package/esm/connectors/notifications-connector.mjs.map +1 -1
  60. package/esm/connectors/socket-connector.mjs +3 -3
  61. package/esm/connectors/socket-connector.mjs.map +1 -1
  62. package/esm/connectors/storage.connector.d.mts +11 -5
  63. package/esm/connectors/storage.connector.d.mts.map +1 -1
  64. package/esm/connectors/storage.connector.mjs +11 -5
  65. package/esm/connectors/storage.connector.mjs.map +1 -1
  66. package/esm/database/migrate-action.mjs +53 -1
  67. package/esm/database/migrate-action.mjs.map +1 -1
  68. package/esm/database/pending-exit-code.mjs +39 -0
  69. package/esm/database/pending-exit-code.mjs.map +1 -0
  70. package/esm/database/resolve-pending-migrations.mjs +49 -0
  71. package/esm/database/resolve-pending-migrations.mjs.map +1 -0
  72. package/esm/dev-server/loader/load-hook.mjs +1 -1
  73. package/esm/generations/stubs.mjs +1 -1
  74. package/esm/generations/stubs.mjs.map +1 -1
  75. package/esm/http/config.mjs +2 -2
  76. package/esm/http/config.mjs.map +1 -1
  77. package/esm/http/createHttpApplication.mjs +2 -2
  78. package/esm/http/createHttpApplication.mjs.map +1 -1
  79. package/esm/http/index.d.mts +1 -0
  80. package/esm/http/index.mjs +1 -0
  81. package/esm/http/middleware/idempotency.middleware.mjs +5 -5
  82. package/esm/http/middleware/idempotency.middleware.mjs.map +1 -1
  83. package/esm/http/middleware/inject-request-context.mjs +2 -2
  84. package/esm/http/middleware/inject-request-context.mjs.map +1 -1
  85. package/esm/http/middleware/maintenance.middleware.mjs +4 -4
  86. package/esm/http/middleware/maintenance.middleware.mjs.map +1 -1
  87. package/esm/http/plugins.mjs +9 -9
  88. package/esm/http/plugins.mjs.map +1 -1
  89. package/esm/http/port-preflight.d.mts +34 -0
  90. package/esm/http/port-preflight.d.mts.map +1 -0
  91. package/esm/http/port-preflight.mjs +66 -0
  92. package/esm/http/port-preflight.mjs.map +1 -0
  93. package/esm/http/response.mjs +5 -5
  94. package/esm/http/response.mjs.map +1 -1
  95. package/esm/http/server.mjs +4 -4
  96. package/esm/http/server.mjs.map +1 -1
  97. package/esm/image/image.d.mts +3 -2
  98. package/esm/image/image.d.mts.map +1 -1
  99. package/esm/image/image.mjs +63 -17
  100. package/esm/image/image.mjs.map +1 -1
  101. package/esm/index.d.mts +6 -2
  102. package/esm/index.mjs +5 -1
  103. package/esm/mail/mailer-pool.d.mts.map +1 -1
  104. package/esm/mail/mailer-pool.mjs.map +1 -1
  105. package/esm/production/assert-generated-imports.mjs +94 -0
  106. package/esm/production/assert-generated-imports.mjs.map +1 -0
  107. package/esm/production/production-builder.mjs +29 -4
  108. package/esm/production/production-builder.mjs.map +1 -1
  109. package/esm/production/production-supervisor.mjs +108 -0
  110. package/esm/production/production-supervisor.mjs.map +1 -0
  111. package/esm/react/index.d.mts +4 -0
  112. package/esm/react/index.d.mts.map +1 -1
  113. package/esm/react/index.mjs +88 -14
  114. package/esm/react/index.mjs.map +1 -1
  115. package/esm/storage/storage.d.mts +16 -0
  116. package/esm/storage/storage.d.mts.map +1 -1
  117. package/esm/storage/storage.mjs +24 -1
  118. package/esm/storage/storage.mjs.map +1 -1
  119. package/esm/tests/index.d.mts +2 -1
  120. package/esm/tests/index.mjs +1 -0
  121. package/esm/tests/start-http-development-server.d.mts +15 -2
  122. package/esm/tests/start-http-development-server.d.mts.map +1 -1
  123. package/esm/tests/start-http-development-server.mjs +43 -16
  124. package/esm/tests/start-http-development-server.mjs.map +1 -1
  125. package/esm/tests/test-helpers.d.mts.map +1 -1
  126. package/esm/tests/test-helpers.mjs +2 -1
  127. package/esm/tests/test-helpers.mjs.map +1 -1
  128. package/esm/tests/test-server-port-channel.d.mts +27 -0
  129. package/esm/tests/test-server-port-channel.d.mts.map +1 -0
  130. package/esm/tests/test-server-port-channel.mjs +32 -0
  131. package/esm/tests/test-server-port-channel.mjs.map +1 -0
  132. package/esm/utils/load-environment.mjs +59 -0
  133. package/esm/utils/load-environment.mjs.map +1 -0
  134. package/esm/utils/paths.mjs +2 -2
  135. package/esm/utils/paths.mjs.map +1 -1
  136. package/esm/validation/validateAll.mjs +2 -2
  137. package/esm/validation/validateAll.mjs.map +1 -1
  138. package/llms-full.txt +90 -3
  139. package/llms.txt +1 -1
  140. package/package.json +13 -13
  141. package/skills/process-image/SKILL.md +3 -1
  142. package/skills/run-app/SKILL.md +63 -0
  143. package/skills/test-http/SKILL.md +27 -3
  144. package/skills/write-cli-command/SKILL.md +19 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,135 @@ All notable changes to `@warlock.js/core` are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@warlock.js/*` packages are released in lockstep — every package shares the same version number, so a version below may list only the changes that affected this package.
6
6
 
7
+ ## 4.12.0
8
+
9
+ ### Added
10
+
11
+ - **`warlock migrate --pending` — what will run next, in the order it will run.** `migrate` could report what had already run (`--list`) and what files existed on disk (`--all`), but not the one thing an operator asks before a schema change against a live database. The pending set was already computed on every migrate run; it simply had no read-only exit
12
+
13
+ The gap forced a workaround that is **unsound in the dangerous direction**. `--all` globs `src/app` only, so it cannot see migrations a *package* registers through `database.migrations` — `@warlock.js/auth` alone contributes two. `--list` reads the migrations table, which does contain them. Differencing the two counts subtracts populations that do not overlap, and it under-counts pending by roughly the number of package migrations installed — reporting "nothing else is pending" when something is
14
+
15
+ **`migrate --list` now prints both sections**, executed and pending, so the question can be answered without knowing a second flag exists. The executed section prints **first and unconditionally**: it is a table read that cannot fail because of a broken file on disk, and `--list` is the command reached for while something is already wrong. `--list` always exits `0` — it is a report
16
+
17
+ **`--pending` is the gate**, and its exit code is its entire API: **`0`** computed and nothing pending, **`1`** computed and N pending, **`2`** could not be computed. Two codes would fold "three migrations are waiting" into "I could not work out what is waiting", and those demand opposite responses — the first is *run them*, the second is *stop*. `migrate --pending && deploy` behaves correctly under all three
18
+
19
+ **A failure to read the migrations never reports `0`.** Computing pending requires loading project code, and a single migration file missing its default export throws. That degrades to an explicit `Pending: unavailable — <reason>` line with the executed listing intact above it, and `--pending` exits `2`. An empty pending set means *nothing is pending*, and nothing else
20
+
21
+ `--all` deliberately does **not** gain a migration name beside each path. The only identifier available without loading is the one derived from the filename, and that derivation is a *fallback* used when a migration does not set `migrationName` — so any migration that names itself (`auth`'s do) would be listed under a name that does not exist. A wrong identifier in a listing whose purpose is cross-referencing is worse than no identifier, and `--list`'s two sections answer the comparison directly
22
+
23
+ Proven against a real Postgres: an executed package migration and a pending local one land in the correct sections, a fully-migrated database reports an empty pending list rather than an absent one, and the reporter's `files − executed` arithmetic is pinned as a test that fails if it is ever reintroduced
24
+
25
+ ### Changed
26
+
27
+ - `migrate`'s preload block no longer declares `env: true`. The flag has done nothing since env began loading for every command that declares a preload block; it was decoration, and the test suite now asserts its **absence** so it is not re-added by someone reading the still-deprecated type
28
+
29
+ - **The package now declares its own test runner and a `test` script.** `@warlock.js/core` shipped a maintained `vitest.config.ts` — aliasing eight sibling packages to their sources — with **no `devDependencies` key at all** and no way to invoke it. Its suite was reachable only by knowing to type `npx vitest`, which resolves whatever happens to exist in the tree rather than anything the manifest asked for. The runner is pinned to an **exact** version, not a range: it moved from 4.1.8 to 4.1.10 mid-development on an unrelated install, silently, and a suite whose runner can change underneath it proves less than it appears to
30
+
31
+ ### Fixed
32
+
33
+ - **A build artifact that names an entry point it does not contain is now refused before it can be packed.** An interrupted build leaves a directory that looks finished — `package.json`, `README`, `CHANGELOG`, `bin/`, `skills/` — and holds no compiled code at all. Nineteen existed in this tree at once, and nothing in the release path noticed: the only related guard compares **modification times**, so a hollow directory with a freshly written manifest is *newer than source* and passes, and it runs solely on the artifact-reuse path, which is not how the hollow directories were produced
34
+
35
+ Each artifact is now verified immediately before `npm pack`, on the normal build path and the reuse path alike. **The manifest is the specification:** `main`, `module` and the typings field name the exact files the package promises to ship, so they are resolved against the artifact and must exist. Fields a manifest does not declare are skipped — `core` and `auth` point `main` at `esm/` while `cascade`, `ai` and `seal` point it at `cjs/`, and any check that assumed one build shape would have raised a false failure on packages that are entirely correct. A manifest declaring no entry point at all is also a failure: a published package nothing can import is not a package
36
+
37
+ - **The production acceptance gate no longer inherits the environment it is supposed to be testing.** `run-pnpm-acceptance.mjs` spawned every child with `env: { ...process.env }` and set no `NODE_ENV`. It exercised the production path only because the shell it was written in happened to carry `NODE_ENV=production`; on a clean checkout, a new contributor's machine, or CI, the same gate boots the app in **development** — and does not fail, it passes while testing something other than the thing it is named after. That is the worst outcome available to a gate, and it sat underneath the proof for 4.11.0's headline fix
38
+
39
+ `NODE_ENV=production` is now set explicitly on every spawn, and — more importantly — **asserted from inside the running app**: `/acceptance` reports the environment it actually booted in, and the run fails if it is anything else. Setting a variable and never checking it arrived is how the original defect survived. The remaining `{ ...process.env }` is documented as a deliberate inheritance of `PATH` and the package-manager store paths, with everything the *verdict* depends on set after it
40
+
41
+ Consequence for the roadmap, recorded because the ordering matters: **CI wiring for this gate is now blocked on this fix, not parallel to it.** Wiring it up first would have produced a green from CI — which carries more weight than a local one — for a run that never touched the production path
42
+
43
+ - **`warlock migrate --rollback=false` no longer drops every table.** CLI options were parsed as raw strings and nothing ever coerced them: `--rollback=false` reached the action as the string `"false"`, `if (rollback)` saw a truthy value, and the run rolled back *everything*. The declared `type: "boolean"` on the option was decorative — used only to render help. The same shape existed on every boolean option, including `warlock drop.tables --force=false`, where it turned a confirmation prompt into an unattended drop
44
+
45
+ Its twin was worse. A bare `--flag` swallowed the following token as its value, so `warlock migrate --rollback 2024_users.ts` produced `rollback: "2024_users.ts"` — the filename was never read as a path, and every table went down while the operator believed they had named one file. **A declared boolean now never consumes the next positional**: `--rollback 2024_users.ts` is `rollback: true` plus the positional `2024_users.ts`
46
+
47
+ Coercion is **type-aware, driven by the command's own declared options**, not a blanket rule in the parser: `--flag` → `true`, `--flag=true|1|yes` → `true`, `--flag=false|0|no` → `false` (case-insensitive), and short aliases (`-r=false`) behave identically. A string-typed option whose value is genuinely the word `false` — `--name=false` — still arrives as the string `"false"`. Options a command does not declare are untouched
48
+
49
+ **An unreadable value is an error, not a guess.** `--rollback=maybe` prints what was invalid and what is accepted, and exits 1. Guessing is what produced this defect; a flag that gates a destructive action must refuse input it cannot read rather than pick a side
50
+
51
+ `parseCliArgs` now takes an optional schema and runs twice: once bare to discover the command name (behaviour unchanged — there is no command to consult yet), then again against the resolved command's options. Re-reading argv is what makes the swallowed positional recoverable; by the time the first pass returns, a swallowed argument is indistinguishable from a value
52
+
53
+ - **`warlock generate.module users --force=false` no longer overwrites your files.** The coercion above is opt-in by design — it applies only to options a command declares `type: "boolean"`, so a string option whose value is genuinely the word `false` survives. The generate family and `add` never carried that declaration, so the fix reached none of them and both faces of the defect stayed live on the commands most likely to be run against existing source
54
+
55
+ `--force=false` arrived at every generator as the truthy string `"false"` and the overwrite guard (`if (exists && !force)`) let it through — a flag written to *prevent* clobbering did the clobbering. Its twin ate the target: `warlock generate.module --force users` parsed `users` as the value of `--force`, so the module name was lost entirely and the generator ran with no name
56
+
57
+ Twenty-three option declarations are now typed: `--force, -f` and `--dry-run` on all eight `generate.*` commands, plus `--minimal, -m`, `--with-validation, -v`, `--with-resource, -rs`, and both `--timestamps [bool]` declarations, and `--list, -l` / `--no-install` on `add`. Options that carry real data are deliberately untouched and still take a value — `--table`, `--add`, `--drop`, `--rename` on the generators, `--package-manager` on `add`, and `seed --drop="Seed Name"`, whose value scopes which seeder is undone
58
+
59
+ `add --no-install` no longer has to be passed last. That instruction was in its help text only because the bare flag used to swallow the feature that followed it; `warlock add --no-install auth` now records `auth` as the feature and skips the install, and the wording is gone
60
+
61
+ The guard drives the real command objects through the manager's own resolution path (`tests/unit/cli/generate-flag-options.test.ts`) and asserts what the action is handed. Asserting the declaration object instead would pass against a fixture while the CLI stayed broken
62
+
63
+ - **`new Image(...)` no longer fails depending on how soon you call it.** The `Image` module fired `import("sharp")` at load time without awaiting it, and the constructor only checked whether that import had *failed* — never whether it was still in flight. Constructing an image in the first tick after importing the package therefore ran with an undefined sharp function and died with `TypeError: sharpFn is not a function`; the exact same code passed if something had awaited a timer first. Anything that builds an image during boot — a startup thumbnail job, a module-level warm-up — hit it, and it presented as a mysterious "works locally, breaks in prod" timing bug rather than as a missing dependency
64
+
65
+ Sharp is now resolved **synchronously on the first construction that needs it**, via `createRequire`, and the outcome is cached for the process. There is no longer a window in which the constructor can proceed without a real sharp function: it either has the module or throws. A missing sharp still throws the same install-hint error, at the same point (construction), with the same wording
66
+
67
+ Resolution stays **lazy** — importing `@warlock.js/core` still does not load sharp's native binary, so apps that never touch images pay nothing — and constructing an `Image` from an existing sharp instance short-circuits before any module load
68
+
69
+ The guard for this is a spawned fresh Node process that imports and constructs with nothing in between (`tests/unit/image/image-sharp-resolution.test.ts`). A same-process test cannot catch it: importing at collection time and constructing later *is* the delay that hides the bug
70
+
71
+ - **A sharp that is installed but will not load no longer reports itself as "not installed".** The resolution above swallowed every failure into a single outcome, so the most common real-world sharp problem — the package present but its native binary built for another platform — arrived as `sharp is not installed.` plus instructions to run `npm install sharp`, which cannot fix it. sharp throws its own long, actionable error naming the runtime, the failing `.node` file and the exact install flags to use; that text was discarded and replaced with a different, wrong cause
72
+
73
+ Only **genuine absence** now produces the install hint: a `MODULE_NOT_FOUND` whose message names the specifier `'sharp'` exactly. Matching on the code alone is not sufficient — a dependency missing *inside* sharp raises the very same code (`Cannot find module 'color'`), and would have been reported as sharp itself being absent. Any other failure surfaces sharp's own message, inlined as `Failed to load "sharp": …` **and** chained as `cause`, so a terminal that never prints `cause` still shows the text that helps. The absent-sharp path is unchanged, wording included
74
+
75
+ **The failure reason is cached, not just the fact of failure.** The resolution attempt runs once per process; a second `new Image(...)` skips the load entirely, so caching only "there is no sharp function" would have re-told the same lie one call later. The guard therefore constructs **twice** in each spawned process and asserts the second error matches the first (`tests/unit/image/image-sharp-load-failure.test.ts`) — a one-shot test passes even with that bug present
76
+
77
+ The `MODULE_NOT_FOUND` shapes the guard feeds in are produced by asking Node for a module that genuinely is not installed, rather than hand-written, so the matcher is tested against Node's real message text
78
+
79
+ - **`renderReact()` no longer renders against modules that have not loaded yet.** The same defect as the two above, in a second module, found by looking for the pattern rather than by a bug report. `react/index.ts` fired `import("react")` and `import("react-dom/server")` at load time without awaiting either, and tracked them with a three-state flag that the guard only tested for one state: `if (moduleExists === false)`. While the imports were in flight the flag was `null`, which is not `false`, so the guard passed and the synchronous `renderReact` read `createElement` off `undefined`. With two sequential dynamic imports the window is wider than the image module's, and it is open during exactly the work a server does at boot — rendering a page or an email template from a module-level warm-up
80
+
81
+ The window swallowed the diagnostics as well as the render. A genuinely missing react did **not** produce the install hint during that window: it produced `TypeError: Cannot read properties of undefined (reading 'createElement')`, because the flag was still `null` rather than `false`. The install instructions only appeared for callers late enough to have missed the race — the callers who least needed telling
82
+
83
+ Both modules are now resolved **synchronously on the first `renderReact()` that needs them**, via `createRequire`, with the outcome cached for the process. Resolution stays **lazy**: importing `@warlock.js/core` still does not pull react into apps that never render. A genuinely absent react throws the same `react is not installed.` message with the same instructions, unchanged
84
+
85
+ - **A broken `react-dom/server` no longer reports itself as `react is not installed`.** The two packages were loaded in one `try` and collapsed into one flag, so any failure of either was attributed to react. The specifiers are now resolved and reported **separately**, and the message names the one that actually failed — `Failed to load "react-dom/server": …` — because sending an operator to reinstall react when react is fine costs them the debugging session. Absence is distinguished from breakage the same way as for sharp: `MODULE_NOT_FOUND` **and** a message naming the specifier exactly, quoted, which is also what stops `'react-dom'` from satisfying a check for `'react'`. Everything else surfaces the original error, inlined and chained as `cause`. A `react-dom` whose `./server` subpath is missing from `exports` raises `ERR_PACKAGE_PATH_NOT_EXPORTED`, so it correctly reports as an incompatible install rather than an absent one
86
+
87
+ As with sharp, the failure **reason** is cached rather than only the fact of failure, so the second call cannot fall through to the "not installed" branch and re-tell a lie the first call got right
88
+
89
+ The guard is a spawned fresh Node process (`tests/unit/react/react-module-resolution.test.ts`); a same-process test cannot catch this, which is why the pre-existing suite was green against it. Against the live bug all five cases failed, and — the detail that shows how much the race hid — all five failed with the *same* `Cannot read properties of undefined` symptom, including the case that only asserts the install message. The present-but-broken states are staged by copying the module source next to a fixture `node_modules`, since resolution anchors to the importing file; the real workspace is not mutated
90
+
91
+ ## 4.11.0
92
+
93
+ ### Added
94
+
95
+ - `startHttpTestServer({ port })` — run an integration suite on an explicit port, honoured over `HTTP_PORT` in `.env`, which the internal bootstrap re-reads and no caller could previously override
96
+ - the test server preflights its port and fails with "stop the dev server" naming the port, instead of a raw `EADDRINUSE` from inside Fastify
97
+ - `Application.setServedPort()` and a `port` field on the readiness signal, so a supervisor learns the bound http port from the app rather than re-deriving it from config it may not be able to read
98
+ - `setConfig(name, value)` — the write side of the config store, exported separately from the read-only `config` accessor so registering configuration stays a deliberate boot-time act
99
+
100
+ ### Changed
101
+
102
+ - `@mongez/dotenv` is now required at `^1.3.1` (was `^1.2.4`). Under the old range a fresh install resolved to 1.3.x while an existing lockfile could stay on 1.2.x, so we could not say which behaviour a given consumer actually had. 1.3.x only changes cases that were previously wrong: `env()` now consults `process.env` instead of returning a default for a key the environment defines, `${VAR}` interpolation throws naming the key instead of baking the string `"undefined"` into a value, and numeric coercion no longer corrupts values like `0123456789` or IDs beyond 2^53. Precedence between `.env` files and injected variables is unchanged
103
+
104
+ Core keeps its own guard for a missing `.env` regardless of which version resolves — an application can pin its own transitive dependencies, so the installed version is never something core can assume
105
+
106
+ ### Deprecated
107
+
108
+ - the `env` preloader flag on a CLI command is no longer read — env is loaded for every command that declares a preload block. Setting it is harmless and does nothing; remove it. Dropped at 5.0
109
+
110
+ ### Fixed
111
+
112
+ - **A production bundle no longer imports a package your app does not declare.** `warlock build`'s generated config loader emitted `import config from "@mongez/config"` — one of *core's* dependencies, never the app's. npm and yarn hoist flat so it resolved by accident; under pnpm's strict layout the shipped bundle died at boot with `ERR_MODULE_NOT_FOUND` for a package the app had no reason to install. The generator now emits `setConfig` from `@warlock.js/core`, which the app does declare, and Node resolves `@mongez/config` from core's own install — correct under pnpm, and portable, unlike baking absolute paths into an artifact meant to be copied between machines
113
+
114
+ **The rule is now enforced, not just followed.** `warlock build` fails if any specifier written into generated code is missing from the app's `dependencies`, listing every violation at once. Rewriting the one bad import fixes today's bundle; the check is what stops the next change to the generator from reintroducing it invisibly — under npm and yarn the mistake never surfaces
115
+
116
+ Two scaffolding sites had the same defect and are fixed with it: `warlock generate.module` emitted `groupedTranslations` from `@mongez/localization`, and the communicators config stub emitted `env` from `@mongez/dotenv`. Both now come from `@warlock.js/core`, which already re-exports them
117
+
118
+ - **`env()` inside `warlock.config.ts` no longer always returns its default.** The config module was evaluated *before* any `.env` file was read, so a project following the documented `build: { outdir: env("BUILD_OUT", "dist") }` recipe silently got `dist` no matter what the environment said — under every command, `dev` included, and under `build` and `start` env was never loaded at all. Env files are now loaded before `warlock.config.ts` is evaluated, for every command
119
+
120
+ Loading is guarded: a project with no `.env` is legitimate and must not start failing `warlock build` now that env loads everywhere. `NODE_ENV` remains authoritative for which file is chosen — no command forces the environment, so a deliberate `NODE_ENV=staging` build still reads `.env.staging`. With `NODE_ENV` unset, plain `.env` is read
121
+
122
+ - **An application without `src/config/storage.ts` can boot again.** The storage connector starts unconditionally, on the documented grounds that `storage.init()` falls back to a built-in `local` driver so file storage works out of the box. That fallback was never implemented: `init()` resolved the default driver *name* and then found nothing registered under it, so any app without a storage config died at boot with `Storage driver "local" is not configured`. A built-in `local` driver rooted at `uploadsPath()` is now registered before configured drivers — so an app defining its own `local` still overrides it, and naming a driver that genuinely does not exist still fails loudly
123
+
124
+ Only scaffolded apps hid this, because `create-warlock` always ships a storage config
125
+
126
+ - `startHttpTestServer` no longer breaks a suite that configures `http.port: 0`. `0` is the OS's "pick a free port for me" idiom, but the guard only checked `typeof port !== "number"`, so `0` fell through: the preflight bound an unrelated ephemeral port and passed without proving anything, and `0` was then published as the bound port, pointing every request in the suite at `http://host:0`. An explicit `0` now takes the same path as no configured port — no preflight, nothing published, Fastify picks the port
127
+
128
+ - **`warlock start` no longer claims success before the app has booted.** The startup banner printed in `preAction` — before the child process was even spawned — and the failure that followed went only to stderr. Any CI gate or process supervisor that watches stdout for the banner read a 🔴 boot failure as a healthy start, which is how a production app that never booted was recorded as running. The banner now prints only when the application reports a completed boot, and a child that dies before reporting is a failed start: the message goes to **both** stdout and stderr, and the exit code is forced non-zero even when the process itself exited `0`
129
+
130
+ Readiness is signalled, never assumed. `Application.markBooted()` sends a versioned `warlock:ready` message — `{ type, version, pid, at, environment, runtimeStrategy, bootDurationMs?, port? }` — over the IPC channel `warlock start` opens, and closes that channel immediately so an open handle can't keep a wedged process looking alive. The signal is gated on a `WARLOCK_BOOT_SIGNAL` handshake the CLI sets on the child it spawns, so an app running under pm2 or any other supervisor never writes into a channel it does not own
131
+
132
+ A bundle built by an older Warlock has no readiness signal. It still starts normally and draws a note on **stderr only** telling you to re-run `warlock build` — an absent signal is never an error, never fails a run, and never kills a slow boot
133
+
134
+ **Upgrading:** re-run `warlock build` so the bundle can report readiness; until you do, `warlock start` runs your app but prints no started banner. Anything parsing `warlock start` output should note that progress lines now go to stderr — stdout carries the started banner and start failures, nothing else
135
+
7
136
  ## 4.10.0
8
137
 
9
138
  ### Changed
@@ -50,6 +50,13 @@ declare class Application {
50
50
  * `markBooted`.
51
51
  */
52
52
  private static bootListeners;
53
+ /**
54
+ * The http port this process actually bound, once it has. Reported in the
55
+ * readiness signal so a supervisor knows where to send its health check
56
+ * instead of re-deriving it from config it may not be able to read.
57
+ * Undefined for an app with no http connector — a queue worker still boots.
58
+ */
59
+ private static servedPort;
53
60
  /**
54
61
  * Whether the application has begun (or finished) shutting down. Flipped once
55
62
  * by `runShutdownHooks`.
@@ -73,6 +80,12 @@ declare class Application {
73
80
  * main, routes) have been loaded.
74
81
  */
75
82
  static get isBooted(): boolean;
83
+ /**
84
+ * Record the http port this process bound.
85
+ *
86
+ * @internal The http connector calls this after a successful `listen`.
87
+ */
88
+ static setServedPort(port: number): void;
76
89
  /**
77
90
  * Run a callback once the application is fully booted.
78
91
  *
@@ -99,6 +112,12 @@ declare class Application {
99
112
  * is idempotent — a second call is a no-op, so a double-invoke can never
100
113
  * double-fire listeners.
101
114
  *
115
+ * Also reports readiness to a supervising parent process — this is the one
116
+ * moment in the lifecycle where "the application is serving" is true, so it
117
+ * is the only honest source for `warlock start`'s success banner. Sent before
118
+ * the listeners run: a slow or hanging `onceBooted` listener must not delay
119
+ * the parent's view of a server that is already accepting requests.
120
+ *
102
121
  * @internal Framework entry points call this; application code must not.
103
122
  */
104
123
  static markBooted(context: BootContext): void;
@@ -1 +1 @@
1
- {"version":3,"file":"application.d.mts","names":[],"sources":["../../../../../../../core/src/application/application.ts"],"mappings":";;;;;AAgBA;;;;KAAY,WAAA;EACV,WAAA,EAAa,WAAA;EACb,eAAA,EAAiB,eAAe;EAAf;;;AAKH;EAAd,cAAA;AAAA;;;;;;KAQU,YAAA,IAAgB,OAAA,EAAS,WAAA,YAAuB,OAAO;AAAA;AAOnE;;;;AAPmE,KAOvD,gBAAA,gBAAgC,OAAO;AAAA,cAEtC,WAAA;EAAW;;;EAAA,gBAIC,SAAA,EAAS,IAAA;EA4CW;;;EAAA,OAvC7B,eAAA,EAAiB,eAAA;EAmGG;;;EAAA,eA9FnB,MAAA;EAgNmB;;;EAAA,eA3MnB,WAAA;EAfiB;;;;EAAA,eAqBjB,aAAA;EAAA;;;;EAAA,eAMA,YAAA;EAiB4B;;;EAAA,eAZ5B,iBAAA;EAuCoB;;;EAAA,WAlCjB,OAAA;EAiDkB;;;EAAA,OA1CtB,kBAAA,CAAmB,QAAA,EAAU,eAAA;EAgFtB;;;;;EAAA,WAvEH,QAAA;EAyHsB;;;;;;;;;;;;;;EAAA,OAvG1B,UAAA,CAAW,QAAA,EAAU,YAAA;EA2MjB;;;AAOU;EAPV,OA5LJ,UAAA,IAAc,OAAA,CAAQ,WAAA;;;;;;;;;SAkBtB,UAAA,CAAW,OAAA,EAAS,WAAA;;;;;iBAoBb,eAAA;;;;;;aAgBH,cAAA;;;;;;;;;;;;SAeJ,UAAA,CAAW,QAAA,EAAU,gBAAA;;;;;;;;;;SAmBf,gBAAA,IAAoB,OAAA;;;;;iBAmBnB,mBAAA;;;;aAWH,MAAA;;;;aAOA,WAAA,IAAe,WAAA;;;;SAOnB,cAAA,CAAe,GAAA,EAAK,WAAA;;;;aAOhB,YAAA;;;;aAOA,aAAA;;;;aAOA,MAAA;;;;aAOA,QAAA;;;;aAOA,OAAA;;;;aAOA,OAAA;;;;aAOA,WAAA;;;;aAOA,WAAA;;;;aAOA,UAAA;AAAA"}
1
+ {"version":3,"file":"application.d.mts","names":[],"sources":["../../../../../../../core/src/application/application.ts"],"mappings":";;;;;AAiBA;;;;KAAY,WAAA;EACV,WAAA,EAAa,WAAA;EACb,eAAA,EAAiB,eAAe;EAAf;;;AAKH;EAAd,cAAA;AAAA;;;;;;KAQU,YAAA,IAAgB,OAAA,EAAS,WAAA,YAAuB,OAAO;AAAA;AAOnE;;;;AAPmE,KAOvD,gBAAA,gBAAgC,OAAO;AAAA,cAEtC,WAAA;EAAW;;;EAAA,gBAIC,SAAA,EAAS,IAAA;EAoDW;;;EAAA,OA/C7B,eAAA,EAAiB,eAAA;EA0HG;;;EAAA,eArHnB,MAAA;EAkPmB;;;EAAA,eA7OnB,WAAA;EAfiB;;;;EAAA,eAqBjB,aAAA;EAAA;;;;;;EAAA,eAQA,UAAA;EAuBkB;;;;EAAA,eAjBlB,YAAA;EAqDoB;;;EAAA,eAhDpB,iBAAA;EA+DqB;;;EAAA,WA1DlB,OAAA;EAiHG;;;EAAA,OA1GP,kBAAA,CAAmB,QAAA,EAAU,eAAA;EAyIlB;;;;;EAAA,WAhIP,QAAA;EAwLe;;;;;EAAA,OA/KnB,aAAA,CAAc,IAAA;EA2MV;;;;;;;;AA0CU;;;;;;EA1CV,OAzLJ,UAAA,CAAW,QAAA,EAAU,YAAA;;;;;SAerB,UAAA,IAAc,OAAA,CAAQ,WAAA;;;;;;;;;;;;;;;SAwBtB,UAAA,CAAW,OAAA,EAAS,WAAA;;;;;iBA+Bb,eAAA;;;;;;aAgBH,cAAA;;;;;;;;;;;;SAeJ,UAAA,CAAW,QAAA,EAAU,gBAAA;;;;;;;;;;SAmBf,gBAAA,IAAoB,OAAA;;;;;iBAmBnB,mBAAA;;;;aAWH,MAAA;;;;aAOA,WAAA,IAAe,WAAA;;;;SAOnB,cAAA,CAAe,GAAA,EAAK,WAAA;;;;aAOhB,YAAA;;;;aAOA,aAAA;;;;aAOA,MAAA;;;;aAOA,QAAA;;;;aAOA,OAAA;;;;aAOA,OAAA;;;;aAOA,WAAA;;;;aAOA,WAAA;;;;aAOA,UAAA;AAAA"}
@@ -1,5 +1,6 @@
1
1
  import { environment, setEnvironment } from "../utils/environment.mjs";
2
2
  import { appPath, publicPath, rootPath, srcPath, storagePath, uploadsPath } from "../utils/paths.mjs";
3
+ import { BOOT_SIGNAL_VERSION, sendBootSignal } from "./boot-signal.mjs";
3
4
  import { getFrameworkVersion } from "../utils/framework-vesion.mjs";
4
5
  import { log } from "@warlock.js/logger";
5
6
 
@@ -41,6 +42,14 @@ var Application = class {
41
42
  return this.booted;
42
43
  }
43
44
  /**
45
+ * Record the http port this process bound.
46
+ *
47
+ * @internal The http connector calls this after a successful `listen`.
48
+ */
49
+ static setServedPort(port) {
50
+ this.servedPort = port;
51
+ }
52
+ /**
44
53
  * Run a callback once the application is fully booted.
45
54
  *
46
55
  * App files (`main` / `events` / `routes` / locales) are imported BEFORE the
@@ -78,12 +87,28 @@ var Application = class {
78
87
  * is idempotent — a second call is a no-op, so a double-invoke can never
79
88
  * double-fire listeners.
80
89
  *
90
+ * Also reports readiness to a supervising parent process — this is the one
91
+ * moment in the lifecycle where "the application is serving" is true, so it
92
+ * is the only honest source for `warlock start`'s success banner. Sent before
93
+ * the listeners run: a slow or hanging `onceBooted` listener must not delay
94
+ * the parent's view of a server that is already accepting requests.
95
+ *
81
96
  * @internal Framework entry points call this; application code must not.
82
97
  */
83
98
  static markBooted(context) {
84
99
  if (this.booted) return;
85
100
  this.booted = true;
86
101
  this.bootContext = context;
102
+ sendBootSignal({
103
+ type: "warlock:ready",
104
+ version: 1,
105
+ pid: process.pid,
106
+ at: (/* @__PURE__ */ new Date()).toISOString(),
107
+ environment: context.environment,
108
+ runtimeStrategy: context.runtimeStrategy,
109
+ bootDurationMs: context.bootDurationMs,
110
+ port: this.servedPort
111
+ });
87
112
  const listeners = this.bootListeners;
88
113
  this.bootListeners = [];
89
114
  for (const listener of listeners) this.runBootListener(listener, context);
@@ -1 +1 @@
1
- {"version":3,"file":"application.mjs","names":[],"sources":["../../../../../../../core/src/application/application.ts"],"sourcesContent":["import { log } from \"@warlock.js/logger\";\nimport {\n environment,\n setEnvironment,\n type Environment,\n type RuntimeStrategy,\n} from \"../utils/environment\";\nimport { getFrameworkVersion } from \"../utils/framework-vesion\";\nimport { appPath, publicPath, rootPath, srcPath, storagePath, uploadsPath } from \"../utils/paths\";\n\n/**\n * Snapshot of the global facts known the moment the application finished\n * booting: every connector (early + late) is active and all app files\n * (locales, events, main, routes) are loaded. Passed to every\n * `Application.onceBooted` listener and resolved from `Application.whenBooted`.\n */\nexport type BootContext = {\n environment: Environment;\n runtimeStrategy: RuntimeStrategy;\n /**\n * Wall-clock milliseconds the boot took. The dev server measures it; the\n * production entry omits it.\n */\n bootDurationMs?: number;\n};\n\n/**\n * A callback registered through `Application.onceBooted`. May be async — its\n * rejection is caught and logged so a single listener can never break boot or\n * the other listeners.\n */\nexport type BootListener = (context: BootContext) => void | Promise<void>;\n\n/**\n * A callback registered through `Application.onShutdown`. Runs once, while the\n * connectors (db, cache, http) are still up, so it can release app-owned\n * resources cleanly. May be async — its rejection is caught and logged.\n */\nexport type ShutdownListener = () => void | Promise<void>;\n\nexport class Application {\n /**\n * Project start time regarding the process start time\n */\n public static readonly startedAt = new Date(Date.now() - process.uptime() * 1000);\n\n /**\n * Runtime strategy\n */\n public static runtimeStrategy: RuntimeStrategy;\n\n /**\n * Whether the application has finished booting. Flipped once by `markBooted`.\n */\n private static booted = false;\n\n /**\n * The boot context, set once booted so it can be replayed to late subscribers.\n */\n private static bootContext: BootContext | undefined;\n\n /**\n * Listeners queued before boot completed, drained in registration order by\n * `markBooted`.\n */\n private static bootListeners: BootListener[] = [];\n\n /**\n * Whether the application has begun (or finished) shutting down. Flipped once\n * by `runShutdownHooks`.\n */\n private static shuttingDown = false;\n\n /**\n * Teardown callbacks, run once when shutdown begins.\n */\n private static shutdownListeners: ShutdownListener[] = [];\n\n /**\n * Get framework version\n */\n public static get version() {\n return getFrameworkVersion();\n }\n\n /**\n * Set the runtime strategy\n */\n public static setRuntimeStrategy(strategy: RuntimeStrategy) {\n this.runtimeStrategy = strategy;\n }\n\n /**\n * Whether the application has finished booting — every connector in both\n * the early and late phases is active and all app files (locales, events,\n * main, routes) have been loaded.\n */\n public static get isBooted(): boolean {\n return this.booted;\n }\n\n /**\n * Run a callback once the application is fully booted.\n *\n * App files (`main` / `events` / `routes` / locales) are imported BEFORE the\n * late-phase connectors (http, socket) start, so a listener registered at\n * import time waits for the whole sequence to finish before it runs. If the\n * application is already booted, the callback runs on the next microtask —\n * so a late subscriber never silently misses the event.\n *\n * @example\n * Application.onceBooted(({ environment }) => {\n * log.info(\"app\", \"booted\", `ready in ${environment}`);\n * });\n */\n public static onceBooted(listener: BootListener): void {\n if (this.booted && this.bootContext) {\n const context = this.bootContext;\n void this.runBootListener(listener, context);\n\n return;\n }\n\n this.bootListeners.push(listener);\n }\n\n /**\n * Promise form of {@link onceBooted} — resolves with the boot context once\n * the application is booted, or immediately if it already is.\n */\n public static whenBooted(): Promise<BootContext> {\n if (this.booted && this.bootContext) {\n return Promise.resolve(this.bootContext);\n }\n\n return new Promise<BootContext>((resolve) => {\n this.onceBooted((context) => resolve(context));\n });\n }\n\n /**\n * Flip the boot latch and drain every queued listener. The dev server and\n * the production entry call this once, right after the late phase starts. It\n * is idempotent — a second call is a no-op, so a double-invoke can never\n * double-fire listeners.\n *\n * @internal Framework entry points call this; application code must not.\n */\n public static markBooted(context: BootContext): void {\n if (this.booted) {\n return;\n }\n\n this.booted = true;\n this.bootContext = context;\n\n const listeners = this.bootListeners;\n this.bootListeners = [];\n\n for (const listener of listeners) {\n void this.runBootListener(listener, context);\n }\n }\n\n /**\n * Invoke a single boot listener, isolating its failure so neither boot nor\n * the remaining listeners are affected.\n */\n private static async runBootListener(\n listener: BootListener,\n context: BootContext,\n ): Promise<void> {\n try {\n await listener(context);\n } catch (error) {\n log.error(\"application\", \"booted-listener\", error as Error);\n }\n }\n\n /**\n * Whether the application has begun shutting down. Once true, readiness\n * checks report not-ready so a load balancer drains this instance before the\n * HTTP server stops accepting requests.\n */\n public static get isShuttingDown(): boolean {\n return this.shuttingDown;\n }\n\n /**\n * Register a teardown callback, run once when the application shuts down —\n * before the connectors (db, cache, http) are torn down, so it can still use\n * them. The natural place to close an app-owned resource opened in\n * `onceBooted`. If shutdown has already begun, the callback runs immediately.\n *\n * @example\n * Application.onShutdown(async () => {\n * await myQueueConsumer.stop();\n * });\n */\n public static onShutdown(listener: ShutdownListener): void {\n if (this.shuttingDown) {\n void this.runShutdownListener(listener);\n\n return;\n }\n\n this.shutdownListeners.push(listener);\n }\n\n /**\n * Flip the shutdown latch and run every teardown callback. The connectors\n * manager calls this at the start of shutdown — before any connector is torn\n * down. Idempotent and error-isolated: each callback is awaited, a thrown one\n * is logged and does not stop the rest. Listeners run LIFO (reverse of\n * registration), mirroring resource-unwind order.\n *\n * @internal Framework entry points call this; application code must not.\n */\n public static async runShutdownHooks(): Promise<void> {\n if (this.shuttingDown) {\n return;\n }\n\n this.shuttingDown = true;\n\n const listeners = this.shutdownListeners.reverse();\n this.shutdownListeners = [];\n\n for (const listener of listeners) {\n await this.runShutdownListener(listener);\n }\n }\n\n /**\n * Invoke a single teardown callback, isolating its failure so the rest still\n * run and shutdown is never blocked by one bad hook.\n */\n private static async runShutdownListener(listener: ShutdownListener): Promise<void> {\n try {\n await listener();\n } catch (error) {\n log.error(\"application\", \"shutdown-listener\", error as Error);\n }\n }\n\n /**\n * Get project uptime in milliseconds\n */\n public static get uptime(): number {\n return process.uptime() * 1000;\n }\n\n /**\n * Get the current environment\n */\n public static get environment(): Environment {\n return environment();\n }\n\n /**\n * Set the current environment\n */\n public static setEnvironment(env: Environment) {\n setEnvironment(env);\n }\n\n /**\n * Check if the application is running in production environment\n */\n public static get isProduction(): boolean {\n return this.environment === \"production\";\n }\n\n /**\n * Check if the application is running in development environment\n */\n public static get isDevelopment(): boolean {\n return this.environment === \"development\";\n }\n\n /**\n * Check if the application is running in test environment\n */\n public static get isTest(): boolean {\n return this.environment === \"test\";\n }\n\n /**\n * Get the root path\n */\n public static get rootPath(): string {\n return rootPath();\n }\n\n /**\n * Get the src path\n */\n public static get srcPath(): string {\n return srcPath();\n }\n\n /**\n * Get the app path\n */\n public static get appPath(): string {\n return appPath();\n }\n\n /**\n * Get the storage path\n */\n public static get storagePath(): string {\n return storagePath();\n }\n\n /**\n * Get the uploads path\n */\n public static get uploadsPath(): string {\n return uploadsPath();\n }\n\n /**\n * Get the public path\n */\n public static get publicPath(): string {\n return publicPath();\n }\n}\n"],"mappings":";;;;;;AAwCA,IAAa,cAAb,MAAyB;;mCAIY,IAAI,KAAK,KAAK,IAAI,IAAI,QAAQ,OAAO,IAAI,GAAI;;;gBAUxD;;;uBAWuB,CAAC;;;sBAMlB;;;2BAKyB,CAAC;;;;;CAKxD,WAAkB,UAAU;EAC1B,OAAO,oBAAoB;CAC7B;;;;CAKA,OAAc,mBAAmB,UAA2B;EAC1D,KAAK,kBAAkB;CACzB;;;;;;CAOA,WAAkB,WAAoB;EACpC,OAAO,KAAK;CACd;;;;;;;;;;;;;;;CAgBA,OAAc,WAAW,UAA8B;EACrD,IAAI,KAAK,UAAU,KAAK,aAAa;GACnC,MAAM,UAAU,KAAK;GACrB,AAAK,KAAK,gBAAgB,UAAU,OAAO;GAE3C;EACF;EAEA,KAAK,cAAc,KAAK,QAAQ;CAClC;;;;;CAMA,OAAc,aAAmC;EAC/C,IAAI,KAAK,UAAU,KAAK,aACtB,OAAO,QAAQ,QAAQ,KAAK,WAAW;EAGzC,OAAO,IAAI,SAAsB,YAAY;GAC3C,KAAK,YAAY,YAAY,QAAQ,OAAO,CAAC;EAC/C,CAAC;CACH;;;;;;;;;CAUA,OAAc,WAAW,SAA4B;EACnD,IAAI,KAAK,QACP;EAGF,KAAK,SAAS;EACd,KAAK,cAAc;EAEnB,MAAM,YAAY,KAAK;EACvB,KAAK,gBAAgB,CAAC;EAEtB,KAAK,MAAM,YAAY,WACrB,AAAK,KAAK,gBAAgB,UAAU,OAAO;CAE/C;;;;;CAMA,aAAqB,gBACnB,UACA,SACe;EACf,IAAI;GACF,MAAM,SAAS,OAAO;EACxB,SAAS,OAAO;GACd,IAAI,MAAM,eAAe,mBAAmB,KAAc;EAC5D;CACF;;;;;;CAOA,WAAkB,iBAA0B;EAC1C,OAAO,KAAK;CACd;;;;;;;;;;;;CAaA,OAAc,WAAW,UAAkC;EACzD,IAAI,KAAK,cAAc;GACrB,AAAK,KAAK,oBAAoB,QAAQ;GAEtC;EACF;EAEA,KAAK,kBAAkB,KAAK,QAAQ;CACtC;;;;;;;;;;CAWA,aAAoB,mBAAkC;EACpD,IAAI,KAAK,cACP;EAGF,KAAK,eAAe;EAEpB,MAAM,YAAY,KAAK,kBAAkB,QAAQ;EACjD,KAAK,oBAAoB,CAAC;EAE1B,KAAK,MAAM,YAAY,WACrB,MAAM,KAAK,oBAAoB,QAAQ;CAE3C;;;;;CAMA,aAAqB,oBAAoB,UAA2C;EAClF,IAAI;GACF,MAAM,SAAS;EACjB,SAAS,OAAO;GACd,IAAI,MAAM,eAAe,qBAAqB,KAAc;EAC9D;CACF;;;;CAKA,WAAkB,SAAiB;EACjC,OAAO,QAAQ,OAAO,IAAI;CAC5B;;;;CAKA,WAAkB,cAA2B;EAC3C,OAAO,YAAY;CACrB;;;;CAKA,OAAc,eAAe,KAAkB;EAC7C,eAAe,GAAG;CACpB;;;;CAKA,WAAkB,eAAwB;EACxC,OAAO,KAAK,gBAAgB;CAC9B;;;;CAKA,WAAkB,gBAAyB;EACzC,OAAO,KAAK,gBAAgB;CAC9B;;;;CAKA,WAAkB,SAAkB;EAClC,OAAO,KAAK,gBAAgB;CAC9B;;;;CAKA,WAAkB,WAAmB;EACnC,OAAO,SAAS;CAClB;;;;CAKA,WAAkB,UAAkB;EAClC,OAAO,QAAQ;CACjB;;;;CAKA,WAAkB,UAAkB;EAClC,OAAO,QAAQ;CACjB;;;;CAKA,WAAkB,cAAsB;EACtC,OAAO,YAAY;CACrB;;;;CAKA,WAAkB,cAAsB;EACtC,OAAO,YAAY;CACrB;;;;CAKA,WAAkB,aAAqB;EACrC,OAAO,WAAW;CACpB;AACF"}
1
+ {"version":3,"file":"application.mjs","names":[],"sources":["../../../../../../../core/src/application/application.ts"],"sourcesContent":["import { log } from \"@warlock.js/logger\";\nimport { BOOT_SIGNAL_VERSION, sendBootSignal } from \"./boot-signal\";\nimport {\n environment,\n setEnvironment,\n type Environment,\n type RuntimeStrategy,\n} from \"../utils/environment\";\nimport { getFrameworkVersion } from \"../utils/framework-vesion\";\nimport { appPath, publicPath, rootPath, srcPath, storagePath, uploadsPath } from \"../utils/paths\";\n\n/**\n * Snapshot of the global facts known the moment the application finished\n * booting: every connector (early + late) is active and all app files\n * (locales, events, main, routes) are loaded. Passed to every\n * `Application.onceBooted` listener and resolved from `Application.whenBooted`.\n */\nexport type BootContext = {\n environment: Environment;\n runtimeStrategy: RuntimeStrategy;\n /**\n * Wall-clock milliseconds the boot took. The dev server measures it; the\n * production entry omits it.\n */\n bootDurationMs?: number;\n};\n\n/**\n * A callback registered through `Application.onceBooted`. May be async — its\n * rejection is caught and logged so a single listener can never break boot or\n * the other listeners.\n */\nexport type BootListener = (context: BootContext) => void | Promise<void>;\n\n/**\n * A callback registered through `Application.onShutdown`. Runs once, while the\n * connectors (db, cache, http) are still up, so it can release app-owned\n * resources cleanly. May be async — its rejection is caught and logged.\n */\nexport type ShutdownListener = () => void | Promise<void>;\n\nexport class Application {\n /**\n * Project start time regarding the process start time\n */\n public static readonly startedAt = new Date(Date.now() - process.uptime() * 1000);\n\n /**\n * Runtime strategy\n */\n public static runtimeStrategy: RuntimeStrategy;\n\n /**\n * Whether the application has finished booting. Flipped once by `markBooted`.\n */\n private static booted = false;\n\n /**\n * The boot context, set once booted so it can be replayed to late subscribers.\n */\n private static bootContext: BootContext | undefined;\n\n /**\n * Listeners queued before boot completed, drained in registration order by\n * `markBooted`.\n */\n private static bootListeners: BootListener[] = [];\n\n /**\n * The http port this process actually bound, once it has. Reported in the\n * readiness signal so a supervisor knows where to send its health check\n * instead of re-deriving it from config it may not be able to read.\n * Undefined for an app with no http connector — a queue worker still boots.\n */\n private static servedPort: number | undefined;\n\n /**\n * Whether the application has begun (or finished) shutting down. Flipped once\n * by `runShutdownHooks`.\n */\n private static shuttingDown = false;\n\n /**\n * Teardown callbacks, run once when shutdown begins.\n */\n private static shutdownListeners: ShutdownListener[] = [];\n\n /**\n * Get framework version\n */\n public static get version() {\n return getFrameworkVersion();\n }\n\n /**\n * Set the runtime strategy\n */\n public static setRuntimeStrategy(strategy: RuntimeStrategy) {\n this.runtimeStrategy = strategy;\n }\n\n /**\n * Whether the application has finished booting — every connector in both\n * the early and late phases is active and all app files (locales, events,\n * main, routes) have been loaded.\n */\n public static get isBooted(): boolean {\n return this.booted;\n }\n\n /**\n * Record the http port this process bound.\n *\n * @internal The http connector calls this after a successful `listen`.\n */\n public static setServedPort(port: number): void {\n this.servedPort = port;\n }\n\n /**\n * Run a callback once the application is fully booted.\n *\n * App files (`main` / `events` / `routes` / locales) are imported BEFORE the\n * late-phase connectors (http, socket) start, so a listener registered at\n * import time waits for the whole sequence to finish before it runs. If the\n * application is already booted, the callback runs on the next microtask —\n * so a late subscriber never silently misses the event.\n *\n * @example\n * Application.onceBooted(({ environment }) => {\n * log.info(\"app\", \"booted\", `ready in ${environment}`);\n * });\n */\n public static onceBooted(listener: BootListener): void {\n if (this.booted && this.bootContext) {\n const context = this.bootContext;\n void this.runBootListener(listener, context);\n\n return;\n }\n\n this.bootListeners.push(listener);\n }\n\n /**\n * Promise form of {@link onceBooted} — resolves with the boot context once\n * the application is booted, or immediately if it already is.\n */\n public static whenBooted(): Promise<BootContext> {\n if (this.booted && this.bootContext) {\n return Promise.resolve(this.bootContext);\n }\n\n return new Promise<BootContext>((resolve) => {\n this.onceBooted((context) => resolve(context));\n });\n }\n\n /**\n * Flip the boot latch and drain every queued listener. The dev server and\n * the production entry call this once, right after the late phase starts. It\n * is idempotent — a second call is a no-op, so a double-invoke can never\n * double-fire listeners.\n *\n * Also reports readiness to a supervising parent process — this is the one\n * moment in the lifecycle where \"the application is serving\" is true, so it\n * is the only honest source for `warlock start`'s success banner. Sent before\n * the listeners run: a slow or hanging `onceBooted` listener must not delay\n * the parent's view of a server that is already accepting requests.\n *\n * @internal Framework entry points call this; application code must not.\n */\n public static markBooted(context: BootContext): void {\n if (this.booted) {\n return;\n }\n\n this.booted = true;\n this.bootContext = context;\n\n sendBootSignal({\n type: \"warlock:ready\",\n version: BOOT_SIGNAL_VERSION,\n pid: process.pid,\n at: new Date().toISOString(),\n environment: context.environment,\n runtimeStrategy: context.runtimeStrategy,\n bootDurationMs: context.bootDurationMs,\n port: this.servedPort,\n });\n\n const listeners = this.bootListeners;\n this.bootListeners = [];\n\n for (const listener of listeners) {\n void this.runBootListener(listener, context);\n }\n }\n\n /**\n * Invoke a single boot listener, isolating its failure so neither boot nor\n * the remaining listeners are affected.\n */\n private static async runBootListener(\n listener: BootListener,\n context: BootContext,\n ): Promise<void> {\n try {\n await listener(context);\n } catch (error) {\n log.error(\"application\", \"booted-listener\", error as Error);\n }\n }\n\n /**\n * Whether the application has begun shutting down. Once true, readiness\n * checks report not-ready so a load balancer drains this instance before the\n * HTTP server stops accepting requests.\n */\n public static get isShuttingDown(): boolean {\n return this.shuttingDown;\n }\n\n /**\n * Register a teardown callback, run once when the application shuts down —\n * before the connectors (db, cache, http) are torn down, so it can still use\n * them. The natural place to close an app-owned resource opened in\n * `onceBooted`. If shutdown has already begun, the callback runs immediately.\n *\n * @example\n * Application.onShutdown(async () => {\n * await myQueueConsumer.stop();\n * });\n */\n public static onShutdown(listener: ShutdownListener): void {\n if (this.shuttingDown) {\n void this.runShutdownListener(listener);\n\n return;\n }\n\n this.shutdownListeners.push(listener);\n }\n\n /**\n * Flip the shutdown latch and run every teardown callback. The connectors\n * manager calls this at the start of shutdown — before any connector is torn\n * down. Idempotent and error-isolated: each callback is awaited, a thrown one\n * is logged and does not stop the rest. Listeners run LIFO (reverse of\n * registration), mirroring resource-unwind order.\n *\n * @internal Framework entry points call this; application code must not.\n */\n public static async runShutdownHooks(): Promise<void> {\n if (this.shuttingDown) {\n return;\n }\n\n this.shuttingDown = true;\n\n const listeners = this.shutdownListeners.reverse();\n this.shutdownListeners = [];\n\n for (const listener of listeners) {\n await this.runShutdownListener(listener);\n }\n }\n\n /**\n * Invoke a single teardown callback, isolating its failure so the rest still\n * run and shutdown is never blocked by one bad hook.\n */\n private static async runShutdownListener(listener: ShutdownListener): Promise<void> {\n try {\n await listener();\n } catch (error) {\n log.error(\"application\", \"shutdown-listener\", error as Error);\n }\n }\n\n /**\n * Get project uptime in milliseconds\n */\n public static get uptime(): number {\n return process.uptime() * 1000;\n }\n\n /**\n * Get the current environment\n */\n public static get environment(): Environment {\n return environment();\n }\n\n /**\n * Set the current environment\n */\n public static setEnvironment(env: Environment) {\n setEnvironment(env);\n }\n\n /**\n * Check if the application is running in production environment\n */\n public static get isProduction(): boolean {\n return this.environment === \"production\";\n }\n\n /**\n * Check if the application is running in development environment\n */\n public static get isDevelopment(): boolean {\n return this.environment === \"development\";\n }\n\n /**\n * Check if the application is running in test environment\n */\n public static get isTest(): boolean {\n return this.environment === \"test\";\n }\n\n /**\n * Get the root path\n */\n public static get rootPath(): string {\n return rootPath();\n }\n\n /**\n * Get the src path\n */\n public static get srcPath(): string {\n return srcPath();\n }\n\n /**\n * Get the app path\n */\n public static get appPath(): string {\n return appPath();\n }\n\n /**\n * Get the storage path\n */\n public static get storagePath(): string {\n return storagePath();\n }\n\n /**\n * Get the uploads path\n */\n public static get uploadsPath(): string {\n return uploadsPath();\n }\n\n /**\n * Get the public path\n */\n public static get publicPath(): string {\n return publicPath();\n }\n}\n"],"mappings":";;;;;;;AAyCA,IAAa,cAAb,MAAyB;;mCAIY,IAAI,KAAK,KAAK,IAAI,IAAI,QAAQ,OAAO,IAAI,GAAI;;;gBAUxD;;;uBAWuB,CAAC;;;sBAclB;;;2BAKyB,CAAC;;;;;CAKxD,WAAkB,UAAU;EAC1B,OAAO,oBAAoB;CAC7B;;;;CAKA,OAAc,mBAAmB,UAA2B;EAC1D,KAAK,kBAAkB;CACzB;;;;;;CAOA,WAAkB,WAAoB;EACpC,OAAO,KAAK;CACd;;;;;;CAOA,OAAc,cAAc,MAAoB;EAC9C,KAAK,aAAa;CACpB;;;;;;;;;;;;;;;CAgBA,OAAc,WAAW,UAA8B;EACrD,IAAI,KAAK,UAAU,KAAK,aAAa;GACnC,MAAM,UAAU,KAAK;GACrB,AAAK,KAAK,gBAAgB,UAAU,OAAO;GAE3C;EACF;EAEA,KAAK,cAAc,KAAK,QAAQ;CAClC;;;;;CAMA,OAAc,aAAmC;EAC/C,IAAI,KAAK,UAAU,KAAK,aACtB,OAAO,QAAQ,QAAQ,KAAK,WAAW;EAGzC,OAAO,IAAI,SAAsB,YAAY;GAC3C,KAAK,YAAY,YAAY,QAAQ,OAAO,CAAC;EAC/C,CAAC;CACH;;;;;;;;;;;;;;;CAgBA,OAAc,WAAW,SAA4B;EACnD,IAAI,KAAK,QACP;EAGF,KAAK,SAAS;EACd,KAAK,cAAc;EAEnB,eAAe;GACb,MAAM;GACN;GACA,KAAK,QAAQ;GACb,qBAAI,IAAI,KAAK,EAAC,CAAC,YAAY;GAC3B,aAAa,QAAQ;GACrB,iBAAiB,QAAQ;GACzB,gBAAgB,QAAQ;GACxB,MAAM,KAAK;EACb,CAAC;EAED,MAAM,YAAY,KAAK;EACvB,KAAK,gBAAgB,CAAC;EAEtB,KAAK,MAAM,YAAY,WACrB,AAAK,KAAK,gBAAgB,UAAU,OAAO;CAE/C;;;;;CAMA,aAAqB,gBACnB,UACA,SACe;EACf,IAAI;GACF,MAAM,SAAS,OAAO;EACxB,SAAS,OAAO;GACd,IAAI,MAAM,eAAe,mBAAmB,KAAc;EAC5D;CACF;;;;;;CAOA,WAAkB,iBAA0B;EAC1C,OAAO,KAAK;CACd;;;;;;;;;;;;CAaA,OAAc,WAAW,UAAkC;EACzD,IAAI,KAAK,cAAc;GACrB,AAAK,KAAK,oBAAoB,QAAQ;GAEtC;EACF;EAEA,KAAK,kBAAkB,KAAK,QAAQ;CACtC;;;;;;;;;;CAWA,aAAoB,mBAAkC;EACpD,IAAI,KAAK,cACP;EAGF,KAAK,eAAe;EAEpB,MAAM,YAAY,KAAK,kBAAkB,QAAQ;EACjD,KAAK,oBAAoB,CAAC;EAE1B,KAAK,MAAM,YAAY,WACrB,MAAM,KAAK,oBAAoB,QAAQ;CAE3C;;;;;CAMA,aAAqB,oBAAoB,UAA2C;EAClF,IAAI;GACF,MAAM,SAAS;EACjB,SAAS,OAAO;GACd,IAAI,MAAM,eAAe,qBAAqB,KAAc;EAC9D;CACF;;;;CAKA,WAAkB,SAAiB;EACjC,OAAO,QAAQ,OAAO,IAAI;CAC5B;;;;CAKA,WAAkB,cAA2B;EAC3C,OAAO,YAAY;CACrB;;;;CAKA,OAAc,eAAe,KAAkB;EAC7C,eAAe,GAAG;CACpB;;;;CAKA,WAAkB,eAAwB;EACxC,OAAO,KAAK,gBAAgB;CAC9B;;;;CAKA,WAAkB,gBAAyB;EACzC,OAAO,KAAK,gBAAgB;CAC9B;;;;CAKA,WAAkB,SAAkB;EAClC,OAAO,KAAK,gBAAgB;CAC9B;;;;CAKA,WAAkB,WAAmB;EACnC,OAAO,SAAS;CAClB;;;;CAKA,WAAkB,UAAkB;EAClC,OAAO,QAAQ;CACjB;;;;CAKA,WAAkB,UAAkB;EAClC,OAAO,QAAQ;CACjB;;;;CAKA,WAAkB,cAAsB;EACtC,OAAO,YAAY;CACrB;;;;CAKA,WAAkB,cAAsB;EACtC,OAAO,YAAY;CACrB;;;;CAKA,WAAkB,aAAqB;EACrC,OAAO,WAAW;CACpB;AACF"}
@@ -0,0 +1,77 @@
1
+ import { Environment, RuntimeStrategy } from "../utils/environment.mjs";
2
+
3
+ //#region ../core/src/application/boot-signal.d.ts
4
+ /**
5
+ * The only message type the boot channel carries today. Named rather than
6
+ * inlined because both the sender (`sendBootSignal`) and the receiver
7
+ * (`warlock start`) must agree on the exact literal.
8
+ */
9
+ type BootSignalType = "warlock:ready";
10
+ /**
11
+ * Schema version of {@link BootSignal}.
12
+ *
13
+ * Present from the first release on purpose: a version field cannot be added to
14
+ * a shipped wire format without a flag day, and this one has to be able to
15
+ * evolve while older CLIs are still supervising newer bundles.
16
+ */
17
+ declare const BOOT_SIGNAL_VERSION = 1;
18
+ /**
19
+ * What a booted child process tells its supervising parent over the IPC
20
+ * channel `warlock start` opens for it.
21
+ *
22
+ * This is a wire contract, not an internal detail: `warlock start` prints its
23
+ * success banner **only** when this arrives, so anything watching that banner
24
+ * (a CI gate, a process supervisor) transitively depends on it.
25
+ */
26
+ type BootSignal = {
27
+ type: BootSignalType;
28
+ version: number; /** The application process, so a supervisor can address it directly. */
29
+ pid: number; /** When the boot completed, ISO-8601. */
30
+ at: string;
31
+ environment: Environment;
32
+ runtimeStrategy: RuntimeStrategy;
33
+ bootDurationMs?: number; /** The bound http port, absent for an app with no http connector. */
34
+ port?: number;
35
+ };
36
+ /**
37
+ * Environment flag `warlock start` sets on the process it supervises.
38
+ *
39
+ * An IPC channel alone is NOT proof that the parent speaks this protocol —
40
+ * pm2, a vitest worker fork, and any custom supervisor all hand their child a
41
+ * channel they use for their own messages. Writing to one of those, and then
42
+ * closing it, corrupts someone else's protocol and can kill the process. The
43
+ * flag is the handshake: only a parent that set it gets signalled.
44
+ */
45
+ declare const BOOT_SIGNAL_ENV_KEY = "WARLOCK_BOOT_SIGNAL";
46
+ /**
47
+ * Report a completed boot to the parent process, if one is supervising.
48
+ *
49
+ * Two conditions must both hold: the process was spawned with an `ipc` stdio
50
+ * channel (`process.send` exists), and the parent identified itself with
51
+ * {@link BOOT_SIGNAL_ENV_KEY}. A bundle run directly (`node app.mjs`, a Docker
52
+ * `CMD`) or under a foreign supervisor is a no-op, so the signal never changes
53
+ * how a standalone process behaves and never touches a channel we don't own.
54
+ *
55
+ * The channel is closed as soon as the message is flushed. An open IPC channel
56
+ * holds a `ref` on the child's event loop, which would keep a finished or
57
+ * wedged process alive and indistinguishable from a healthy server — the exact
58
+ * confusion this signal exists to remove. One message, then disconnect.
59
+ *
60
+ * Failure to send is deliberately swallowed: the parent may have already
61
+ * detached or died, and a supervisor going away must never take down a
62
+ * healthy application.
63
+ */
64
+ declare function sendBootSignal(signal: BootSignal): void;
65
+ /**
66
+ * Narrow an arbitrary IPC payload to a {@link BootSignal}.
67
+ *
68
+ * The parent receives whatever the child chooses to send, so the message is
69
+ * untrusted input and is validated before it is allowed to flip the parent's
70
+ * "the server is up" state. A *newer* version than this CLI knows is still
71
+ * accepted — readiness is readiness, and refusing it would make an old
72
+ * supervisor report a healthy app as failed.
73
+ */
74
+ declare function isBootSignal(message: unknown): message is BootSignal;
75
+ //#endregion
76
+ export { BOOT_SIGNAL_ENV_KEY, BOOT_SIGNAL_VERSION, BootSignal, BootSignalType, isBootSignal, sendBootSignal };
77
+ //# sourceMappingURL=boot-signal.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"boot-signal.d.mts","names":[],"sources":["../../../../../../../core/src/application/boot-signal.ts"],"mappings":";;;;;AAOA;;;KAAY,cAAA;AAAc;AAS1B;;;;AAAgC;AAUhC;AAnB0B,cASb,mBAAA;;;;;;;;;KAUD,UAAA;EACV,IAAA,EAAM,cAAA;EACN,OAAA,UAKA;EAHA,GAAA,UAIA;EAFA,EAAA;EACA,WAAA,EAAa,WAAA;EACb,eAAA,EAAiB,eAAA;EACjB,cAAA,WAEI;EAAJ,IAAA;AAAA;;;AAY8B;AAoBhC;;;;AAAiD;AA4BjD;cAhDa,mBAAA;;;;;;;AAgDwD;;;;;;;;;;;;iBA5BrD,cAAA,CAAe,MAAkB,EAAV,UAAU;;;;;;;;;;iBA4BjC,YAAA,CAAa,OAAA,YAAmB,OAAA,IAAW,UAAU"}
@@ -0,0 +1,64 @@
1
+ //#region ../core/src/application/boot-signal.ts
2
+ /**
3
+ * Schema version of {@link BootSignal}.
4
+ *
5
+ * Present from the first release on purpose: a version field cannot be added to
6
+ * a shipped wire format without a flag day, and this one has to be able to
7
+ * evolve while older CLIs are still supervising newer bundles.
8
+ */
9
+ const BOOT_SIGNAL_VERSION = 1;
10
+ /**
11
+ * Environment flag `warlock start` sets on the process it supervises.
12
+ *
13
+ * An IPC channel alone is NOT proof that the parent speaks this protocol —
14
+ * pm2, a vitest worker fork, and any custom supervisor all hand their child a
15
+ * channel they use for their own messages. Writing to one of those, and then
16
+ * closing it, corrupts someone else's protocol and can kill the process. The
17
+ * flag is the handshake: only a parent that set it gets signalled.
18
+ */
19
+ const BOOT_SIGNAL_ENV_KEY = "WARLOCK_BOOT_SIGNAL";
20
+ /**
21
+ * Report a completed boot to the parent process, if one is supervising.
22
+ *
23
+ * Two conditions must both hold: the process was spawned with an `ipc` stdio
24
+ * channel (`process.send` exists), and the parent identified itself with
25
+ * {@link BOOT_SIGNAL_ENV_KEY}. A bundle run directly (`node app.mjs`, a Docker
26
+ * `CMD`) or under a foreign supervisor is a no-op, so the signal never changes
27
+ * how a standalone process behaves and never touches a channel we don't own.
28
+ *
29
+ * The channel is closed as soon as the message is flushed. An open IPC channel
30
+ * holds a `ref` on the child's event loop, which would keep a finished or
31
+ * wedged process alive and indistinguishable from a healthy server — the exact
32
+ * confusion this signal exists to remove. One message, then disconnect.
33
+ *
34
+ * Failure to send is deliberately swallowed: the parent may have already
35
+ * detached or died, and a supervisor going away must never take down a
36
+ * healthy application.
37
+ */
38
+ function sendBootSignal(signal) {
39
+ if (typeof process.send !== "function" || process.env["WARLOCK_BOOT_SIGNAL"] !== "1") return;
40
+ delete process.env[BOOT_SIGNAL_ENV_KEY];
41
+ try {
42
+ process.send(signal, void 0, void 0, () => {
43
+ process.disconnect?.();
44
+ });
45
+ } catch {}
46
+ }
47
+ /**
48
+ * Narrow an arbitrary IPC payload to a {@link BootSignal}.
49
+ *
50
+ * The parent receives whatever the child chooses to send, so the message is
51
+ * untrusted input and is validated before it is allowed to flip the parent's
52
+ * "the server is up" state. A *newer* version than this CLI knows is still
53
+ * accepted — readiness is readiness, and refusing it would make an old
54
+ * supervisor report a healthy app as failed.
55
+ */
56
+ function isBootSignal(message) {
57
+ if (typeof message !== "object" || message === null) return false;
58
+ const candidate = message;
59
+ return candidate.type === "warlock:ready" && typeof candidate.version === "number";
60
+ }
61
+
62
+ //#endregion
63
+ export { BOOT_SIGNAL_ENV_KEY, BOOT_SIGNAL_VERSION, isBootSignal, sendBootSignal };
64
+ //# sourceMappingURL=boot-signal.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"boot-signal.mjs","names":[],"sources":["../../../../../../../core/src/application/boot-signal.ts"],"sourcesContent":["import type { Environment, RuntimeStrategy } from \"../utils/environment\";\n\n/**\n * The only message type the boot channel carries today. Named rather than\n * inlined because both the sender (`sendBootSignal`) and the receiver\n * (`warlock start`) must agree on the exact literal.\n */\nexport type BootSignalType = \"warlock:ready\";\n\n/**\n * Schema version of {@link BootSignal}.\n *\n * Present from the first release on purpose: a version field cannot be added to\n * a shipped wire format without a flag day, and this one has to be able to\n * evolve while older CLIs are still supervising newer bundles.\n */\nexport const BOOT_SIGNAL_VERSION = 1;\n\n/**\n * What a booted child process tells its supervising parent over the IPC\n * channel `warlock start` opens for it.\n *\n * This is a wire contract, not an internal detail: `warlock start` prints its\n * success banner **only** when this arrives, so anything watching that banner\n * (a CI gate, a process supervisor) transitively depends on it.\n */\nexport type BootSignal = {\n type: BootSignalType;\n version: number;\n /** The application process, so a supervisor can address it directly. */\n pid: number;\n /** When the boot completed, ISO-8601. */\n at: string;\n environment: Environment;\n runtimeStrategy: RuntimeStrategy;\n bootDurationMs?: number;\n /** The bound http port, absent for an app with no http connector. */\n port?: number;\n};\n\n/**\n * Environment flag `warlock start` sets on the process it supervises.\n *\n * An IPC channel alone is NOT proof that the parent speaks this protocol —\n * pm2, a vitest worker fork, and any custom supervisor all hand their child a\n * channel they use for their own messages. Writing to one of those, and then\n * closing it, corrupts someone else's protocol and can kill the process. The\n * flag is the handshake: only a parent that set it gets signalled.\n */\nexport const BOOT_SIGNAL_ENV_KEY = \"WARLOCK_BOOT_SIGNAL\";\n\n/**\n * Report a completed boot to the parent process, if one is supervising.\n *\n * Two conditions must both hold: the process was spawned with an `ipc` stdio\n * channel (`process.send` exists), and the parent identified itself with\n * {@link BOOT_SIGNAL_ENV_KEY}. A bundle run directly (`node app.mjs`, a Docker\n * `CMD`) or under a foreign supervisor is a no-op, so the signal never changes\n * how a standalone process behaves and never touches a channel we don't own.\n *\n * The channel is closed as soon as the message is flushed. An open IPC channel\n * holds a `ref` on the child's event loop, which would keep a finished or\n * wedged process alive and indistinguishable from a healthy server — the exact\n * confusion this signal exists to remove. One message, then disconnect.\n *\n * Failure to send is deliberately swallowed: the parent may have already\n * detached or died, and a supervisor going away must never take down a\n * healthy application.\n */\nexport function sendBootSignal(signal: BootSignal): void {\n if (typeof process.send !== \"function\" || process.env[BOOT_SIGNAL_ENV_KEY] !== \"1\") {\n return;\n }\n\n // Consumed here so it cannot be inherited by anything this app itself spawns\n // and make a grandchild report readiness on a channel meant for someone else.\n delete process.env[BOOT_SIGNAL_ENV_KEY];\n\n try {\n process.send(signal, undefined, undefined, () => {\n process.disconnect?.();\n });\n } catch {\n // The channel closed between the check and the write — the app is up\n // regardless, and there is no longer anyone to tell.\n }\n}\n\n/**\n * Narrow an arbitrary IPC payload to a {@link BootSignal}.\n *\n * The parent receives whatever the child chooses to send, so the message is\n * untrusted input and is validated before it is allowed to flip the parent's\n * \"the server is up\" state. A *newer* version than this CLI knows is still\n * accepted — readiness is readiness, and refusing it would make an old\n * supervisor report a healthy app as failed.\n */\nexport function isBootSignal(message: unknown): message is BootSignal {\n if (typeof message !== \"object\" || message === null) {\n return false;\n }\n\n const candidate = message as { type?: unknown; version?: unknown };\n\n return candidate.type === \"warlock:ready\" && typeof candidate.version === \"number\";\n}\n"],"mappings":";;;;;;;;AAgBA,MAAa,sBAAsB;;;;;;;;;;AAiCnC,MAAa,sBAAsB;;;;;;;;;;;;;;;;;;;AAoBnC,SAAgB,eAAe,QAA0B;CACvD,IAAI,OAAO,QAAQ,SAAS,cAAc,QAAQ,+BAA6B,KAC7E;CAKF,OAAO,QAAQ,IAAI;CAEnB,IAAI;EACF,QAAQ,KAAK,QAAQ,QAAW,cAAiB;GAC/C,QAAQ,aAAa;EACvB,CAAC;CACH,QAAQ,CAGR;AACF;;;;;;;;;;AAWA,SAAgB,aAAa,SAAyC;CACpE,IAAI,OAAO,YAAY,YAAY,YAAY,MAC7C,OAAO;CAGT,MAAM,YAAY;CAElB,OAAO,UAAU,SAAS,mBAAmB,OAAO,UAAU,YAAY;AAC5E"}
@@ -1,3 +1,4 @@
1
1
  import { app } from "./app.mjs";
2
2
  import { Application, BootContext, BootListener, ShutdownListener } from "./application.mjs";
3
+ import { BOOT_SIGNAL_ENV_KEY, BOOT_SIGNAL_VERSION, BootSignal, BootSignalType, isBootSignal, sendBootSignal } from "./boot-signal.mjs";
3
4
  import { AppConfigurations } from "./application-config-types.mjs";
@@ -1,3 +1,4 @@
1
+ import { BOOT_SIGNAL_ENV_KEY, BOOT_SIGNAL_VERSION, isBootSignal, sendBootSignal } from "./boot-signal.mjs";
1
2
  import { Application } from "./application.mjs";
2
3
  import { app } from "./app.mjs";
3
4
 
package/esm/bootstrap.mjs CHANGED
@@ -1,12 +1,12 @@
1
1
  import { Application } from "./application/application.mjs";
2
2
  import "./application/index.mjs";
3
- import { loadEnv } from "@mongez/dotenv";
3
+ import { loadEnvironmentFiles } from "./utils/load-environment.mjs";
4
4
  import { captureAnyUnhandledRejection } from "@warlock.js/logger";
5
5
  import { initializeDayjs } from "@mongez/time-wizard";
6
6
 
7
7
  //#region ../core/src/bootstrap.ts
8
8
  async function bootstrap() {
9
- await loadEnv();
9
+ await loadEnvironmentFiles();
10
10
  initializeDayjs();
11
11
  captureAnyUnhandledRejection({ exitOnUncaughtException: Application.isProduction });
12
12
  }
@@ -1 +1 @@
1
- {"version":3,"file":"bootstrap.mjs","names":[],"sources":["../../../../../../core/src/bootstrap.ts"],"sourcesContent":["import { loadEnv } from \"@mongez/dotenv\";\nimport { initializeDayjs } from \"@mongez/time-wizard\";\nimport { captureAnyUnhandledRejection } from \"@warlock.js/logger\";\nimport { Application } from \"./application\";\n\nexport async function bootstrap() {\n await loadEnv();\n\n initializeDayjs();\n\n // In production a fatal `uncaughtException` must take the process down loudly\n // (Node's own default) so supervisors restart and `warlock start` surfaces\n // the failure instead of exiting 0 in silence. In development the dev server\n // intentionally survives runtime errors so HMR can recover, so we only log.\n captureAnyUnhandledRejection({ exitOnUncaughtException: Application.isProduction });\n}\n"],"mappings":";;;;;;;AAKA,eAAsB,YAAY;CAChC,MAAM,QAAQ;CAEd,gBAAgB;CAMhB,6BAA6B,EAAE,yBAAyB,YAAY,aAAa,CAAC;AACpF"}
1
+ {"version":3,"file":"bootstrap.mjs","names":[],"sources":["../../../../../../core/src/bootstrap.ts"],"sourcesContent":["import { initializeDayjs } from \"@mongez/time-wizard\";\nimport { captureAnyUnhandledRejection } from \"@warlock.js/logger\";\nimport { Application } from \"./application\";\nimport { loadEnvironmentFiles } from \"./utils/load-environment\";\n\nexport async function bootstrap() {\n // Guarded: a project with no `.env` is legitimate, and a raw `loadEnv()`\n // throws on one. See `loadEnvironmentFiles`.\n await loadEnvironmentFiles();\n\n initializeDayjs();\n\n // In production a fatal `uncaughtException` must take the process down loudly\n // (Node's own default) so supervisors restart and `warlock start` surfaces\n // the failure instead of exiting 0 in silence. In development the dev server\n // intentionally survives runtime errors so HMR can recover, so we only log.\n captureAnyUnhandledRejection({ exitOnUncaughtException: Application.isProduction });\n}\n"],"mappings":";;;;;;;AAKA,eAAsB,YAAY;CAGhC,MAAM,qBAAqB;CAE3B,gBAAgB;CAMhB,6BAA6B,EAAE,yBAAyB,YAAY,aAAa,CAAC;AACpF"}
@@ -2,6 +2,7 @@ import { appPath } from "../utils/paths.mjs";
2
2
  import "../utils/index.mjs";
3
3
  import { Application } from "../application/application.mjs";
4
4
  import "../application/index.mjs";
5
+ import { loadEnvironmentFiles } from "../utils/load-environment.mjs";
5
6
  import { bootstrap } from "../bootstrap.mjs";
6
7
  import { ConnectorLifecyclePhase } from "../connectors/types.mjs";
7
8
  import { connectorsManager } from "../connectors/connectors-manager.mjs";
@@ -12,9 +13,8 @@ import { manifestManager } from "../manifest/manifest-manager.mjs";
12
13
  import { displayBootError, displayCommandError, displayCommandHelp, displayCommandNotFound, displayCommandSuccess, displayExecutingCommand, displayHelp, displayMissingCommand, displayMissingOptions, displayWarlockVersionInTerminal, isMatchingCommandName } from "./cli-commands.utils.mjs";
13
14
  import { cliCommandsLoader } from "./commands-loader.mjs";
14
15
  import { frameworkCommands } from "./framework-cli-commands.mjs";
15
- import { parseCliArgs } from "./parse-cli-args.mjs";
16
+ import { CliOptionValueError, parseCliArgs } from "./parse-cli-args.mjs";
16
17
  import { findSimilar } from "./string-similarity.mjs";
17
- import { loadEnv } from "@mongez/dotenv";
18
18
  import { colors } from "@mongez/copper";
19
19
  import { fileExistsAsync } from "@warlock.js/fs";
20
20
 
@@ -74,7 +74,7 @@ var CLICommandsManager = class {
74
74
  * Start the cli manager
75
75
  */
76
76
  async start() {
77
- const { name, options, args } = parseCliArgs(process.argv);
77
+ const { name, options } = parseCliArgs(process.argv);
78
78
  if (options.noCache) {
79
79
  manifestManager.clearCommandsCache();
80
80
  await manifestManager.removeCommandsFile();
@@ -116,12 +116,35 @@ var CLICommandsManager = class {
116
116
  });
117
117
  process.exit(0);
118
118
  }
119
+ const resolved = this.resolveCommandArgs(command);
119
120
  await this.execute(command, {
120
- options,
121
- args
121
+ options: resolved.options,
122
+ args: resolved.args
122
123
  });
123
124
  }
124
125
  /**
126
+ * Re-parse argv against the resolved command's declared options.
127
+ *
128
+ * parseCliArgs runs twice on purpose. The first run produces the command
129
+ * name, and there is no schema to consult yet; the second one, here, can be
130
+ * type-aware. Re-reading argv (rather than post-processing the first result)
131
+ * is what makes `--rollback users.ts` recoverable: by the time the first pass
132
+ * returns, the swallowed positional is indistinguishable from a value.
133
+ */
134
+ resolveCommandArgs(command, argv = process.argv) {
135
+ try {
136
+ return parseCliArgs(argv, command.commandOptions);
137
+ } catch (error) {
138
+ if (error instanceof CliOptionValueError) {
139
+ console.log();
140
+ console.log(` ${colors.redBright("Error:")} ${error.message}`);
141
+ console.log();
142
+ process.exit(1);
143
+ }
144
+ throw error;
145
+ }
146
+ }
147
+ /**
125
148
  * Show global help with all commands
126
149
  * Uses manifest if available for fast display
127
150
  */
@@ -228,7 +251,7 @@ var CLICommandsManager = class {
228
251
  if (opt.defaultValue !== void 0) {
229
252
  if (!(result[opt.name] !== void 0 || opt.alias && result[opt.alias] !== void 0)) result[opt.name] = opt.defaultValue;
230
253
  }
231
- if (opt.alias !== void 0 && result[opt.alias] && result[opt.name] === void 0) result[opt.name] = result[opt.alias];
254
+ if (opt.alias !== void 0 && result[opt.alias] !== void 0 && result[opt.name] === void 0) result[opt.name] = result[opt.alias];
232
255
  });
233
256
  return result;
234
257
  }
@@ -269,10 +292,10 @@ var CLICommandsManager = class {
269
292
  const preloaders = command.commandPreload || {};
270
293
  if (preloaders.runtimeStrategy) Application.setRuntimeStrategy(preloaders.runtimeStrategy);
271
294
  if (preloaders.environemnt) Application.setEnvironment(preloaders.environemnt);
295
+ await loadEnvironmentFiles();
272
296
  await warlockConfigManager.load();
273
297
  if (preloaders.config || preloaders.bootstrap || preloaders.prestart) await filesOrchestrator.init();
274
- if (preloaders.env && !preloaders.bootstrap) await loadEnv();
275
- else if (preloaders.bootstrap) {
298
+ if (preloaders.bootstrap) {
276
299
  await bootstrap();
277
300
  if (await fileExistsAsync(appPath("bootstrap.ts"))) await filesOrchestrator.load("src/app/bootstrap.ts");
278
301
  }