@launchfile/macos-dev 0.3.0 → 0.7.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 -96
- package/dist/env-writer.d.ts +33 -7
- package/dist/env-writer.js +68 -14
- package/dist/health.d.ts +8 -2
- package/dist/health.js +11 -17
- package/dist/prereqs.js +2 -2
- package/dist/process-manager.js +2 -1
- package/dist/provider.d.ts +45 -7
- package/dist/provider.js +296 -32
- 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 +22 -0
- package/dist/state.js +12 -1
- package/dist/storage.d.ts +10 -2
- package/dist/storage.js +29 -4
- package/package.json +6 -3
package/dist/health.js
CHANGED
|
@@ -1,23 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Health check polling for components.
|
|
3
3
|
*/
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import { parseDurationMs } from "@launchfile/sdk";
|
|
5
|
+
import { shellScript } from "./shell.js";
|
|
6
|
+
/**
|
|
7
|
+
* Parse a health duration against the ratified grammar (D-48). Throws on an
|
|
8
|
+
* unparseable value — PROVIDERS.md §10.10 forbids silently substituting a
|
|
9
|
+
* default, and the previous local parser did exactly that in the worst
|
|
10
|
+
* possible way: it accepted no `h` unit and returned 0, so a spec-valid
|
|
11
|
+
* `interval: "1h"` became a zero-length poll that could never pass.
|
|
12
|
+
*/
|
|
6
13
|
export function parseDuration(duration) {
|
|
7
|
-
|
|
8
|
-
if (!match)
|
|
9
|
-
return 0;
|
|
10
|
-
const value = Number.parseInt(match[1], 10);
|
|
11
|
-
switch (match[2]) {
|
|
12
|
-
case "ms":
|
|
13
|
-
return value;
|
|
14
|
-
case "s":
|
|
15
|
-
return value * 1000;
|
|
16
|
-
case "m":
|
|
17
|
-
return value * 60_000;
|
|
18
|
-
default:
|
|
19
|
-
return 0;
|
|
20
|
-
}
|
|
14
|
+
return parseDurationMs(duration);
|
|
21
15
|
}
|
|
22
16
|
/**
|
|
23
17
|
* Wait for a component to become healthy.
|
|
@@ -45,7 +39,7 @@ export async function waitForHealthy(name, health, port, overallTimeout = 60_000
|
|
|
45
39
|
}
|
|
46
40
|
}
|
|
47
41
|
else if (health.command) {
|
|
48
|
-
await
|
|
42
|
+
await shellScript(health.command, { timeout: checkTimeout, silent: true });
|
|
49
43
|
console.log(` [${name}] Healthy`);
|
|
50
44
|
return true;
|
|
51
45
|
}
|
package/dist/prereqs.js
CHANGED
|
@@ -6,11 +6,11 @@ export async function checkPrereqs() {
|
|
|
6
6
|
const missing = [];
|
|
7
7
|
const warnings = [];
|
|
8
8
|
// Homebrew is required
|
|
9
|
-
if (!(await shellOk("which brew"))) {
|
|
9
|
+
if (!(await shellOk("which", ["brew"]))) {
|
|
10
10
|
missing.push("Homebrew — install from https://brew.sh");
|
|
11
11
|
}
|
|
12
12
|
// Git is required (for cloning)
|
|
13
|
-
if (!(await shellOk("which git"))) {
|
|
13
|
+
if (!(await shellOk("which", ["git"]))) {
|
|
14
14
|
missing.push("git — install via: brew install git");
|
|
15
15
|
}
|
|
16
16
|
return {
|
package/dist/process-manager.js
CHANGED
|
@@ -8,6 +8,7 @@ import { spawn } from "node:child_process";
|
|
|
8
8
|
import { createWriteStream, mkdirSync } from "node:fs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { waitForHealthy } from "./health.js";
|
|
11
|
+
import { redactSecrets } from "./redact.js";
|
|
11
12
|
// ANSI colors for log prefixing
|
|
12
13
|
const COLORS = [
|
|
13
14
|
"\x1b[36m", // cyan
|
|
@@ -89,7 +90,7 @@ export class ProcessManager {
|
|
|
89
90
|
// For "started" condition, the process is already spawned by the time we get here
|
|
90
91
|
}
|
|
91
92
|
proc.status = "starting";
|
|
92
|
-
console.log(` [${name}] Starting: ${proc.command}`);
|
|
93
|
+
console.log(` [${name}] Starting: ${redactSecrets(proc.command)}`);
|
|
93
94
|
const logFile = createWriteStream(join(this.logDir, `${name}.log`), { flags: "a" });
|
|
94
95
|
const colorIdx = [...this.processes.keys()].indexOf(name) % COLORS.length;
|
|
95
96
|
const color = COLORS[colorIdx];
|
package/dist/provider.d.ts
CHANGED
|
@@ -4,16 +4,14 @@
|
|
|
4
4
|
* Reads a Launchfile, provisions resources, resolves env vars,
|
|
5
5
|
* installs runtimes, and starts all components.
|
|
6
6
|
*/
|
|
7
|
-
import { type NormalizedComponent } from "@launchfile/sdk";
|
|
7
|
+
import { type NormalizedLaunch, type NormalizedComponent } from "@launchfile/sdk";
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* This provider runs apps from source. A component is source-runnable when
|
|
10
|
+
* {@link resolveSourceRunCommand} (D-38) resolves a command — declares `dev`,
|
|
11
|
+
* or a `start` with no `image`. An `image` without a `dev` override stays
|
|
12
|
+
* artifact-mode, which this source-only provider can't launch.
|
|
13
13
|
*/
|
|
14
14
|
export declare function isSourceRunnable(component: NormalizedComponent): boolean;
|
|
15
|
-
/** The command run from source, or undefined if the component resolves to its artifact. */
|
|
16
|
-
export declare function sourceRunCommand(component: NormalizedComponent): string | undefined;
|
|
17
15
|
export interface LaunchUpOpts {
|
|
18
16
|
withOptional?: boolean;
|
|
19
17
|
noBuild?: boolean;
|
|
@@ -28,7 +26,47 @@ export interface LaunchUpOpts {
|
|
|
28
26
|
* so both yield the identical running topology (P-5). Empty = all components.
|
|
29
27
|
*/
|
|
30
28
|
components?: string[];
|
|
29
|
+
/**
|
|
30
|
+
* Host paths for `content: operator` volumes (D-50 rule 1), keyed as the
|
|
31
|
+
* operator typed them: `<volume>`, or `<component>.<volume>` where a volume
|
|
32
|
+
* name is ambiguous. Relative paths resolve against the current directory.
|
|
33
|
+
*/
|
|
34
|
+
storage?: Record<string, string>;
|
|
31
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Components this provider must refuse, mapped to the capabilities it cannot
|
|
38
|
+
* grant (D-44, PROVIDERS.md §11). Both spellings fold together so the `host:`
|
|
39
|
+
* entry form and the legacy top-level block produce the same outcome.
|
|
40
|
+
*
|
|
41
|
+
* A refusal must remove the component from the run, not merely report it —
|
|
42
|
+
* this provider grants no host capabilities, so anything listed here cannot
|
|
43
|
+
* be installed, wired, registered, or started.
|
|
44
|
+
*/
|
|
45
|
+
export declare function refusedHostCapabilities(launch: NormalizedLaunch): Map<string, string[]>;
|
|
46
|
+
/**
|
|
47
|
+
* The launch-time notice a provider without a scheduler owes for a declared
|
|
48
|
+
* `schedule` (D-51, PROVIDERS.md §10 item 8).
|
|
49
|
+
*
|
|
50
|
+
* States what *this provider* does, not what will happen to the app: a
|
|
51
|
+
* component may schedule itself — `catalog/drafts/diun` sets its own
|
|
52
|
+
* `DIUN_WATCH_SCHEDULE`, and nextcloud's `cron.sh` is a foreground `crond` —
|
|
53
|
+
* so claiming the job will not run would be false about those apps, and a
|
|
54
|
+
* warning that misstates the user's app is worse than the silence it replaces.
|
|
55
|
+
*/
|
|
56
|
+
export declare function scheduleWarning(component: string, schedule: string): string;
|
|
57
|
+
/**
|
|
58
|
+
* Remove every component this provider must refuse, and say so on stderr.
|
|
59
|
+
*
|
|
60
|
+
* The removal is the refusal (D-44, PROVIDERS.md §11): a component left in the
|
|
61
|
+
* map goes on to be installed, env-wired, registered with the process manager
|
|
62
|
+
* and started, so logging alone would have the provider assert a refusal it
|
|
63
|
+
* did not perform. Mutates `launch.components` for exactly that reason —
|
|
64
|
+
* everything downstream reads it.
|
|
65
|
+
*
|
|
66
|
+
* Returns "none-left" when nothing survives, so the caller can fail rather than
|
|
67
|
+
* report success over an empty set.
|
|
68
|
+
*/
|
|
69
|
+
export declare function applyHostCapabilityRefusals(launch: NormalizedLaunch): "ok" | "none-left";
|
|
32
70
|
export declare function launchUp(opts?: LaunchUpOpts): Promise<void>;
|
|
33
71
|
export declare function launchDown(opts?: {
|
|
34
72
|
destroy?: boolean;
|
package/dist/provider.js
CHANGED
|
@@ -4,26 +4,10 @@
|
|
|
4
4
|
* Reads a Launchfile, provisions resources, resolves env vars,
|
|
5
5
|
* installs runtimes, and starts all components.
|
|
6
6
|
*/
|
|
7
|
+
import { accessSync, constants as fsConstants } from "node:fs";
|
|
7
8
|
import { readFile } from "node:fs/promises";
|
|
8
|
-
import { join } from "node:path";
|
|
9
|
-
import { readLaunch, selectionClosure } from "@launchfile/sdk";
|
|
10
|
-
/**
|
|
11
|
-
* Source-mode run resolution (D-38, precedence `dev` > `image` > `start`).
|
|
12
|
-
* This provider runs apps from source. A component is source-runnable when it
|
|
13
|
-
* declares `dev`, or a `start` with no `image` — an `image` without a `dev`
|
|
14
|
-
* override stays artifact-mode, which this source-only provider can't launch.
|
|
15
|
-
*/
|
|
16
|
-
export function isSourceRunnable(component) {
|
|
17
|
-
return Boolean(component.commands?.dev || (component.commands?.start && !component.image));
|
|
18
|
-
}
|
|
19
|
-
/** The command run from source, or undefined if the component resolves to its artifact. */
|
|
20
|
-
export function sourceRunCommand(component) {
|
|
21
|
-
if (component.commands?.dev?.command)
|
|
22
|
-
return component.commands.dev.command;
|
|
23
|
-
if (component.image)
|
|
24
|
-
return undefined; // image, no `dev` override → artifact
|
|
25
|
-
return component.commands?.start?.command;
|
|
26
|
-
}
|
|
9
|
+
import { join, resolve as resolvePath } from "node:path";
|
|
10
|
+
import { indexOperatorStoragePaths, MissingOperatorStoragePathError, readLaunch, resolveSourcePrepareCommand, resolveSourceRunCommand, selectionClosure, UnboundOperatorStorageError, unsuppliedRequiredEnv, } from "@launchfile/sdk";
|
|
27
11
|
import { checkPrereqs } from "./prereqs.js";
|
|
28
12
|
import { loadState, initState, saveState, ensureDirs } from "./state.js";
|
|
29
13
|
import { buildResolverContext, computeAppProperties, resolveComponentEnv, generateSecrets, resolveGenerators, writeEnvFile, } from "./env-writer.js";
|
|
@@ -34,8 +18,109 @@ import { detectPackageManager } from "./lockfile-detect.js";
|
|
|
34
18
|
import { provisionStorage, storagePaths } from "./storage.js";
|
|
35
19
|
import { ProcessManager } from "./process-manager.js";
|
|
36
20
|
import { stopRecordedProcesses } from "./process-stopper.js";
|
|
37
|
-
import {
|
|
21
|
+
import { shellScript } from "./shell.js";
|
|
38
22
|
import { parseDuration } from "./bootstrap.js";
|
|
23
|
+
/**
|
|
24
|
+
* This provider runs apps from source. A component is source-runnable when
|
|
25
|
+
* {@link resolveSourceRunCommand} (D-38) resolves a command — declares `dev`,
|
|
26
|
+
* or a `start` with no `image`. An `image` without a `dev` override stays
|
|
27
|
+
* artifact-mode, which this source-only provider can't launch.
|
|
28
|
+
*/
|
|
29
|
+
export function isSourceRunnable(component) {
|
|
30
|
+
return resolveSourceRunCommand(component) !== undefined;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Parse a declared timeout, adding the stage/component label to the error.
|
|
34
|
+
* An unparseable duration is surfaced — it fails the stage that declared it
|
|
35
|
+
* (PROVIDERS.md §10.10) — never silently replaced with a default. Undefined
|
|
36
|
+
* passes through so callers keep their own default budgets.
|
|
37
|
+
*/
|
|
38
|
+
function declaredTimeout(timeout, label) {
|
|
39
|
+
if (timeout === undefined)
|
|
40
|
+
return undefined;
|
|
41
|
+
try {
|
|
42
|
+
return parseDuration(timeout);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
throw new Error(`${label}: ${err instanceof Error ? err.message : String(err)}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Whether a path exists and this process can read it (D-50 rule 2, row 3). */
|
|
49
|
+
function isReadable(path) {
|
|
50
|
+
try {
|
|
51
|
+
accessSync(path, fsConstants.R_OK);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Components this provider must refuse, mapped to the capabilities it cannot
|
|
60
|
+
* grant (D-44, PROVIDERS.md §11). Both spellings fold together so the `host:`
|
|
61
|
+
* entry form and the legacy top-level block produce the same outcome.
|
|
62
|
+
*
|
|
63
|
+
* A refusal must remove the component from the run, not merely report it —
|
|
64
|
+
* this provider grants no host capabilities, so anything listed here cannot
|
|
65
|
+
* be installed, wired, registered, or started.
|
|
66
|
+
*/
|
|
67
|
+
export function refusedHostCapabilities(launch) {
|
|
68
|
+
const refused = new Map();
|
|
69
|
+
for (const [name, c] of Object.entries(launch.components)) {
|
|
70
|
+
const caps = [];
|
|
71
|
+
for (const req of c.requires ?? []) {
|
|
72
|
+
for (const [capability, value] of Object.entries(req.host ?? {})) {
|
|
73
|
+
caps.push(`${capability}=${String(value)}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (c.host?.docker === "required")
|
|
77
|
+
caps.push("container_runtime=docker (host.docker)");
|
|
78
|
+
if (c.host?.network === "host")
|
|
79
|
+
caps.push("network=host (host.network)");
|
|
80
|
+
if (c.host?.privileged)
|
|
81
|
+
caps.push("privileged=true (host.privileged)");
|
|
82
|
+
if (caps.length > 0)
|
|
83
|
+
refused.set(name, caps);
|
|
84
|
+
}
|
|
85
|
+
return refused;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The launch-time notice a provider without a scheduler owes for a declared
|
|
89
|
+
* `schedule` (D-51, PROVIDERS.md §10 item 8).
|
|
90
|
+
*
|
|
91
|
+
* States what *this provider* does, not what will happen to the app: a
|
|
92
|
+
* component may schedule itself — `catalog/drafts/diun` sets its own
|
|
93
|
+
* `DIUN_WATCH_SCHEDULE`, and nextcloud's `cron.sh` is a foreground `crond` —
|
|
94
|
+
* so claiming the job will not run would be false about those apps, and a
|
|
95
|
+
* warning that misstates the user's app is worse than the silence it replaces.
|
|
96
|
+
*/
|
|
97
|
+
export function scheduleWarning(component, schedule) {
|
|
98
|
+
return (`[${component}] declares \`schedule: ${schedule}\` — this provider will not ` +
|
|
99
|
+
"run it on a timer. If the component does not schedule itself, the job will not run.");
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Remove every component this provider must refuse, and say so on stderr.
|
|
103
|
+
*
|
|
104
|
+
* The removal is the refusal (D-44, PROVIDERS.md §11): a component left in the
|
|
105
|
+
* map goes on to be installed, env-wired, registered with the process manager
|
|
106
|
+
* and started, so logging alone would have the provider assert a refusal it
|
|
107
|
+
* did not perform. Mutates `launch.components` for exactly that reason —
|
|
108
|
+
* everything downstream reads it.
|
|
109
|
+
*
|
|
110
|
+
* Returns "none-left" when nothing survives, so the caller can fail rather than
|
|
111
|
+
* report success over an empty set.
|
|
112
|
+
*/
|
|
113
|
+
export function applyHostCapabilityRefusals(launch) {
|
|
114
|
+
const refused = refusedHostCapabilities(launch);
|
|
115
|
+
for (const [name, caps] of refused) {
|
|
116
|
+
console.error(` Refused: ${name} requires host capabilities this provider cannot grant ` +
|
|
117
|
+
`(${caps.join("; ")}) — component not started`);
|
|
118
|
+
}
|
|
119
|
+
if (refused.size === 0)
|
|
120
|
+
return "ok";
|
|
121
|
+
launch.components = Object.fromEntries(Object.entries(launch.components).filter(([n]) => !refused.has(n)));
|
|
122
|
+
return Object.keys(launch.components).length === 0 ? "none-left" : "ok";
|
|
123
|
+
}
|
|
39
124
|
export async function launchUp(opts = {}) {
|
|
40
125
|
const projectDir = opts.projectDir ?? process.cwd();
|
|
41
126
|
// 1. Check prerequisites
|
|
@@ -80,6 +165,27 @@ export async function launchUp(opts = {}) {
|
|
|
80
165
|
const startSet = new Set(selection.start);
|
|
81
166
|
launch.components = Object.fromEntries(Object.entries(launch.components).filter(([n]) => startSet.has(n)));
|
|
82
167
|
}
|
|
168
|
+
// 2a. Host capabilities are granted or refused, never provisioned (D-44,
|
|
169
|
+
// PROVIDERS.md §11). This provider runs processes directly on the host and
|
|
170
|
+
// grants none of them, so a component with a required capability is
|
|
171
|
+
// DECLINED — removed from the map here so nothing downstream installs a
|
|
172
|
+
// runtime, wires env, registers with pm2, or starts it. Logging alone would
|
|
173
|
+
// leave the provider asserting a refusal it did not perform.
|
|
174
|
+
// Both spellings fold together so they land identically (§11 equivalence):
|
|
175
|
+
// the `host:` entry form and the legacy top-level block.
|
|
176
|
+
if (applyHostCapabilityRefusals(launch) === "none-left") {
|
|
177
|
+
console.error("Every selected component requires a host capability this provider cannot grant.");
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
// An optional capability is not refused — the component runs, degraded.
|
|
181
|
+
for (const [name, c] of Object.entries(launch.components)) {
|
|
182
|
+
for (const sup of c.supports ?? []) {
|
|
183
|
+
for (const [capability, value] of Object.entries(sup.host ?? {})) {
|
|
184
|
+
console.warn(` Warning: ${name}: optional host capability ` +
|
|
185
|
+
`${capability}=${String(value)} not granted — running degraded`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
83
189
|
const componentNames = Object.keys(launch.components);
|
|
84
190
|
// 2b. Source-mode guard (D-38) — fail fast before provisioning anything.
|
|
85
191
|
// Run precedence is `dev` > `image` > `start`: a component runs from source
|
|
@@ -102,6 +208,116 @@ export async function launchUp(opts = {}) {
|
|
|
102
208
|
console.warn(` ! [${name}] has an image and no \`dev\` override — runs as an artifact, ` +
|
|
103
209
|
"skipped in source mode; use `launchfile up` to run it.");
|
|
104
210
|
}
|
|
211
|
+
// PROVIDERS.md conformance rule 8 (D-51): a provider that does not
|
|
212
|
+
// execute `schedule` MUST say so at launch. Staying silent leaves an
|
|
213
|
+
// author believing a declared cron job is running — the one outcome
|
|
214
|
+
// worse than not supporting it. Wording stays start-agnostic: artifact
|
|
215
|
+
// components with a schedule reach this loop too, and they are skipped
|
|
216
|
+
// entirely in source mode.
|
|
217
|
+
if (c.schedule) {
|
|
218
|
+
console.warn(` ! ${scheduleWarning(name, c.schedule)}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// 2c. Unsupplied `required:` environment variables (D-52, PROVIDERS.md §10
|
|
222
|
+
// rule 8, deploying branch). This provider's operator channel is the
|
|
223
|
+
// launching environment, read EXPLICITLY here — the `...process.env` spread
|
|
224
|
+
// on the pm2 registration below is incidental inheritance that never reaches
|
|
225
|
+
// `release` and is invisible to `env`, so it cannot serve as the channel.
|
|
226
|
+
// Values found are carried in `operatorEnv` and merged into `allEnvs` at
|
|
227
|
+
// step 12, which puts them on both `release` and `start` and makes them
|
|
228
|
+
// visible to `launch env`. Anything still missing fails HERE — before
|
|
229
|
+
// directories, resources, ports, runtimes, or processes exist. No prompt: a
|
|
230
|
+
// non-interactive invocation must fail by name, not hang on stdin.
|
|
231
|
+
const operatorEnv = {};
|
|
232
|
+
const missingRequired = [];
|
|
233
|
+
for (const [name, component] of Object.entries(launch.components)) {
|
|
234
|
+
// A `requires:` binding injects only when this provider can provision the
|
|
235
|
+
// resource behind it; `supports:` is provisioned only under --with-optional
|
|
236
|
+
// and is never credited (SPEC.md §Supports).
|
|
237
|
+
const arriving = new Set((component.requires ?? [])
|
|
238
|
+
.filter((req) => !req.host && getProvisioner(req.type))
|
|
239
|
+
.flatMap((req) => Object.keys(req.set_env ?? {})));
|
|
240
|
+
for (const { key, sensitive } of unsuppliedRequiredEnv(component, arriving)) {
|
|
241
|
+
const supplied = process.env[key];
|
|
242
|
+
if (supplied !== undefined) {
|
|
243
|
+
(operatorEnv[name] ??= {})[key] = supplied;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
missingRequired.push({ component: name, key, sensitive });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (missingRequired.length > 0) {
|
|
250
|
+
console.error(`\nCannot launch: ${missingRequired.length} required environment variable${missingRequired.length === 1 ? "" : "s"} had no value.`);
|
|
251
|
+
for (const { component, key, sensitive } of missingRequired) {
|
|
252
|
+
console.error(` - ${component}: ${key}${sensitive ? " (sensitive)" : ""}`);
|
|
253
|
+
}
|
|
254
|
+
console.error("\nThe Launchfile declares them `required:` with no `default:`, `generator:`, or resource");
|
|
255
|
+
console.error("binding, so you supply them. Set them in the environment and run `up` again, e.g.");
|
|
256
|
+
console.error(` ${missingRequired[0].key}=<value> launch up`);
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
259
|
+
// 2d. Operator-supplied storage (D-50 rules 1–2), settled here — before
|
|
260
|
+
// state, directories, resources, ports, runtimes or processes exist, and so
|
|
261
|
+
// before `--dry-run` returns. A marked volume with no path, or with one that
|
|
262
|
+
// is not on disk, fails the launch: an empty directory where the operator's
|
|
263
|
+
// library belongs is D-52's fabrication in storage form, and this provider
|
|
264
|
+
// creates neither.
|
|
265
|
+
//
|
|
266
|
+
// Scoped to the components this provider will actually run. An artifact
|
|
267
|
+
// component is warned about and skipped at step 16, so refusing the whole
|
|
268
|
+
// launch over storage it will never read would be a refusal about nothing —
|
|
269
|
+
// the same reason `@launchfile/docker` only examines components it
|
|
270
|
+
// translates. Its volumes simply stay unprovisioned at step 11.
|
|
271
|
+
//
|
|
272
|
+
// Unlike the host-capability refusal above, this throws rather than dropping
|
|
273
|
+
// the component: `@launchfile/docker` fails the whole launch for the same
|
|
274
|
+
// file, and one Launchfile must not yield two topologies (P-5).
|
|
275
|
+
const suppliedStorage = opts.storage
|
|
276
|
+
? Object.fromEntries(Object.entries(opts.storage).map(([key, path]) => [key, resolvePath(path)]))
|
|
277
|
+
: undefined;
|
|
278
|
+
const storageIndex = indexOperatorStoragePaths(launch, suppliedStorage);
|
|
279
|
+
const usedStorageKeys = new Set();
|
|
280
|
+
const unboundVolumes = [];
|
|
281
|
+
const storageBinds = [];
|
|
282
|
+
const operatorStorage = {};
|
|
283
|
+
for (const [name, component] of Object.entries(launch.components)) {
|
|
284
|
+
if (!isSourceRunnable(component))
|
|
285
|
+
continue;
|
|
286
|
+
for (const [volName, vol] of Object.entries(component.storage ?? {})) {
|
|
287
|
+
if (vol.content !== "operator")
|
|
288
|
+
continue;
|
|
289
|
+
const supplied = storageIndex.lookup(name, volName);
|
|
290
|
+
if (!supplied) {
|
|
291
|
+
unboundVolumes.push({
|
|
292
|
+
component: name,
|
|
293
|
+
volume: volName,
|
|
294
|
+
flag: storageIndex.flagFor(name, volName),
|
|
295
|
+
});
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
usedStorageKeys.add(supplied.key);
|
|
299
|
+
storageBinds.push({
|
|
300
|
+
component: name,
|
|
301
|
+
volume: volName,
|
|
302
|
+
key: supplied.key,
|
|
303
|
+
hostPath: supplied.path,
|
|
304
|
+
containerPath: vol.path,
|
|
305
|
+
});
|
|
306
|
+
(operatorStorage[name] ??= {})[volName] = supplied.path;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (unboundVolumes.length > 0) {
|
|
310
|
+
throw new UnboundOperatorStorageError(unboundVolumes);
|
|
311
|
+
}
|
|
312
|
+
const unreadableBinds = storageBinds.filter((bind) => !isReadable(bind.hostPath));
|
|
313
|
+
if (unreadableBinds.length > 0) {
|
|
314
|
+
throw new MissingOperatorStoragePathError(unreadableBinds);
|
|
315
|
+
}
|
|
316
|
+
// A supplied key that bound nothing would otherwise vanish without a trace,
|
|
317
|
+
// so a typo'd name surfaces here. Row 4 keeps unmarked volumes untouched, so
|
|
318
|
+
// a key naming one is unused too.
|
|
319
|
+
for (const key of storageIndex.unusedKeys(usedStorageKeys)) {
|
|
320
|
+
console.warn(` Warning: --storage ${key} matches no \`content: operator\` volume — ignored`);
|
|
105
321
|
}
|
|
106
322
|
// 3. Load or init state
|
|
107
323
|
let state = await loadState(projectDir);
|
|
@@ -116,6 +332,8 @@ export async function launchUp(opts = {}) {
|
|
|
116
332
|
const resourceMap = {};
|
|
117
333
|
for (const [_compName, component] of Object.entries(launch.components)) {
|
|
118
334
|
for (const req of component.requires ?? []) {
|
|
335
|
+
if (req.host)
|
|
336
|
+
continue; // capability, not a backing service (D-44)
|
|
119
337
|
const resourceName = req.name ?? req.type;
|
|
120
338
|
if (resourceMap[resourceName])
|
|
121
339
|
continue; // Already provisioned
|
|
@@ -139,6 +357,8 @@ export async function launchUp(opts = {}) {
|
|
|
139
357
|
// Optional supports resources
|
|
140
358
|
if (opts.withOptional) {
|
|
141
359
|
for (const sup of component.supports ?? []) {
|
|
360
|
+
if (sup.host)
|
|
361
|
+
continue; // capability, not a backing service (D-44)
|
|
142
362
|
const resourceName = sup.name ?? sup.type;
|
|
143
363
|
if (resourceMap[resourceName])
|
|
144
364
|
continue;
|
|
@@ -202,7 +422,7 @@ export async function launchUp(opts = {}) {
|
|
|
202
422
|
// so it can be injected as $storage.<name>.path (D-39). Scoped per component.
|
|
203
423
|
const componentStorage = {};
|
|
204
424
|
for (const [name, component] of Object.entries(launch.components)) {
|
|
205
|
-
const volumeMap = await provisionStorage(component.storage, name, projectDir);
|
|
425
|
+
const volumeMap = await provisionStorage(component.storage, name, projectDir, operatorStorage[name]);
|
|
206
426
|
const storageCtx = {};
|
|
207
427
|
for (const [volName, localPath] of Object.entries(volumeMap)) {
|
|
208
428
|
storageCtx[volName] = { path: localPath };
|
|
@@ -212,9 +432,15 @@ export async function launchUp(opts = {}) {
|
|
|
212
432
|
// 12. Resolve env vars and write .env files
|
|
213
433
|
const allEnvs = {};
|
|
214
434
|
const isSingleComponent = componentNames.length === 1 && componentNames[0] === "default";
|
|
435
|
+
// Minted env-level generator values live in state (D-49) so a redeploy
|
|
436
|
+
// reuses them; saveState below (step 13) persists anything minted here.
|
|
437
|
+
const generatedEnv = (state.generatedEnv ??= {});
|
|
215
438
|
for (const [name, component] of Object.entries(launch.components)) {
|
|
216
|
-
const env = resolveComponentEnv(component, context, resourceMap, componentStorage[name]);
|
|
217
|
-
await resolveGenerators(component, env);
|
|
439
|
+
const { env } = resolveComponentEnv(component, context, resourceMap, componentStorage[name]);
|
|
440
|
+
await resolveGenerators(component, env, name, generatedEnv);
|
|
441
|
+
// Operator-supplied `required:` values (step 2c) join the resolved set, so
|
|
442
|
+
// they reach `release` and `start` alike and show up in `launch env`.
|
|
443
|
+
Object.assign(env, operatorEnv[name] ?? {});
|
|
218
444
|
const port = componentPorts[name];
|
|
219
445
|
if (port && !env.PORT) {
|
|
220
446
|
env.PORT = String(port);
|
|
@@ -236,6 +462,9 @@ export async function launchUp(opts = {}) {
|
|
|
236
462
|
}
|
|
237
463
|
}
|
|
238
464
|
// 13. Save state before build (in case build fails, we still have resource state)
|
|
465
|
+
// The bound operator paths ride along so `env` can report the directory the
|
|
466
|
+
// app actually reads (D-50); a later `up` still has to supply them again.
|
|
467
|
+
state.operatorStorage = operatorStorage;
|
|
239
468
|
await saveState(projectDir, state);
|
|
240
469
|
if (opts.dryRun) {
|
|
241
470
|
console.log("\n[dry-run] Would now run build, release, and start commands.");
|
|
@@ -245,16 +474,16 @@ export async function launchUp(opts = {}) {
|
|
|
245
474
|
// 14. Run source-mode prepare \u2014 `install ?? build` (D-38), on demand
|
|
246
475
|
if (!opts.noBuild) {
|
|
247
476
|
for (const [name, component] of Object.entries(launch.components)) {
|
|
248
|
-
const prepare = component
|
|
477
|
+
const prepare = resolveSourcePrepareCommand(component);
|
|
249
478
|
const cmd = prepare?.command ?? pm?.installCommand;
|
|
250
479
|
if (cmd) {
|
|
251
480
|
console.log(` \u2193 Preparing${componentNames.length > 1 ? ` [${name}]` : ""}...`);
|
|
252
|
-
await
|
|
481
|
+
await shellScript(cmd, {
|
|
253
482
|
cwd: join(projectDir, component.source ?? component.build?.context ?? "."),
|
|
254
483
|
env: allEnvs[name],
|
|
255
484
|
// Installs/compiles routinely exceed the 2-minute shell default;
|
|
256
485
|
// honor a declared timeout, else allow 10 minutes.
|
|
257
|
-
timeout: prepare?.timeout
|
|
486
|
+
timeout: declaredTimeout(prepare?.timeout, `prepare [${name}]`) ?? 600_000,
|
|
258
487
|
});
|
|
259
488
|
}
|
|
260
489
|
}
|
|
@@ -264,10 +493,10 @@ export async function launchUp(opts = {}) {
|
|
|
264
493
|
const release = component.commands?.release;
|
|
265
494
|
if (release?.command) {
|
|
266
495
|
console.log(` \u2193 Running release${componentNames.length > 1 ? ` [${name}]` : ""}...`);
|
|
267
|
-
await
|
|
496
|
+
await shellScript(release.command, {
|
|
268
497
|
cwd: join(projectDir, component.source ?? component.build?.context ?? "."),
|
|
269
498
|
env: allEnvs[name],
|
|
270
|
-
timeout: release.timeout
|
|
499
|
+
timeout: declaredTimeout(release.timeout, `release [${name}]`),
|
|
271
500
|
});
|
|
272
501
|
}
|
|
273
502
|
}
|
|
@@ -278,7 +507,7 @@ export async function launchUp(opts = {}) {
|
|
|
278
507
|
// Resolve the source-mode run command (D-38 precedence `dev` > `image` >
|
|
279
508
|
// `start`). Artifact components (image, no `dev` override) resolve to
|
|
280
509
|
// undefined — they were warned by the guard; skip them.
|
|
281
|
-
const startCmd =
|
|
510
|
+
const startCmd = resolveSourceRunCommand(component)?.command;
|
|
282
511
|
if (!startCmd)
|
|
283
512
|
continue;
|
|
284
513
|
pm2.register(name, {
|
|
@@ -425,24 +654,59 @@ export async function launchEnv(opts = {}) {
|
|
|
425
654
|
}
|
|
426
655
|
const appProperties = computeAppProperties(launch, state.ports);
|
|
427
656
|
const context = buildResolverContext(resourceMap, state.ports, state.secrets, appProperties);
|
|
657
|
+
// `env` reports what the running app has, so it reads minted generator
|
|
658
|
+
// values from the same store `up` persists to (D-49). A value can still be
|
|
659
|
+
// minted here — a generator declared after the last `up` — and then it is
|
|
660
|
+
// persisted below, before printing, so `up`, `env`, and `bootstrap` all
|
|
661
|
+
// keep answering with the same value.
|
|
662
|
+
const generatedEnv = (state.generatedEnv ??= {});
|
|
663
|
+
let minted = false;
|
|
664
|
+
const resolvedEnvs = [];
|
|
428
665
|
for (const [name, component] of Object.entries(launch.components)) {
|
|
429
666
|
if (opts.component && name !== opts.component)
|
|
430
667
|
continue;
|
|
431
668
|
// Resolved storage paths (D-39) — computed, not provisioned (no mkdir):
|
|
432
669
|
// `launchfile env` only prints, and the dirs already exist from `up`.
|
|
670
|
+
// A `content: operator` volume reads back the host path `up` bound
|
|
671
|
+
// (D-50), so what prints here is what the running app was given.
|
|
433
672
|
const storageCtx = {};
|
|
434
|
-
for (const [volName, localPath] of Object.entries(storagePaths(component.storage, name, projectDir))) {
|
|
673
|
+
for (const [volName, localPath] of Object.entries(storagePaths(component.storage, name, projectDir, state.operatorStorage?.[name]))) {
|
|
435
674
|
storageCtx[volName] = { path: localPath };
|
|
436
675
|
}
|
|
437
|
-
const env = resolveComponentEnv(component, context, resourceMap, storageCtx);
|
|
438
|
-
await resolveGenerators(component, env);
|
|
676
|
+
const { env, unsupplied } = resolveComponentEnv(component, context, resourceMap, storageCtx);
|
|
677
|
+
minted = (await resolveGenerators(component, env, name, generatedEnv)) || minted;
|
|
678
|
+
// The operator channel `up` reads (the launching environment) answers here
|
|
679
|
+
// too, so a var supplied at launch time prints as a real value rather than
|
|
680
|
+
// being reported missing.
|
|
681
|
+
for (const { key } of unsupplied) {
|
|
682
|
+
const supplied = process.env[key];
|
|
683
|
+
if (supplied !== undefined)
|
|
684
|
+
env[key] = supplied;
|
|
685
|
+
}
|
|
439
686
|
const port = state.ports[name];
|
|
440
687
|
if (port && !env.PORT)
|
|
441
688
|
env.PORT = String(port);
|
|
689
|
+
resolvedEnvs.push([name, env, unsupplied]);
|
|
690
|
+
}
|
|
691
|
+
if (minted) {
|
|
692
|
+
await saveState(projectDir, state);
|
|
693
|
+
}
|
|
694
|
+
for (const [name, env, unsupplied] of resolvedEnvs) {
|
|
442
695
|
console.log(`\n# ${name}`);
|
|
443
696
|
for (const [key, value] of Object.entries(env).sort(([a], [b]) => a.localeCompare(b))) {
|
|
444
697
|
console.log(`${key}=${value}`);
|
|
445
698
|
}
|
|
699
|
+
// PROVIDERS.md §10 rule 8, `env` branch: report an unsupplied required var
|
|
700
|
+
// rather than dropping it — this is where an operator comes to find out
|
|
701
|
+
// what is missing. It goes out as a `#` comment, never a bare `KEY=` line,
|
|
702
|
+
// because this output is designed to be `eval`'d (§2): a bare line would
|
|
703
|
+
// export an empty value and re-create the failure the rule exists to stop.
|
|
704
|
+
for (const { key, sensitive } of unsupplied.sort((a, b) => a.key.localeCompare(b.key))) {
|
|
705
|
+
if (env[key] !== undefined)
|
|
706
|
+
continue;
|
|
707
|
+
console.log(`# ${key}: unsupplied — required, no default/generator/binding` +
|
|
708
|
+
`${sensitive ? ", sensitive" : ""}. Supply it in the environment.`);
|
|
709
|
+
}
|
|
446
710
|
}
|
|
447
711
|
}
|
|
448
712
|
//# 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
|