@launchfile/macos-dev 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap.d.ts +56 -8
- package/dist/bootstrap.js +170 -93
- package/dist/env-writer.d.ts +40 -11
- package/dist/env-writer.js +84 -23
- package/dist/health.d.ts +8 -2
- package/dist/health.js +11 -17
- package/dist/prereqs.js +2 -2
- package/dist/process-manager.d.ts +13 -0
- package/dist/process-manager.js +59 -3
- package/dist/process-stopper.d.ts +99 -0
- package/dist/process-stopper.js +165 -0
- package/dist/provider.d.ts +50 -0
- package/dist/provider.js +337 -28
- package/dist/redact.d.ts +55 -0
- package/dist/redact.js +92 -0
- package/dist/resources/identifiers.d.ts +22 -0
- package/dist/resources/identifiers.js +31 -0
- package/dist/resources/index.d.ts +3 -3
- package/dist/resources/index.js +21 -10
- package/dist/resources/mysql.d.ts +3 -1
- package/dist/resources/mysql.js +49 -11
- package/dist/resources/postgres.d.ts +4 -1
- package/dist/resources/postgres.js +63 -17
- package/dist/resources/redis.d.ts +3 -1
- package/dist/resources/redis.js +14 -4
- package/dist/resources/sqlite.js +2 -2
- package/dist/resources/types.d.ts +21 -3
- package/dist/runtimes/bun.js +2 -2
- package/dist/runtimes/installed-versions.d.ts +12 -0
- package/dist/runtimes/installed-versions.js +21 -0
- package/dist/runtimes/node.js +13 -8
- package/dist/runtimes/python.js +14 -7
- package/dist/runtimes/ruby.js +13 -8
- package/dist/secret-generator.js +11 -3
- package/dist/shell.d.ts +28 -6
- package/dist/shell.js +83 -28
- package/dist/state.d.ts +38 -0
- package/dist/state.js +12 -1
- package/dist/storage.d.ts +16 -3
- package/dist/storage.js +23 -6
- package/package.json +7 -4
- package/dist/__tests__/bootstrap.test.d.ts +0 -2
- package/dist/__tests__/bootstrap.test.js +0 -90
- package/dist/__tests__/dry-run.test.d.ts +0 -2
- package/dist/__tests__/dry-run.test.js +0 -141
- package/dist/__tests__/env-writer.test.d.ts +0 -2
- package/dist/__tests__/env-writer.test.js +0 -201
- package/dist/__tests__/lockfile-detect.test.d.ts +0 -2
- package/dist/__tests__/lockfile-detect.test.js +0 -75
- package/dist/__tests__/port-allocator.test.d.ts +0 -2
- package/dist/__tests__/port-allocator.test.js +0 -53
- package/dist/__tests__/secret-generator.test.d.ts +0 -2
- package/dist/__tests__/secret-generator.test.js +0 -26
- package/dist/__tests__/state.test.d.ts +0 -2
- package/dist/__tests__/state.test.js +0 -30
package/dist/provider.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { readFile } from "node:fs/promises";
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
-
import { readLaunch } from "@launchfile/sdk";
|
|
9
|
+
import { readLaunch, resolveSourcePrepareCommand, resolveSourceRunCommand, selectionClosure, unsuppliedRequiredEnv, } from "@launchfile/sdk";
|
|
10
10
|
import { checkPrereqs } from "./prereqs.js";
|
|
11
11
|
import { loadState, initState, saveState, ensureDirs } from "./state.js";
|
|
12
12
|
import { buildResolverContext, computeAppProperties, resolveComponentEnv, generateSecrets, resolveGenerators, writeEnvFile, } from "./env-writer.js";
|
|
@@ -14,9 +14,102 @@ import { getProvisioner } from "./resources/index.js";
|
|
|
14
14
|
import { allocatePorts } from "./port-allocator.js";
|
|
15
15
|
import { getRuntimeInstaller } from "./runtimes/index.js";
|
|
16
16
|
import { detectPackageManager } from "./lockfile-detect.js";
|
|
17
|
-
import { provisionStorage } from "./storage.js";
|
|
17
|
+
import { provisionStorage, storagePaths } from "./storage.js";
|
|
18
18
|
import { ProcessManager } from "./process-manager.js";
|
|
19
|
-
import {
|
|
19
|
+
import { stopRecordedProcesses } from "./process-stopper.js";
|
|
20
|
+
import { shellScript } from "./shell.js";
|
|
21
|
+
import { parseDuration } from "./bootstrap.js";
|
|
22
|
+
/**
|
|
23
|
+
* This provider runs apps from source. A component is source-runnable when
|
|
24
|
+
* {@link resolveSourceRunCommand} (D-38) resolves a command — declares `dev`,
|
|
25
|
+
* or a `start` with no `image`. An `image` without a `dev` override stays
|
|
26
|
+
* artifact-mode, which this source-only provider can't launch.
|
|
27
|
+
*/
|
|
28
|
+
export function isSourceRunnable(component) {
|
|
29
|
+
return resolveSourceRunCommand(component) !== undefined;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Parse a declared timeout, adding the stage/component label to the error.
|
|
33
|
+
* An unparseable duration is surfaced — it fails the stage that declared it
|
|
34
|
+
* (PROVIDERS.md §10.10) — never silently replaced with a default. Undefined
|
|
35
|
+
* passes through so callers keep their own default budgets.
|
|
36
|
+
*/
|
|
37
|
+
function declaredTimeout(timeout, label) {
|
|
38
|
+
if (timeout === undefined)
|
|
39
|
+
return undefined;
|
|
40
|
+
try {
|
|
41
|
+
return parseDuration(timeout);
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
throw new Error(`${label}: ${err instanceof Error ? err.message : String(err)}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Components this provider must refuse, mapped to the capabilities it cannot
|
|
49
|
+
* grant (D-44, PROVIDERS.md §11). Both spellings fold together so the `host:`
|
|
50
|
+
* entry form and the legacy top-level block produce the same outcome.
|
|
51
|
+
*
|
|
52
|
+
* A refusal must remove the component from the run, not merely report it —
|
|
53
|
+
* this provider grants no host capabilities, so anything listed here cannot
|
|
54
|
+
* be installed, wired, registered, or started.
|
|
55
|
+
*/
|
|
56
|
+
export function refusedHostCapabilities(launch) {
|
|
57
|
+
const refused = new Map();
|
|
58
|
+
for (const [name, c] of Object.entries(launch.components)) {
|
|
59
|
+
const caps = [];
|
|
60
|
+
for (const req of c.requires ?? []) {
|
|
61
|
+
for (const [capability, value] of Object.entries(req.host ?? {})) {
|
|
62
|
+
caps.push(`${capability}=${String(value)}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (c.host?.docker === "required")
|
|
66
|
+
caps.push("container_runtime=docker (host.docker)");
|
|
67
|
+
if (c.host?.network === "host")
|
|
68
|
+
caps.push("network=host (host.network)");
|
|
69
|
+
if (c.host?.privileged)
|
|
70
|
+
caps.push("privileged=true (host.privileged)");
|
|
71
|
+
if (caps.length > 0)
|
|
72
|
+
refused.set(name, caps);
|
|
73
|
+
}
|
|
74
|
+
return refused;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The launch-time notice a provider without a scheduler owes for a declared
|
|
78
|
+
* `schedule` (D-51, PROVIDERS.md §10 item 8).
|
|
79
|
+
*
|
|
80
|
+
* States what *this provider* does, not what will happen to the app: a
|
|
81
|
+
* component may schedule itself — `catalog/drafts/diun` sets its own
|
|
82
|
+
* `DIUN_WATCH_SCHEDULE`, and nextcloud's `cron.sh` is a foreground `crond` —
|
|
83
|
+
* so claiming the job will not run would be false about those apps, and a
|
|
84
|
+
* warning that misstates the user's app is worse than the silence it replaces.
|
|
85
|
+
*/
|
|
86
|
+
export function scheduleWarning(component, schedule) {
|
|
87
|
+
return (`[${component}] declares \`schedule: ${schedule}\` — this provider will not ` +
|
|
88
|
+
"run it on a timer. If the component does not schedule itself, the job will not run.");
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Remove every component this provider must refuse, and say so on stderr.
|
|
92
|
+
*
|
|
93
|
+
* The removal is the refusal (D-44, PROVIDERS.md §11): a component left in the
|
|
94
|
+
* map goes on to be installed, env-wired, registered with the process manager
|
|
95
|
+
* and started, so logging alone would have the provider assert a refusal it
|
|
96
|
+
* did not perform. Mutates `launch.components` for exactly that reason —
|
|
97
|
+
* everything downstream reads it.
|
|
98
|
+
*
|
|
99
|
+
* Returns "none-left" when nothing survives, so the caller can fail rather than
|
|
100
|
+
* report success over an empty set.
|
|
101
|
+
*/
|
|
102
|
+
export function applyHostCapabilityRefusals(launch) {
|
|
103
|
+
const refused = refusedHostCapabilities(launch);
|
|
104
|
+
for (const [name, caps] of refused) {
|
|
105
|
+
console.error(` Refused: ${name} requires host capabilities this provider cannot grant ` +
|
|
106
|
+
`(${caps.join("; ")}) — component not started`);
|
|
107
|
+
}
|
|
108
|
+
if (refused.size === 0)
|
|
109
|
+
return "ok";
|
|
110
|
+
launch.components = Object.fromEntries(Object.entries(launch.components).filter(([n]) => !refused.has(n)));
|
|
111
|
+
return Object.keys(launch.components).length === 0 ? "none-left" : "ok";
|
|
112
|
+
}
|
|
20
113
|
export async function launchUp(opts = {}) {
|
|
21
114
|
const projectDir = opts.projectDir ?? process.cwd();
|
|
22
115
|
// 1. Check prerequisites
|
|
@@ -38,7 +131,120 @@ export async function launchUp(opts = {}) {
|
|
|
38
131
|
process.exit(1);
|
|
39
132
|
}
|
|
40
133
|
const launch = readLaunch(launchfileContent);
|
|
134
|
+
const allComponentNames = Object.keys(launch.components);
|
|
135
|
+
// Resolve component selector (#77) into its D-41 start-set: selected
|
|
136
|
+
// components + their transitive downward `depends_on` closure (and, by
|
|
137
|
+
// narrowing to that set, every closure member's `requires`). Narrowing
|
|
138
|
+
// launch.components here makes every downstream phase loop in launchUp honor
|
|
139
|
+
// the closure without per-loop edits — dropping it would hand back an app
|
|
140
|
+
// missing the very dependencies a selected component needs to start (D-16).
|
|
141
|
+
// down/status/env re-read the file, so they are unaffected.
|
|
142
|
+
const selection = selectionClosure(launch, opts.components ?? []);
|
|
143
|
+
if (selection.unknown.length > 0 || selection.resources.length > 0) {
|
|
144
|
+
console.error(`\nCannot select: ${[...selection.unknown, ...selection.resources].join(", ")}`);
|
|
145
|
+
for (const r of selection.resources) {
|
|
146
|
+
console.error(` - "${r}" is a backing resource, not a component; select the component that requires it.`);
|
|
147
|
+
}
|
|
148
|
+
for (const u of selection.unknown) {
|
|
149
|
+
console.error(` - "${u}" matches no component. Available: ${allComponentNames.join(", ")}`);
|
|
150
|
+
}
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
if (opts.components && opts.components.length > 0) {
|
|
154
|
+
const startSet = new Set(selection.start);
|
|
155
|
+
launch.components = Object.fromEntries(Object.entries(launch.components).filter(([n]) => startSet.has(n)));
|
|
156
|
+
}
|
|
157
|
+
// 2a. Host capabilities are granted or refused, never provisioned (D-44,
|
|
158
|
+
// PROVIDERS.md §11). This provider runs processes directly on the host and
|
|
159
|
+
// grants none of them, so a component with a required capability is
|
|
160
|
+
// DECLINED — removed from the map here so nothing downstream installs a
|
|
161
|
+
// runtime, wires env, registers with pm2, or starts it. Logging alone would
|
|
162
|
+
// leave the provider asserting a refusal it did not perform.
|
|
163
|
+
// Both spellings fold together so they land identically (§11 equivalence):
|
|
164
|
+
// the `host:` entry form and the legacy top-level block.
|
|
165
|
+
if (applyHostCapabilityRefusals(launch) === "none-left") {
|
|
166
|
+
console.error("Every selected component requires a host capability this provider cannot grant.");
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
// An optional capability is not refused — the component runs, degraded.
|
|
170
|
+
for (const [name, c] of Object.entries(launch.components)) {
|
|
171
|
+
for (const sup of c.supports ?? []) {
|
|
172
|
+
for (const [capability, value] of Object.entries(sup.host ?? {})) {
|
|
173
|
+
console.warn(` Warning: ${name}: optional host capability ` +
|
|
174
|
+
`${capability}=${String(value)} not granted — running degraded`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
41
178
|
const componentNames = Object.keys(launch.components);
|
|
179
|
+
// 2b. Source-mode guard (D-38) — fail fast before provisioning anything.
|
|
180
|
+
// Run precedence is `dev` > `image` > `start`: a component runs from source
|
|
181
|
+
// when it declares `dev`, or a `start` with no `image`. An `image` without a
|
|
182
|
+
// `dev` override stays artifact-mode — which this source-only provider can't
|
|
183
|
+
// launch. If nothing is source-runnable, the app belongs on `launchfile up`.
|
|
184
|
+
const sourceRunnable = Object.values(launch.components).filter(isSourceRunnable);
|
|
185
|
+
if (sourceRunnable.length === 0) {
|
|
186
|
+
const hasImage = Object.values(launch.components).some((c) => c.image);
|
|
187
|
+
console.error("Nothing to run from source: no component declares `dev` (or a `start` without an `image`).");
|
|
188
|
+
console.error(hasImage
|
|
189
|
+
? "This app runs from an image — use `launchfile up` to launch the built artifact."
|
|
190
|
+
: "Add a `dev` (or `start`) command to run it from source.");
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
// Mixed app: warn about artifact components (image, no `dev` override) that
|
|
194
|
+
// this source-only provider can't launch — they need `launchfile up`.
|
|
195
|
+
for (const [name, c] of Object.entries(launch.components)) {
|
|
196
|
+
if (!isSourceRunnable(c) && c.image) {
|
|
197
|
+
console.warn(` ! [${name}] has an image and no \`dev\` override — runs as an artifact, ` +
|
|
198
|
+
"skipped in source mode; use `launchfile up` to run it.");
|
|
199
|
+
}
|
|
200
|
+
// PROVIDERS.md conformance rule 8 (D-51): a provider that does not
|
|
201
|
+
// execute `schedule` MUST say so at launch. Staying silent leaves an
|
|
202
|
+
// author believing a declared cron job is running — the one outcome
|
|
203
|
+
// worse than not supporting it. Wording stays start-agnostic: artifact
|
|
204
|
+
// components with a schedule reach this loop too, and they are skipped
|
|
205
|
+
// entirely in source mode.
|
|
206
|
+
if (c.schedule) {
|
|
207
|
+
console.warn(` ! ${scheduleWarning(name, c.schedule)}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// 2c. Unsupplied `required:` environment variables (D-52, PROVIDERS.md §10
|
|
211
|
+
// rule 8, deploying branch). This provider's operator channel is the
|
|
212
|
+
// launching environment, read EXPLICITLY here — the `...process.env` spread
|
|
213
|
+
// on the pm2 registration below is incidental inheritance that never reaches
|
|
214
|
+
// `release` and is invisible to `env`, so it cannot serve as the channel.
|
|
215
|
+
// Values found are carried in `operatorEnv` and merged into `allEnvs` at
|
|
216
|
+
// step 12, which puts them on both `release` and `start` and makes them
|
|
217
|
+
// visible to `launch env`. Anything still missing fails HERE — before
|
|
218
|
+
// directories, resources, ports, runtimes, or processes exist. No prompt: a
|
|
219
|
+
// non-interactive invocation must fail by name, not hang on stdin.
|
|
220
|
+
const operatorEnv = {};
|
|
221
|
+
const missingRequired = [];
|
|
222
|
+
for (const [name, component] of Object.entries(launch.components)) {
|
|
223
|
+
// A `requires:` binding injects only when this provider can provision the
|
|
224
|
+
// resource behind it; `supports:` is provisioned only under --with-optional
|
|
225
|
+
// and is never credited (SPEC.md §Supports).
|
|
226
|
+
const arriving = new Set((component.requires ?? [])
|
|
227
|
+
.filter((req) => !req.host && getProvisioner(req.type))
|
|
228
|
+
.flatMap((req) => Object.keys(req.set_env ?? {})));
|
|
229
|
+
for (const { key, sensitive } of unsuppliedRequiredEnv(component, arriving)) {
|
|
230
|
+
const supplied = process.env[key];
|
|
231
|
+
if (supplied !== undefined) {
|
|
232
|
+
(operatorEnv[name] ??= {})[key] = supplied;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
missingRequired.push({ component: name, key, sensitive });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (missingRequired.length > 0) {
|
|
239
|
+
console.error(`\nCannot launch: ${missingRequired.length} required environment variable${missingRequired.length === 1 ? "" : "s"} had no value.`);
|
|
240
|
+
for (const { component, key, sensitive } of missingRequired) {
|
|
241
|
+
console.error(` - ${component}: ${key}${sensitive ? " (sensitive)" : ""}`);
|
|
242
|
+
}
|
|
243
|
+
console.error("\nThe Launchfile declares them `required:` with no `default:`, `generator:`, or resource");
|
|
244
|
+
console.error("binding, so you supply them. Set them in the environment and run `up` again, e.g.");
|
|
245
|
+
console.error(` ${missingRequired[0].key}=<value> launch up`);
|
|
246
|
+
process.exit(1);
|
|
247
|
+
}
|
|
42
248
|
// 3. Load or init state
|
|
43
249
|
let state = await loadState(projectDir);
|
|
44
250
|
if (!state) {
|
|
@@ -52,6 +258,8 @@ export async function launchUp(opts = {}) {
|
|
|
52
258
|
const resourceMap = {};
|
|
53
259
|
for (const [_compName, component] of Object.entries(launch.components)) {
|
|
54
260
|
for (const req of component.requires ?? []) {
|
|
261
|
+
if (req.host)
|
|
262
|
+
continue; // capability, not a backing service (D-44)
|
|
55
263
|
const resourceName = req.name ?? req.type;
|
|
56
264
|
if (resourceMap[resourceName])
|
|
57
265
|
continue; // Already provisioned
|
|
@@ -75,6 +283,8 @@ export async function launchUp(opts = {}) {
|
|
|
75
283
|
// Optional supports resources
|
|
76
284
|
if (opts.withOptional) {
|
|
77
285
|
for (const sup of component.supports ?? []) {
|
|
286
|
+
if (sup.host)
|
|
287
|
+
continue; // capability, not a backing service (D-44)
|
|
78
288
|
const resourceName = sup.name ?? sup.type;
|
|
79
289
|
if (resourceMap[resourceName])
|
|
80
290
|
continue;
|
|
@@ -134,16 +344,29 @@ export async function launchUp(opts = {}) {
|
|
|
134
344
|
}
|
|
135
345
|
// 10. Detect package manager
|
|
136
346
|
const pm = await detectPackageManager(projectDir);
|
|
137
|
-
// 11. Create storage directories
|
|
347
|
+
// 11. Create storage directories, capturing each volume's resolved local path
|
|
348
|
+
// so it can be injected as $storage.<name>.path (D-39). Scoped per component.
|
|
349
|
+
const componentStorage = {};
|
|
138
350
|
for (const [name, component] of Object.entries(launch.components)) {
|
|
139
|
-
await provisionStorage(component.storage, name, projectDir);
|
|
351
|
+
const volumeMap = await provisionStorage(component.storage, name, projectDir);
|
|
352
|
+
const storageCtx = {};
|
|
353
|
+
for (const [volName, localPath] of Object.entries(volumeMap)) {
|
|
354
|
+
storageCtx[volName] = { path: localPath };
|
|
355
|
+
}
|
|
356
|
+
componentStorage[name] = storageCtx;
|
|
140
357
|
}
|
|
141
358
|
// 12. Resolve env vars and write .env files
|
|
142
359
|
const allEnvs = {};
|
|
143
360
|
const isSingleComponent = componentNames.length === 1 && componentNames[0] === "default";
|
|
361
|
+
// Minted env-level generator values live in state (D-49) so a redeploy
|
|
362
|
+
// reuses them; saveState below (step 13) persists anything minted here.
|
|
363
|
+
const generatedEnv = (state.generatedEnv ??= {});
|
|
144
364
|
for (const [name, component] of Object.entries(launch.components)) {
|
|
145
|
-
const env = resolveComponentEnv(component, context, resourceMap);
|
|
146
|
-
await resolveGenerators(component, env);
|
|
365
|
+
const { env } = resolveComponentEnv(component, context, resourceMap, componentStorage[name]);
|
|
366
|
+
await resolveGenerators(component, env, name, generatedEnv);
|
|
367
|
+
// Operator-supplied `required:` values (step 2c) join the resolved set, so
|
|
368
|
+
// they reach `release` and `start` alike and show up in `launch env`.
|
|
369
|
+
Object.assign(env, operatorEnv[name] ?? {});
|
|
147
370
|
const port = componentPorts[name];
|
|
148
371
|
if (port && !env.PORT) {
|
|
149
372
|
env.PORT = String(port);
|
|
@@ -171,62 +394,75 @@ export async function launchUp(opts = {}) {
|
|
|
171
394
|
printSummary(launch, componentPorts, resourceMap);
|
|
172
395
|
return;
|
|
173
396
|
}
|
|
174
|
-
// 14. Run build
|
|
397
|
+
// 14. Run source-mode prepare \u2014 `install ?? build` (D-38), on demand
|
|
175
398
|
if (!opts.noBuild) {
|
|
176
399
|
for (const [name, component] of Object.entries(launch.components)) {
|
|
177
|
-
const
|
|
178
|
-
const cmd =
|
|
400
|
+
const prepare = resolveSourcePrepareCommand(component);
|
|
401
|
+
const cmd = prepare?.command ?? pm?.installCommand;
|
|
179
402
|
if (cmd) {
|
|
180
|
-
console.log(` \u2193
|
|
181
|
-
await
|
|
182
|
-
cwd: projectDir,
|
|
403
|
+
console.log(` \u2193 Preparing${componentNames.length > 1 ? ` [${name}]` : ""}...`);
|
|
404
|
+
await shellScript(cmd, {
|
|
405
|
+
cwd: join(projectDir, component.source ?? component.build?.context ?? "."),
|
|
183
406
|
env: allEnvs[name],
|
|
407
|
+
// Installs/compiles routinely exceed the 2-minute shell default;
|
|
408
|
+
// honor a declared timeout, else allow 10 minutes.
|
|
409
|
+
timeout: declaredTimeout(prepare?.timeout, `prepare [${name}]`) ?? 600_000,
|
|
184
410
|
});
|
|
185
411
|
}
|
|
186
412
|
}
|
|
187
413
|
}
|
|
188
|
-
// 15. Run release commands (migrations)
|
|
414
|
+
// 15. Run release commands (migrations) \u2014 mode-invariant (D-38)
|
|
189
415
|
for (const [name, component] of Object.entries(launch.components)) {
|
|
190
|
-
const
|
|
191
|
-
if (
|
|
416
|
+
const release = component.commands?.release;
|
|
417
|
+
if (release?.command) {
|
|
192
418
|
console.log(` \u2193 Running release${componentNames.length > 1 ? ` [${name}]` : ""}...`);
|
|
193
|
-
await
|
|
194
|
-
cwd: projectDir,
|
|
419
|
+
await shellScript(release.command, {
|
|
420
|
+
cwd: join(projectDir, component.source ?? component.build?.context ?? "."),
|
|
195
421
|
env: allEnvs[name],
|
|
422
|
+
timeout: declaredTimeout(release.timeout, `release [${name}]`),
|
|
196
423
|
});
|
|
197
424
|
}
|
|
198
425
|
}
|
|
199
|
-
// 16.
|
|
426
|
+
// 16. Run components from source \u2014 `dev` over `start` (D-38; this provider ignores `image`)
|
|
200
427
|
process.stdout.write(` \u2193 Starting services...`);
|
|
201
428
|
const pm2 = new ProcessManager(projectDir);
|
|
202
429
|
for (const [name, component] of Object.entries(launch.components)) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
430
|
+
// Resolve the source-mode run command (D-38 precedence `dev` > `image` >
|
|
431
|
+
// `start`). Artifact components (image, no `dev` override) resolve to
|
|
432
|
+
// undefined — they were warned by the guard; skip them.
|
|
433
|
+
const startCmd = resolveSourceRunCommand(component)?.command;
|
|
434
|
+
if (!startCmd)
|
|
206
435
|
continue;
|
|
207
|
-
}
|
|
208
436
|
pm2.register(name, {
|
|
209
437
|
command: startCmd,
|
|
210
438
|
env: { ...process.env, ...allEnvs[name] },
|
|
211
|
-
cwd: projectDir,
|
|
439
|
+
cwd: join(projectDir, component.source ?? component.build?.context ?? "."),
|
|
212
440
|
dependsOn: component.depends_on,
|
|
213
441
|
health: component.health,
|
|
214
442
|
port: componentPorts[name],
|
|
215
443
|
});
|
|
216
444
|
}
|
|
217
445
|
// Handle Ctrl+C gracefully
|
|
446
|
+
const finalState = state;
|
|
218
447
|
process.on("SIGINT", async () => {
|
|
219
448
|
console.log("\n\nShutting down...");
|
|
220
449
|
await pm2.stopAll();
|
|
221
|
-
|
|
450
|
+
// Processes are now dead; clear the recorded pids so a later `launch down`
|
|
451
|
+
// doesn't try to signal stale (and possibly recycled) pids.
|
|
452
|
+
finalState.processes = {};
|
|
453
|
+
await saveState(projectDir, finalState);
|
|
222
454
|
process.exit(0);
|
|
223
455
|
});
|
|
224
456
|
await pm2.startAll();
|
|
225
457
|
console.log("");
|
|
226
458
|
console.log(` \u2713 All components started`);
|
|
459
|
+
// Record spawned pids so `launch down` can stop them from another shell or
|
|
460
|
+
// after this foreground session ends (closes #49). Backward compatible: the
|
|
461
|
+
// field is optional and absent in pre-existing state files.
|
|
462
|
+
state.processes = pm2.getRecordedProcesses();
|
|
227
463
|
// 17. Print summary
|
|
228
464
|
printSummary(launch, componentPorts, resourceMap);
|
|
229
|
-
// Save final state
|
|
465
|
+
// Save final state (now including recorded pids)
|
|
230
466
|
await saveState(projectDir, state);
|
|
231
467
|
}
|
|
232
468
|
function printSummary(launch, ports, _resources) {
|
|
@@ -244,6 +480,35 @@ export async function launchDown(opts = {}) {
|
|
|
244
480
|
console.log("No active launch state found.");
|
|
245
481
|
return;
|
|
246
482
|
}
|
|
483
|
+
// Stop recorded app processes (closes #49). Backward compatible: state files
|
|
484
|
+
// written before pid persistence simply have no `processes`, so we skip this
|
|
485
|
+
// and behave exactly as before (resources-only down).
|
|
486
|
+
const recorded = state.processes ?? {};
|
|
487
|
+
if (Object.keys(recorded).length > 0) {
|
|
488
|
+
console.log("Stopping app processes...");
|
|
489
|
+
const outcomes = await stopRecordedProcesses(recorded);
|
|
490
|
+
for (const o of outcomes) {
|
|
491
|
+
switch (o.result) {
|
|
492
|
+
case "stopped":
|
|
493
|
+
console.log(` Stopped ${o.component}`);
|
|
494
|
+
break;
|
|
495
|
+
case "already-dead":
|
|
496
|
+
console.log(` ${o.component} was not running`);
|
|
497
|
+
break;
|
|
498
|
+
case "identity-mismatch":
|
|
499
|
+
console.log(` Skipped ${o.component} (pid recycled — left untouched)`);
|
|
500
|
+
break;
|
|
501
|
+
case "error":
|
|
502
|
+
console.log(` Failed to stop ${o.component}: ${o.error}`);
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
// Clear recorded pids now that we've handled them.
|
|
507
|
+
state.processes = {};
|
|
508
|
+
if (!opts.destroy) {
|
|
509
|
+
await saveState(projectDir, state);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
247
512
|
if (opts.destroy) {
|
|
248
513
|
console.log("Destroying resources...");
|
|
249
514
|
for (const [name, resourceState] of Object.entries(state.resources)) {
|
|
@@ -262,6 +527,11 @@ export async function launchDown(opts = {}) {
|
|
|
262
527
|
console.log("Stopped. Resources are still running (use --destroy to remove them).");
|
|
263
528
|
}
|
|
264
529
|
}
|
|
530
|
+
// --detach is intentionally left as a follow-up: persisting pids (this PR) is
|
|
531
|
+
// the prerequisite for it. With pids now recorded and a working cross-session
|
|
532
|
+
// `down`, detach becomes "spawn detached + unref + don't install the SIGINT
|
|
533
|
+
// foreground loop, then return" — a self-contained change best done separately
|
|
534
|
+
// so the kill-path fix lands reviewable on its own.
|
|
265
535
|
export async function launchStatus(opts = {}) {
|
|
266
536
|
const projectDir = opts.projectDir ?? process.cwd();
|
|
267
537
|
const state = await loadState(projectDir);
|
|
@@ -307,18 +577,57 @@ export async function launchEnv(opts = {}) {
|
|
|
307
577
|
}
|
|
308
578
|
const appProperties = computeAppProperties(launch, state.ports);
|
|
309
579
|
const context = buildResolverContext(resourceMap, state.ports, state.secrets, appProperties);
|
|
580
|
+
// `env` reports what the running app has, so it reads minted generator
|
|
581
|
+
// values from the same store `up` persists to (D-49). A value can still be
|
|
582
|
+
// minted here — a generator declared after the last `up` — and then it is
|
|
583
|
+
// persisted below, before printing, so `up`, `env`, and `bootstrap` all
|
|
584
|
+
// keep answering with the same value.
|
|
585
|
+
const generatedEnv = (state.generatedEnv ??= {});
|
|
586
|
+
let minted = false;
|
|
587
|
+
const resolvedEnvs = [];
|
|
310
588
|
for (const [name, component] of Object.entries(launch.components)) {
|
|
311
589
|
if (opts.component && name !== opts.component)
|
|
312
590
|
continue;
|
|
313
|
-
|
|
314
|
-
|
|
591
|
+
// Resolved storage paths (D-39) — computed, not provisioned (no mkdir):
|
|
592
|
+
// `launchfile env` only prints, and the dirs already exist from `up`.
|
|
593
|
+
const storageCtx = {};
|
|
594
|
+
for (const [volName, localPath] of Object.entries(storagePaths(component.storage, name, projectDir))) {
|
|
595
|
+
storageCtx[volName] = { path: localPath };
|
|
596
|
+
}
|
|
597
|
+
const { env, unsupplied } = resolveComponentEnv(component, context, resourceMap, storageCtx);
|
|
598
|
+
minted = (await resolveGenerators(component, env, name, generatedEnv)) || minted;
|
|
599
|
+
// The operator channel `up` reads (the launching environment) answers here
|
|
600
|
+
// too, so a var supplied at launch time prints as a real value rather than
|
|
601
|
+
// being reported missing.
|
|
602
|
+
for (const { key } of unsupplied) {
|
|
603
|
+
const supplied = process.env[key];
|
|
604
|
+
if (supplied !== undefined)
|
|
605
|
+
env[key] = supplied;
|
|
606
|
+
}
|
|
315
607
|
const port = state.ports[name];
|
|
316
608
|
if (port && !env.PORT)
|
|
317
609
|
env.PORT = String(port);
|
|
610
|
+
resolvedEnvs.push([name, env, unsupplied]);
|
|
611
|
+
}
|
|
612
|
+
if (minted) {
|
|
613
|
+
await saveState(projectDir, state);
|
|
614
|
+
}
|
|
615
|
+
for (const [name, env, unsupplied] of resolvedEnvs) {
|
|
318
616
|
console.log(`\n# ${name}`);
|
|
319
617
|
for (const [key, value] of Object.entries(env).sort(([a], [b]) => a.localeCompare(b))) {
|
|
320
618
|
console.log(`${key}=${value}`);
|
|
321
619
|
}
|
|
620
|
+
// PROVIDERS.md §10 rule 8, `env` branch: report an unsupplied required var
|
|
621
|
+
// rather than dropping it — this is where an operator comes to find out
|
|
622
|
+
// what is missing. It goes out as a `#` comment, never a bare `KEY=` line,
|
|
623
|
+
// because this output is designed to be `eval`'d (§2): a bare line would
|
|
624
|
+
// export an empty value and re-create the failure the rule exists to stop.
|
|
625
|
+
for (const { key, sensitive } of unsupplied.sort((a, b) => a.key.localeCompare(b.key))) {
|
|
626
|
+
if (env[key] !== undefined)
|
|
627
|
+
continue;
|
|
628
|
+
console.log(`# ${key}: unsupplied — required, no default/generator/binding` +
|
|
629
|
+
`${sensitive ? ", sensitive" : ""}. Supply it in the environment.`);
|
|
630
|
+
}
|
|
322
631
|
}
|
|
323
632
|
}
|
|
324
633
|
//# sourceMappingURL=provider.js.map
|
package/dist/redact.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret redaction for anything this provider prints or embeds in an error.
|
|
3
|
+
*
|
|
4
|
+
* Two independent layers, because either alone leaves a hole:
|
|
5
|
+
*
|
|
6
|
+
* 1. A registry of exact secret values. Every generated secret, every
|
|
7
|
+
* persisted `state.secrets` entry, and every resource password registers
|
|
8
|
+
* itself at creation/load time; `redactSecrets` then scrubs those literals
|
|
9
|
+
* out of any string on its way to stdout/stderr or an Error message.
|
|
10
|
+
* 2. A pattern scrub for credentials embedded in URLs
|
|
11
|
+
* (`scheme://user:pass@host`), which catches secrets that never passed
|
|
12
|
+
* through this provider — e.g. a connection string written literally in a
|
|
13
|
+
* Launchfile `env:` value and interpolated into a bootstrap command.
|
|
14
|
+
*
|
|
15
|
+
* The registry is process-global on purpose: a command string is assembled in
|
|
16
|
+
* one module and printed in another, so the scrub has to be reachable from the
|
|
17
|
+
* sink without threading a context object through every call site.
|
|
18
|
+
*/
|
|
19
|
+
export declare const REDACTED = "[REDACTED]";
|
|
20
|
+
/**
|
|
21
|
+
* Register a value this provider *inferred* is a secret: one it minted itself,
|
|
22
|
+
* or read back out of its own state. Values below `MIN_SECRET_LENGTH` are
|
|
23
|
+
* dropped — nothing declared them sensitive, so a coincidental match would
|
|
24
|
+
* corrupt output for no gain.
|
|
25
|
+
*/
|
|
26
|
+
export declare function registerSecret(value: string | undefined | null): void;
|
|
27
|
+
/**
|
|
28
|
+
* Register a value something *declared* is a secret: an `env:` literal marked
|
|
29
|
+
* `sensitive: true` (D-18), or a value handed over on the operator channel
|
|
30
|
+
* (D-52). No length floor applies.
|
|
31
|
+
*
|
|
32
|
+
* `sensitive: true` on a six-digit PIN is the author stating that value must be
|
|
33
|
+
* masked. Dropping it for being short writes the PIN to disk in plaintext
|
|
34
|
+
* (CWE-532) — the exact failure this registry exists to prevent. Honouring the
|
|
35
|
+
* declaration costs an over-redacted diagnostic where the value also occurs by
|
|
36
|
+
* chance, which is recoverable; the alternative is a leaked credential, which
|
|
37
|
+
* is not.
|
|
38
|
+
*
|
|
39
|
+
* The empty string is rejected: it is not a credential, and an empty separator
|
|
40
|
+
* would splice `[REDACTED]` between every character of the text.
|
|
41
|
+
*/
|
|
42
|
+
export declare function registerDeclaredSecret(value: string | undefined | null): void;
|
|
43
|
+
/** Register many secret values at once. Non-string entries are ignored. */
|
|
44
|
+
export declare function registerSecrets(values: Iterable<string | undefined | null>): void;
|
|
45
|
+
/** Drop every registered secret. Exists for test isolation. */
|
|
46
|
+
export declare function clearRegisteredSecrets(): void;
|
|
47
|
+
/**
|
|
48
|
+
* Scrub registered secrets and URL-embedded credentials out of `text`.
|
|
49
|
+
*
|
|
50
|
+
* Longest registered values are replaced first so that a secret which is a
|
|
51
|
+
* substring of another (a password inside its own connection URL) cannot leave
|
|
52
|
+
* a partial value behind.
|
|
53
|
+
*/
|
|
54
|
+
export declare function redactSecrets(text: string): string;
|
|
55
|
+
//# sourceMappingURL=redact.d.ts.map
|
package/dist/redact.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret redaction for anything this provider prints or embeds in an error.
|
|
3
|
+
*
|
|
4
|
+
* Two independent layers, because either alone leaves a hole:
|
|
5
|
+
*
|
|
6
|
+
* 1. A registry of exact secret values. Every generated secret, every
|
|
7
|
+
* persisted `state.secrets` entry, and every resource password registers
|
|
8
|
+
* itself at creation/load time; `redactSecrets` then scrubs those literals
|
|
9
|
+
* out of any string on its way to stdout/stderr or an Error message.
|
|
10
|
+
* 2. A pattern scrub for credentials embedded in URLs
|
|
11
|
+
* (`scheme://user:pass@host`), which catches secrets that never passed
|
|
12
|
+
* through this provider — e.g. a connection string written literally in a
|
|
13
|
+
* Launchfile `env:` value and interpolated into a bootstrap command.
|
|
14
|
+
*
|
|
15
|
+
* The registry is process-global on purpose: a command string is assembled in
|
|
16
|
+
* one module and printed in another, so the scrub has to be reachable from the
|
|
17
|
+
* sink without threading a context object through every call site.
|
|
18
|
+
*/
|
|
19
|
+
export const REDACTED = "[REDACTED]";
|
|
20
|
+
/**
|
|
21
|
+
* Values shorter than this are not registered *by inference*. Short strings
|
|
22
|
+
* appear inside unrelated text by coincidence, and scrubbing them would corrupt
|
|
23
|
+
* the output it is meant to protect. Every secret this provider mints is far
|
|
24
|
+
* longer, so the floor never binds on a minted value.
|
|
25
|
+
*
|
|
26
|
+
* The floor is a heuristic, and a heuristic does not overrule an explicit
|
|
27
|
+
* declaration — see `registerDeclaredSecret`.
|
|
28
|
+
*/
|
|
29
|
+
const MIN_SECRET_LENGTH = 8;
|
|
30
|
+
const registry = new Set();
|
|
31
|
+
/**
|
|
32
|
+
* Register a value this provider *inferred* is a secret: one it minted itself,
|
|
33
|
+
* or read back out of its own state. Values below `MIN_SECRET_LENGTH` are
|
|
34
|
+
* dropped — nothing declared them sensitive, so a coincidental match would
|
|
35
|
+
* corrupt output for no gain.
|
|
36
|
+
*/
|
|
37
|
+
export function registerSecret(value) {
|
|
38
|
+
if (typeof value !== "string")
|
|
39
|
+
return;
|
|
40
|
+
if (value.length < MIN_SECRET_LENGTH)
|
|
41
|
+
return;
|
|
42
|
+
registry.add(value);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Register a value something *declared* is a secret: an `env:` literal marked
|
|
46
|
+
* `sensitive: true` (D-18), or a value handed over on the operator channel
|
|
47
|
+
* (D-52). No length floor applies.
|
|
48
|
+
*
|
|
49
|
+
* `sensitive: true` on a six-digit PIN is the author stating that value must be
|
|
50
|
+
* masked. Dropping it for being short writes the PIN to disk in plaintext
|
|
51
|
+
* (CWE-532) — the exact failure this registry exists to prevent. Honouring the
|
|
52
|
+
* declaration costs an over-redacted diagnostic where the value also occurs by
|
|
53
|
+
* chance, which is recoverable; the alternative is a leaked credential, which
|
|
54
|
+
* is not.
|
|
55
|
+
*
|
|
56
|
+
* The empty string is rejected: it is not a credential, and an empty separator
|
|
57
|
+
* would splice `[REDACTED]` between every character of the text.
|
|
58
|
+
*/
|
|
59
|
+
export function registerDeclaredSecret(value) {
|
|
60
|
+
if (typeof value !== "string" || value === "")
|
|
61
|
+
return;
|
|
62
|
+
registry.add(value);
|
|
63
|
+
}
|
|
64
|
+
/** Register many secret values at once. Non-string entries are ignored. */
|
|
65
|
+
export function registerSecrets(values) {
|
|
66
|
+
for (const value of values)
|
|
67
|
+
registerSecret(value);
|
|
68
|
+
}
|
|
69
|
+
/** Drop every registered secret. Exists for test isolation. */
|
|
70
|
+
export function clearRegisteredSecrets() {
|
|
71
|
+
registry.clear();
|
|
72
|
+
}
|
|
73
|
+
// `scheme://user:password@host` — the password group is everything between the
|
|
74
|
+
// first `:` after the userinfo and the `@`. Userinfo cannot contain `/`, `@`,
|
|
75
|
+
// or whitespace, which bounds the match to a single URL.
|
|
76
|
+
const CREDENTIAL_URL = /([a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s/:@]+:)([^\s/@]+)(@)/g;
|
|
77
|
+
/**
|
|
78
|
+
* Scrub registered secrets and URL-embedded credentials out of `text`.
|
|
79
|
+
*
|
|
80
|
+
* Longest registered values are replaced first so that a secret which is a
|
|
81
|
+
* substring of another (a password inside its own connection URL) cannot leave
|
|
82
|
+
* a partial value behind.
|
|
83
|
+
*/
|
|
84
|
+
export function redactSecrets(text) {
|
|
85
|
+
let out = text;
|
|
86
|
+
const values = [...registry].sort((a, b) => b.length - a.length);
|
|
87
|
+
for (const value of values) {
|
|
88
|
+
out = out.split(value).join(REDACTED);
|
|
89
|
+
}
|
|
90
|
+
return out.replace(CREDENTIAL_URL, `$1${REDACTED}$3`);
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=redact.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guards for values that reach a SQL statement by interpolation.
|
|
3
|
+
*
|
|
4
|
+
* On a first run these values are safe by construction: the database and user
|
|
5
|
+
* names derive from a schema-validated app name, and `generatePassword()`
|
|
6
|
+
* emits base64url. Every later run reuses whatever `.launchfile/state.json`
|
|
7
|
+
* holds, and `loadState()` (state.ts) `JSON.parse`s that file with no
|
|
8
|
+
* validation. The file sits inside the cloned repo, so on the reuse path every
|
|
9
|
+
* value below is attacker-controlled.
|
|
10
|
+
*
|
|
11
|
+
* Argv execution keeps the shell out of these commands; these checks keep the
|
|
12
|
+
* SQL parser out of them. Both are needed: `mysql -e` runs `;`-separated
|
|
13
|
+
* statements and connects as root, so a quote that escapes an identifier or a
|
|
14
|
+
* password literal is a full statement injection with no shell involved.
|
|
15
|
+
*/
|
|
16
|
+
/** Alphanumeric + underscore — safe as a SQL identifier and as a shell arg. */
|
|
17
|
+
export declare const SAFE_IDENTIFIER: RegExp;
|
|
18
|
+
/** base64url, the exact alphabet `generatePassword()` emits. No quote fits. */
|
|
19
|
+
export declare const SAFE_PASSWORD: RegExp;
|
|
20
|
+
export declare function assertSafeIdentifier(value: string, label: string): void;
|
|
21
|
+
export declare function assertSafePassword(value: string): void;
|
|
22
|
+
//# sourceMappingURL=identifiers.d.ts.map
|