@luckystack/devkit 0.4.1 → 0.5.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.
@@ -1,65 +1,65 @@
1
- /* eslint-disable unicorn/no-abusive-eslint-disable */
2
- /* eslint-disable */
3
-
4
- //@ts-ignore We replace {{REL_PATH}} with the relative path to the project root at scaffold time.
5
- import { AuthProps, SessionLayout } from '{{REL_PATH}}config';
6
- //@ts-ignore We replace {{REL_PATH}} with the relative path to the project root at scaffold time.
7
- import { Functions, SyncServerResponse, MaybePromise, SyncServerStreamEmitter, SyncBroadcastStreamEmitter, SyncStreamToEmitter } from '{{REL_PATH}}src/_sockets/apiTypes.generated';
8
-
9
- export const auth: AuthProps = {
10
- login: true,
11
- additional: []
12
- };
13
-
14
- export interface SyncParams {
15
- clientInput: {
16
- // Define the data shape sent from the client e.g.
17
- // message: string;
18
- // targetUserId: string;
19
- };
20
- user: SessionLayout; // session data of the user who called the sync event
21
- functions: Functions; // functions object
22
- roomCode: string; // room code
23
- //? Stream primitives — pick whichever audience matches your use case:
24
- //? stream → originator only (cheapest)
25
- //? broadcastStream → every socket in the receiver room (live AI chat, collab)
26
- //? streamTo → specific session tokens only (selective subscribers)
27
- stream: SyncServerStreamEmitter;
28
- broadcastStream: SyncBroadcastStreamEmitter;
29
- streamTo: SyncStreamToEmitter;
30
- //? Aborts on client-side cancel (`syncRequest({ signal })`) or socket
31
- //? disconnect. Check `abortSignal.aborted` in long-running loops and bail
32
- //? out — already-emitted chunks are not unsent, but new ones are
33
- //? short-circuited automatically by the stream emitters.
34
- abortSignal: AbortSignal;
35
- //? Awaitable backpressure helper. Resolves once the worst-case write
36
- //? buffer across affected sockets drops below the threshold (default 1 MB).
37
- //? Use opt-in between batches of stream chunks to keep memory bounded.
38
- flushPressure: (options?: { thresholdBytes?: number }) => Promise<void>;
39
- }
40
-
41
- export const main = ({ }: SyncParams): MaybePromise<SyncServerResponse> => {
42
- // THIS FILE RUNS JUST ONCE ON THE SERVER
43
-
44
- // Stream payload types are generated from your stream(...) calls.
45
- // stream({ phase: 'validate', progress: 10 }); // → originator only
46
- // broadcastStream({ chunk: 'hello' }); // → everyone in the room
47
- // streamTo([adminToken], { audit: 'event' }); // → specific tokens
48
-
49
- // For LLM token streams, coalesce small pieces with createStreamThrottle:
50
- // import { createStreamThrottle } from '@luckystack/sync';
51
- // const throttle = createStreamThrottle({ flushEveryMs: 50, flushAtChars: 32 });
52
- // for await (const piece of aiStream) throttle.push(piece.text, broadcastStream);
53
- // throttle.flush(broadcastStream);
54
-
55
- // Return { status: 'error', message: '...' } OR { status: 'error', errorCode: '...' }
56
- // Returning error here aborts the full sync flow.
57
-
58
- // Please validate clientInput here and dont just send the data back to the other clients
59
- // optional: database action or something else
60
-
61
- return {
62
- status: 'success',
63
- // Add any data you want to broadcast to clients
64
- };
1
+ /* eslint-disable unicorn/no-abusive-eslint-disable */
2
+ /* eslint-disable */
3
+
4
+ //@ts-ignore We replace {{REL_PATH}} with the relative path to the project root at scaffold time.
5
+ import { AuthProps, SessionLayout } from '{{REL_PATH}}config';
6
+ //@ts-ignore We replace {{REL_PATH}} with the relative path to the project root at scaffold time.
7
+ import { Functions, SyncServerResponse, MaybePromise, SyncServerStreamEmitter, SyncBroadcastStreamEmitter, SyncStreamToEmitter } from '{{REL_PATH}}src/_sockets/apiTypes.generated';
8
+
9
+ export const auth: AuthProps = {
10
+ login: true,
11
+ additional: []
12
+ };
13
+
14
+ export interface SyncParams {
15
+ clientInput: {
16
+ // Define the data shape sent from the client e.g.
17
+ // message: string;
18
+ // targetUserId: string;
19
+ };
20
+ user: SessionLayout; // session data of the user who called the sync event
21
+ functions: Functions; // functions object
22
+ roomCode: string; // room code
23
+ //? Stream primitives — pick whichever audience matches your use case:
24
+ //? stream → originator only (cheapest)
25
+ //? broadcastStream → every socket in the receiver room (live AI chat, collab)
26
+ //? streamTo → specific session tokens only (selective subscribers)
27
+ stream: SyncServerStreamEmitter;
28
+ broadcastStream: SyncBroadcastStreamEmitter;
29
+ streamTo: SyncStreamToEmitter;
30
+ //? Aborts on client-side cancel (`syncRequest({ signal })`) or socket
31
+ //? disconnect. Check `abortSignal.aborted` in long-running loops and bail
32
+ //? out — already-emitted chunks are not unsent, but new ones are
33
+ //? short-circuited automatically by the stream emitters.
34
+ abortSignal: AbortSignal;
35
+ //? Awaitable backpressure helper. Resolves once the worst-case write
36
+ //? buffer across affected sockets drops below the threshold (default 1 MB).
37
+ //? Use opt-in between batches of stream chunks to keep memory bounded.
38
+ flushPressure: (options?: { thresholdBytes?: number }) => Promise<void>;
39
+ }
40
+
41
+ export const main = ({ }: SyncParams): MaybePromise<SyncServerResponse> => {
42
+ // THIS FILE RUNS JUST ONCE ON THE SERVER
43
+
44
+ // Stream payload types are generated from your stream(...) calls.
45
+ // stream({ phase: 'validate', progress: 10 }); // → originator only
46
+ // broadcastStream({ chunk: 'hello' }); // → everyone in the room
47
+ // streamTo([adminToken], { audit: 'event' }); // → specific tokens
48
+
49
+ // For LLM token streams, coalesce small pieces with createStreamThrottle:
50
+ // import { createStreamThrottle } from '@luckystack/sync';
51
+ // const throttle = createStreamThrottle({ flushEveryMs: 50, flushAtChars: 32 });
52
+ // for await (const piece of aiStream) throttle.push(piece.text, broadcastStream);
53
+ // throttle.flush(broadcastStream);
54
+
55
+ // Return { status: 'error', message: '...' } OR { status: 'error', errorCode: '...' }
56
+ // Returning error here aborts the full sync flow.
57
+
58
+ // Please validate clientInput here and dont just send the data back to the other clients
59
+ // optional: database action or something else
60
+
61
+ return {
62
+ status: 'success',
63
+ // Add any data you want to broadcast to clients
64
+ };
65
65
  };
package/docs/cli.md CHANGED
@@ -1,245 +1,245 @@
1
- # CLI (`luckystack-validate-deploy` + `validateDeploy`)
2
-
3
- > Dev / build-time only. The CLI is the pre-deploy gate that runs against compiled `services.config.js` + `deploy.config.js` artifacts. The validator itself (`validateDeploy`) is a pure function — safe to call directly from a build script without going through the CLI.
4
-
5
- `@luckystack/devkit` ships a single bin entry:
6
-
7
- ```json
8
- {
9
- "bin": {
10
- "luckystack-validate-deploy": "./dist/cli/validateDeploy.js"
11
- }
12
- }
13
- ```
14
-
15
- The CLI is a thin wrapper around `validateDeploy()` from `src/validateDeploy.ts`. The validator runs nine rules against the project's `services.config` + `deploy.config` snapshots and returns a structured `ValidateDeployResult` with one finding per detected problem.
16
-
17
- ---
18
-
19
- ## CLI surface
20
-
21
- ```
22
- USAGE
23
- luckystack-validate-deploy --deploy <file> --services <file> [options]
24
-
25
- REQUIRED
26
- --deploy, -d <file> Path to compiled deploy.config.js (registers DeployConfig).
27
- --services, -s <file> Path to compiled services.config.js (registers ServicesConfig).
28
-
29
- OPTIONS
30
- --strict Exit 1 on warnings as well as errors.
31
- --help, -h Show this help.
32
- ```
33
-
34
- Argument parsing (`parseArgs(argv)`):
35
-
36
- ```typescript
37
- interface CliArgs {
38
- deploy: string | null;
39
- services: string | null;
40
- failOnWarning: boolean;
41
- }
42
- ```
43
-
44
- Both config paths are required. There is no implicit search of the working directory — the CLI demands an explicit path so build pipelines can't accidentally pick up a stale untracked file.
45
-
46
- ---
47
-
48
- ## Side-effect import order
49
-
50
- ```typescript
51
- await importConfig(args.deploy, 'deploy config');
52
- await importConfig(args.services, 'services config');
53
-
54
- if (!isDeployConfigRegistered()) {
55
- // exit 2 with hint
56
- }
57
- if (!isServicesConfigRegistered()) {
58
- // exit 2 with hint
59
- }
60
- ```
61
-
62
- The compiled config files are expected to call `registerDeployConfig(...)` / `registerServicesConfig(...)` (re-exported from `@luckystack/core`) as a side effect of import. The CLI imports each file via `pathToFileURL(absolute).href` (Windows-safe), then probes the registry with `isDeployConfigRegistered()` / `isServicesConfigRegistered()`.
63
-
64
- If a file imports cleanly but doesn't register, the CLI exits with code 2 and a message:
65
-
66
- ```
67
- [luckystack-validate-deploy] deploy config file did not call registerDeployConfig — nothing to validate.
68
- ```
69
-
70
- This is the most common authoring mistake: forgetting the registration call leaves a silent empty registry, which would otherwise pass validation with zero findings.
71
-
72
- `importConfig` wraps the dynamic import in a try/catch and re-throws with a clearer message including the absolute path:
73
-
74
- ```typescript
75
- const importConfig = async (file: string, label: string): Promise<void> => {
76
- const abs = path.isAbsolute(file) ? file : path.join(process.cwd(), file);
77
- try {
78
- await import(pathToFileURL(abs).href);
79
- } catch (error) {
80
- throw new Error(`[luckystack-validate-deploy] failed to import ${label} at ${abs}: ${message}`);
81
- }
82
- };
83
- ```
84
-
85
- ---
86
-
87
- ## `validateDeploy(input)` — pure validator
88
-
89
- Defined in `packages/devkit/src/validateDeploy.ts`. Public types:
90
-
91
- ```typescript
92
- export type ValidationSeverity = 'error' | 'warning';
93
-
94
- export interface ValidationFinding {
95
- severity: ValidationSeverity;
96
- code: string;
97
- message: string;
98
- /** Human-readable location pointer (e.g. `services.config.ts > presets.api`). */
99
- location?: string;
100
- }
101
-
102
- export interface ValidateDeployInput {
103
- services: ServicesConfigShape;
104
- deploy: DeployConfigShape;
105
- env?: Record<string, string | undefined>; // defaults to process.env
106
- }
107
-
108
- export interface ValidateDeployResult {
109
- ok: boolean;
110
- findings: ValidationFinding[];
111
- errorCount: number;
112
- warningCount: number;
113
- }
114
-
115
- export const validateDeploy = ({ services, deploy, env = process.env }: ValidateDeployInput): ValidateDeployResult;
116
- ```
117
-
118
- `ServicesConfigShape` / `DeployConfigShape` are re-exported from `@luckystack/core` and define the canonical shape of the two config files. The validator never reads from disk — it works exclusively from the in-memory snapshots passed in.
119
-
120
- ### Rules
121
-
122
- | # | Rule | Code | Severity |
123
- |---|---|---|---|
124
- | 1 | Every service is assigned to exactly one preset | `service-unassigned`, `service-in-multiple-presets` | error |
125
- | 2 | Every preset references services that exist | `preset-references-unknown-service` | error |
126
- | 3 | Every environment binding's service matches an actual service | `binding-references-unknown-service` | error |
127
- | 3a | Every environment binding URL parses as a valid URL | `binding-invalid-url` | error |
128
- | 3b | Every environment binding URL declares an explicit port | `binding-missing-port` | error |
129
- | 4 | Every environment's redis/mongo resource keys exist | `unknown-redis-resource`, `unknown-mongo-resource` | error |
130
- | 5 | Fallback envs must share the same redis/mongo resource keys (shared-resource invariant) | `unknown-fallback-env`, `fallback-redis-mismatch`, `fallback-mongo-mismatch` | error |
131
- | 6 | `urlEnvKey` values resolve at config time | `missing-resource-env-var` | warning |
132
- | 7 | `synchronizedEnvKeys` values resolve at config time | `missing-synchronized-env-var` | warning |
133
- | 8 | Services bound in no environment | `service-bound-in-no-environment` | warning |
134
-
135
- Each rule pushes one or more `ValidationFinding` records into a single result array; the validator never throws. The CLI iterates findings and prints each one before deciding the exit code.
136
-
137
- ### Findings output
138
-
139
- ```typescript
140
- const printFinding = (finding: ValidationFinding): void => {
141
- const tag = finding.severity === 'error'
142
- ? `<red bold>ERROR</red>`
143
- : `<yellow bold>WARN </yellow>`;
144
- const code = `<dim>[${finding.code}]</dim>`;
145
- const location = finding.location ? `\n <dim>at ${finding.location}</dim>` : '';
146
- const sink = finding.severity === 'error' ? process.stderr : process.stdout;
147
- sink.write(`${tag} ${code} ${finding.message}${location}\n`);
148
- };
149
- ```
150
-
151
- - Errors go to stderr, warnings to stdout — so CI pipelines that only mirror stderr see failures cleanly.
152
- - ANSI color is only applied when stdout is a TTY (`process.stdout.isTTY`). Piping the CLI through `tee`, redirecting to a file, or running under non-TTY CI yields plain text.
153
- - The trailing summary line is the same as a single finding: `OK — 0 error(s), 2 warning(s)` (green) or `FAILED — 1 error(s), 0 warning(s)` (red).
154
-
155
- ### Exit codes
156
-
157
- | Code | Meaning |
158
- |---|---|
159
- | `0` | No errors. Warnings are allowed unless `--strict`. |
160
- | `1` | At least one error finding, OR `--strict` with at least one warning. |
161
- | `2` | Bad CLI usage: missing required flag, failed import, or config file didn't call its register function. |
162
-
163
- Designed to be a pre-deploy gate — the recommended placement is the last step of `npm run build` or a dedicated CI job after `tsc -p tsconfig.build.json`.
164
-
165
- ---
166
-
167
- ## Calling from a build script
168
-
169
- ```typescript
170
- import { validateDeploy } from '@luckystack/devkit';
171
- import { getServicesConfig, getDeployConfig } from '@luckystack/core';
172
-
173
- await import('./services.config');
174
- await import('./deploy.config');
175
-
176
- const result = validateDeploy({
177
- services: getServicesConfig(),
178
- deploy: getDeployConfig(),
179
- });
180
-
181
- if (!result.ok) {
182
- for (const finding of result.findings) {
183
- console.error(finding);
184
- }
185
- process.exit(1);
186
- }
187
- ```
188
-
189
- `validateDeploy` is pure and has no I/O. Pass a `env` fixture in tests:
190
-
191
- ```typescript
192
- const result = validateDeploy({
193
- services: testServices,
194
- deploy: testDeploy,
195
- env: {
196
- REDIS_URL: 'redis://localhost:6379',
197
- // MONGO_URL deliberately absent to trigger the warning
198
- },
199
- });
200
-
201
- expect(result.findings.map(f => f.code)).toContain('missing-resource-env-var');
202
- ```
203
-
204
- The CLI is sugar; library consumers should reach for `validateDeploy` directly and decide their own exit behavior.
205
-
206
- ---
207
-
208
- ## Failure modes
209
-
210
- | Symptom | Cause | Exit code |
211
- |---|---|---|
212
- | `--deploy and --services are required.` | Missing required flag | 2 |
213
- | `failed to import deploy config at <path>: ...` | The file path is wrong, throws on evaluation, or is missing | 2 |
214
- | `... did not call registerDeployConfig — nothing to validate` | Import succeeded but the consumer forgot the registration call | 2 |
215
- | `ERROR [service-unassigned] ...` | A service in `services.services` isn't in any preset | 1 |
216
- | `ERROR [preset-references-unknown-service] ...` | A preset references a service that doesn't exist | 1 |
217
- | `ERROR [binding-invalid-url] ...` | A binding URL in `deploy.config.ts > environments.<env>.bindings.<service>` doesn't parse as a valid URL | 1 |
218
- | `ERROR [binding-missing-port] ...` | A binding URL in `deploy.config.ts > environments.<env>.bindings.<service>` is missing an explicit port — the router requires one to disambiguate per-preset backends | 1 |
219
- | `ERROR [fallback-redis-mismatch] ...` | Two envs in a fallback chain reference different redis resource keys | 1 |
220
- | `WARN [missing-resource-env-var] ...` | A resource's `urlEnvKey` is unset at config time (boot may still succeed if env is set at runtime) | 0 (1 with `--strict`) |
221
- | `FAILED — N error(s)` | Run had at least one error finding | 1 |
222
- | `OK — 0 error(s), N warning(s)` | No errors; warnings tolerated | 0 |
223
-
224
- ---
225
-
226
- ## Public exports
227
-
228
- Re-exported from `@luckystack/devkit`:
229
-
230
- ```typescript
231
- export { validateDeploy } from './validateDeploy';
232
- export type {
233
- ValidateDeployInput,
234
- ValidateDeployResult,
235
- ValidationFinding,
236
- ValidationSeverity,
237
- } from './validateDeploy';
238
- ```
239
-
240
- Core registry probes used by the CLI live in `@luckystack/core`:
241
-
242
- - `getServicesConfig()` / `isServicesConfigRegistered()`
243
- - `getDeployConfig()` / `isDeployConfigRegistered()`
244
-
245
- These are not re-exported by devkit; consumers calling `validateDeploy` from build scripts should import them directly from core.
1
+ # CLI (`luckystack-validate-deploy` + `validateDeploy`)
2
+
3
+ > Dev / build-time only. The CLI is the pre-deploy gate that runs against compiled `services.config.js` + `deploy.config.js` artifacts. The validator itself (`validateDeploy`) is a pure function — safe to call directly from a build script without going through the CLI.
4
+
5
+ `@luckystack/devkit` ships a single bin entry:
6
+
7
+ ```json
8
+ {
9
+ "bin": {
10
+ "luckystack-validate-deploy": "./dist/cli/validateDeploy.js"
11
+ }
12
+ }
13
+ ```
14
+
15
+ The CLI is a thin wrapper around `validateDeploy()` from `src/validateDeploy.ts`. The validator runs nine rules against the project's `services.config` + `deploy.config` snapshots and returns a structured `ValidateDeployResult` with one finding per detected problem.
16
+
17
+ ---
18
+
19
+ ## CLI surface
20
+
21
+ ```
22
+ USAGE
23
+ luckystack-validate-deploy --deploy <file> --services <file> [options]
24
+
25
+ REQUIRED
26
+ --deploy, -d <file> Path to compiled deploy.config.js (registers DeployConfig).
27
+ --services, -s <file> Path to compiled services.config.js (registers ServicesConfig).
28
+
29
+ OPTIONS
30
+ --strict Exit 1 on warnings as well as errors.
31
+ --help, -h Show this help.
32
+ ```
33
+
34
+ Argument parsing (`parseArgs(argv)`):
35
+
36
+ ```typescript
37
+ interface CliArgs {
38
+ deploy: string | null;
39
+ services: string | null;
40
+ failOnWarning: boolean;
41
+ }
42
+ ```
43
+
44
+ Both config paths are required. There is no implicit search of the working directory — the CLI demands an explicit path so build pipelines can't accidentally pick up a stale untracked file.
45
+
46
+ ---
47
+
48
+ ## Side-effect import order
49
+
50
+ ```typescript
51
+ await importConfig(args.deploy, 'deploy config');
52
+ await importConfig(args.services, 'services config');
53
+
54
+ if (!isDeployConfigRegistered()) {
55
+ // exit 2 with hint
56
+ }
57
+ if (!isServicesConfigRegistered()) {
58
+ // exit 2 with hint
59
+ }
60
+ ```
61
+
62
+ The compiled config files are expected to call `registerDeployConfig(...)` / `registerServicesConfig(...)` (re-exported from `@luckystack/core`) as a side effect of import. The CLI imports each file via `pathToFileURL(absolute).href` (Windows-safe), then probes the registry with `isDeployConfigRegistered()` / `isServicesConfigRegistered()`.
63
+
64
+ If a file imports cleanly but doesn't register, the CLI exits with code 2 and a message:
65
+
66
+ ```
67
+ [luckystack-validate-deploy] deploy config file did not call registerDeployConfig — nothing to validate.
68
+ ```
69
+
70
+ This is the most common authoring mistake: forgetting the registration call leaves a silent empty registry, which would otherwise pass validation with zero findings.
71
+
72
+ `importConfig` wraps the dynamic import in a try/catch and re-throws with a clearer message including the absolute path:
73
+
74
+ ```typescript
75
+ const importConfig = async (file: string, label: string): Promise<void> => {
76
+ const abs = path.isAbsolute(file) ? file : path.join(process.cwd(), file);
77
+ try {
78
+ await import(pathToFileURL(abs).href);
79
+ } catch (error) {
80
+ throw new Error(`[luckystack-validate-deploy] failed to import ${label} at ${abs}: ${message}`);
81
+ }
82
+ };
83
+ ```
84
+
85
+ ---
86
+
87
+ ## `validateDeploy(input)` — pure validator
88
+
89
+ Defined in `packages/devkit/src/validateDeploy.ts`. Public types:
90
+
91
+ ```typescript
92
+ export type ValidationSeverity = 'error' | 'warning';
93
+
94
+ export interface ValidationFinding {
95
+ severity: ValidationSeverity;
96
+ code: string;
97
+ message: string;
98
+ /** Human-readable location pointer (e.g. `services.config.ts > presets.api`). */
99
+ location?: string;
100
+ }
101
+
102
+ export interface ValidateDeployInput {
103
+ services: ServicesConfigShape;
104
+ deploy: DeployConfigShape;
105
+ env?: Record<string, string | undefined>; // defaults to process.env
106
+ }
107
+
108
+ export interface ValidateDeployResult {
109
+ ok: boolean;
110
+ findings: ValidationFinding[];
111
+ errorCount: number;
112
+ warningCount: number;
113
+ }
114
+
115
+ export const validateDeploy = ({ services, deploy, env = process.env }: ValidateDeployInput): ValidateDeployResult;
116
+ ```
117
+
118
+ `ServicesConfigShape` / `DeployConfigShape` are re-exported from `@luckystack/core` and define the canonical shape of the two config files. The validator never reads from disk — it works exclusively from the in-memory snapshots passed in.
119
+
120
+ ### Rules
121
+
122
+ | # | Rule | Code | Severity |
123
+ |---|---|---|---|
124
+ | 1 | Every service is assigned to exactly one preset | `service-unassigned`, `service-in-multiple-presets` | error |
125
+ | 2 | Every preset references services that exist | `preset-references-unknown-service` | error |
126
+ | 3 | Every environment binding's service matches an actual service | `binding-references-unknown-service` | error |
127
+ | 3a | Every environment binding URL parses as a valid URL | `binding-invalid-url` | error |
128
+ | 3b | Every environment binding URL declares an explicit port | `binding-missing-port` | error |
129
+ | 4 | Every environment's redis/mongo resource keys exist | `unknown-redis-resource`, `unknown-mongo-resource` | error |
130
+ | 5 | Fallback envs must share the same redis/mongo resource keys (shared-resource invariant) | `unknown-fallback-env`, `fallback-redis-mismatch`, `fallback-mongo-mismatch` | error |
131
+ | 6 | `urlEnvKey` values resolve at config time | `missing-resource-env-var` | warning |
132
+ | 7 | `synchronizedEnvKeys` values resolve at config time | `missing-synchronized-env-var` | warning |
133
+ | 8 | Services bound in no environment | `service-bound-in-no-environment` | warning |
134
+
135
+ Each rule pushes one or more `ValidationFinding` records into a single result array; the validator never throws. The CLI iterates findings and prints each one before deciding the exit code.
136
+
137
+ ### Findings output
138
+
139
+ ```typescript
140
+ const printFinding = (finding: ValidationFinding): void => {
141
+ const tag = finding.severity === 'error'
142
+ ? `<red bold>ERROR</red>`
143
+ : `<yellow bold>WARN </yellow>`;
144
+ const code = `<dim>[${finding.code}]</dim>`;
145
+ const location = finding.location ? `\n <dim>at ${finding.location}</dim>` : '';
146
+ const sink = finding.severity === 'error' ? process.stderr : process.stdout;
147
+ sink.write(`${tag} ${code} ${finding.message}${location}\n`);
148
+ };
149
+ ```
150
+
151
+ - Errors go to stderr, warnings to stdout — so CI pipelines that only mirror stderr see failures cleanly.
152
+ - ANSI color is only applied when stdout is a TTY (`process.stdout.isTTY`). Piping the CLI through `tee`, redirecting to a file, or running under non-TTY CI yields plain text.
153
+ - The trailing summary line is the same as a single finding: `OK — 0 error(s), 2 warning(s)` (green) or `FAILED — 1 error(s), 0 warning(s)` (red).
154
+
155
+ ### Exit codes
156
+
157
+ | Code | Meaning |
158
+ |---|---|
159
+ | `0` | No errors. Warnings are allowed unless `--strict`. |
160
+ | `1` | At least one error finding, OR `--strict` with at least one warning. |
161
+ | `2` | Bad CLI usage: missing required flag, failed import, or config file didn't call its register function. |
162
+
163
+ Designed to be a pre-deploy gate — the recommended placement is the last step of `npm run build` or a dedicated CI job after `tsc -p tsconfig.build.json`.
164
+
165
+ ---
166
+
167
+ ## Calling from a build script
168
+
169
+ ```typescript
170
+ import { validateDeploy } from '@luckystack/devkit';
171
+ import { getServicesConfig, getDeployConfig } from '@luckystack/core';
172
+
173
+ await import('./services.config');
174
+ await import('./deploy.config');
175
+
176
+ const result = validateDeploy({
177
+ services: getServicesConfig(),
178
+ deploy: getDeployConfig(),
179
+ });
180
+
181
+ if (!result.ok) {
182
+ for (const finding of result.findings) {
183
+ console.error(finding);
184
+ }
185
+ process.exit(1);
186
+ }
187
+ ```
188
+
189
+ `validateDeploy` is pure and has no I/O. Pass a `env` fixture in tests:
190
+
191
+ ```typescript
192
+ const result = validateDeploy({
193
+ services: testServices,
194
+ deploy: testDeploy,
195
+ env: {
196
+ REDIS_URL: 'redis://localhost:6379',
197
+ // MONGO_URL deliberately absent to trigger the warning
198
+ },
199
+ });
200
+
201
+ expect(result.findings.map(f => f.code)).toContain('missing-resource-env-var');
202
+ ```
203
+
204
+ The CLI is sugar; library consumers should reach for `validateDeploy` directly and decide their own exit behavior.
205
+
206
+ ---
207
+
208
+ ## Failure modes
209
+
210
+ | Symptom | Cause | Exit code |
211
+ |---|---|---|
212
+ | `--deploy and --services are required.` | Missing required flag | 2 |
213
+ | `failed to import deploy config at <path>: ...` | The file path is wrong, throws on evaluation, or is missing | 2 |
214
+ | `... did not call registerDeployConfig — nothing to validate` | Import succeeded but the consumer forgot the registration call | 2 |
215
+ | `ERROR [service-unassigned] ...` | A service in `services.services` isn't in any preset | 1 |
216
+ | `ERROR [preset-references-unknown-service] ...` | A preset references a service that doesn't exist | 1 |
217
+ | `ERROR [binding-invalid-url] ...` | A binding URL in `deploy.config.ts > environments.<env>.bindings.<service>` doesn't parse as a valid URL | 1 |
218
+ | `ERROR [binding-missing-port] ...` | A binding URL in `deploy.config.ts > environments.<env>.bindings.<service>` is missing an explicit port — the router requires one to disambiguate per-preset backends | 1 |
219
+ | `ERROR [fallback-redis-mismatch] ...` | Two envs in a fallback chain reference different redis resource keys | 1 |
220
+ | `WARN [missing-resource-env-var] ...` | A resource's `urlEnvKey` is unset at config time (boot may still succeed if env is set at runtime) | 0 (1 with `--strict`) |
221
+ | `FAILED — N error(s)` | Run had at least one error finding | 1 |
222
+ | `OK — 0 error(s), N warning(s)` | No errors; warnings tolerated | 0 |
223
+
224
+ ---
225
+
226
+ ## Public exports
227
+
228
+ Re-exported from `@luckystack/devkit`:
229
+
230
+ ```typescript
231
+ export { validateDeploy } from './validateDeploy';
232
+ export type {
233
+ ValidateDeployInput,
234
+ ValidateDeployResult,
235
+ ValidationFinding,
236
+ ValidationSeverity,
237
+ } from './validateDeploy';
238
+ ```
239
+
240
+ Core registry probes used by the CLI live in `@luckystack/core`:
241
+
242
+ - `getServicesConfig()` / `isServicesConfigRegistered()`
243
+ - `getDeployConfig()` / `isDeployConfigRegistered()`
244
+
245
+ These are not re-exported by devkit; consumers calling `validateDeploy` from build scripts should import them directly from core.