@launchfile/macos-dev 0.2.0 → 0.3.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.js CHANGED
@@ -158,6 +158,9 @@ export async function launchBootstrap(opts = {}) {
158
158
  for (const [name, component] of Object.entries(launch.components)) {
159
159
  if (opts.component && name !== opts.component)
160
160
  continue;
161
+ // `bootstrap` is mode-invariant (D-38): the same command runs from
162
+ // source or artifact. A path or binary that differs by mode belongs in
163
+ // storage:/env/PATH, not a separate command.
161
164
  const bootstrap = component.commands?.bootstrap;
162
165
  if (!bootstrap)
163
166
  continue;
@@ -8,10 +8,13 @@ import { type NormalizedComponent, type NormalizedLaunch, type ResolverContext,
8
8
  import type { ResourceProperties } from "./resources/types.js";
9
9
  export type { ResolverContext };
10
10
  /**
11
- * Compute the $app.* property set for a Launchfile under the macos-dev
12
- * provider. The app's "primary" port comes from the first component (in
13
- * declaration order) that has at least one `exposed: true` provides entry.
14
- * Apps with no exposed component get `port: 0` and `url: ""`.
11
+ * Compute the $app.* property set (D-33, D-35) for a Launchfile under the
12
+ * macos-dev provider. The app's "primary" port comes from the first component
13
+ * (in declaration order) that has at least one `exposed: true` provides entry.
14
+ * The `authority`/`scheme`/`tls` trio is derived from the resulting URL via the
15
+ * SDK so split-field tokens (e.g. `CMD_DOMAIN: $app.authority`) resolve.
16
+ * Apps with no exposed component get `port: 0` and `url: ""` (and empty
17
+ * authority/scheme/tls).
15
18
  *
16
19
  * For multi-exposed-component apps that need a specific component's URL,
17
20
  * use `$components.<name>.url` instead — `$app.*` always points at the
@@ -26,7 +29,7 @@ export declare function buildResolverContext(resourceMap: Record<string, Resourc
26
29
  /**
27
30
  * Resolve all environment variables for a single component.
28
31
  */
29
- export declare function resolveComponentEnv(component: NormalizedComponent, context: ResolverContext, resourceMap: Record<string, ResourceProperties>): Record<string, string>;
32
+ export declare function resolveComponentEnv(component: NormalizedComponent, context: ResolverContext, resourceMap: Record<string, ResourceProperties>, storage?: Record<string, Record<string, string>>): Record<string, string>;
30
33
  /**
31
34
  * Generate all app-wide secrets, reusing values from state when available.
32
35
  */
@@ -6,13 +6,16 @@
6
6
  */
7
7
  import { writeFile, mkdir } from "node:fs/promises";
8
8
  import { join } from "node:path";
9
- import { resolveExpression, isExpression, } from "@launchfile/sdk";
9
+ import { deriveAppUrlProperties, resolveExpression, isExpression, } from "@launchfile/sdk";
10
10
  import { generateValue } from "./secret-generator.js";
11
11
  /**
12
- * Compute the $app.* property set for a Launchfile under the macos-dev
13
- * provider. The app's "primary" port comes from the first component (in
14
- * declaration order) that has at least one `exposed: true` provides entry.
15
- * Apps with no exposed component get `port: 0` and `url: ""`.
12
+ * Compute the $app.* property set (D-33, D-35) for a Launchfile under the
13
+ * macos-dev provider. The app's "primary" port comes from the first component
14
+ * (in declaration order) that has at least one `exposed: true` provides entry.
15
+ * The `authority`/`scheme`/`tls` trio is derived from the resulting URL via the
16
+ * SDK so split-field tokens (e.g. `CMD_DOMAIN: $app.authority`) resolve.
17
+ * Apps with no exposed component get `port: 0` and `url: ""` (and empty
18
+ * authority/scheme/tls).
16
19
  *
17
20
  * For multi-exposed-component apps that need a specific component's URL,
18
21
  * use `$components.<name>.url` instead — `$app.*` always points at the
@@ -27,11 +30,13 @@ export function computeAppProperties(launch, componentPorts) {
27
30
  break;
28
31
  }
29
32
  }
33
+ const url = primaryPort > 0 ? `http://localhost:${primaryPort}` : "";
30
34
  return {
31
35
  name: launch.name,
32
36
  host: "localhost",
33
37
  port: primaryPort,
34
- url: primaryPort > 0 ? `http://localhost:${primaryPort}` : "",
38
+ url,
39
+ ...deriveAppUrlProperties(url),
35
40
  };
36
41
  }
37
42
  /**
@@ -64,8 +69,12 @@ export function buildResolverContext(resourceMap, componentPorts, secrets, app)
64
69
  /**
65
70
  * Resolve all environment variables for a single component.
66
71
  */
67
- export function resolveComponentEnv(component, context, resourceMap) {
72
+ export function resolveComponentEnv(component, context, resourceMap, storage) {
68
73
  const env = {};
74
+ // This component's provider-resolved storage paths (D-39). Scoped per
75
+ // component because volume names are component-local — component A's
76
+ // `$storage.cache.path` must not see component B's `cache`.
77
+ const ctx = storage ? { ...context, storage } : context;
69
78
  // 1. Resolve set_env from requires
70
79
  for (const req of component.requires ?? []) {
71
80
  const resourceName = req.name ?? req.type;
@@ -79,7 +88,7 @@ export function resolveComponentEnv(component, context, resourceMap) {
79
88
  resourceRecord[k] = v;
80
89
  }
81
90
  const scopedContext = {
82
- ...context,
91
+ ...ctx,
83
92
  resource: resourceRecord,
84
93
  };
85
94
  for (const [envKey, expr] of Object.entries(req.set_env)) {
@@ -98,7 +107,7 @@ export function resolveComponentEnv(component, context, resourceMap) {
98
107
  resourceRecord[k] = v;
99
108
  }
100
109
  const scopedContext = {
101
- ...context,
110
+ ...ctx,
102
111
  resource: resourceRecord,
103
112
  };
104
113
  for (const [envKey, expr] of Object.entries(sup.set_env)) {
@@ -113,7 +122,7 @@ export function resolveComponentEnv(component, context, resourceMap) {
113
122
  if (envVar.default !== undefined) {
114
123
  const defaultStr = String(envVar.default);
115
124
  if (isExpression(defaultStr)) {
116
- env[key] = resolveExpression(defaultStr, context);
125
+ env[key] = resolveExpression(defaultStr, ctx);
117
126
  }
118
127
  else {
119
128
  env[key] = defaultStr;
@@ -5,6 +5,13 @@
5
5
  * health check waits, and graceful shutdown.
6
6
  */
7
7
  import type { NormalizedHealth, NormalizedDependsOnEntry } from "@launchfile/sdk";
8
+ /** A spawned component process recorded for cross-session shutdown. */
9
+ export interface RecordedProcessInfo {
10
+ pid: number;
11
+ pgid: number;
12
+ startedAt: string;
13
+ command: string;
14
+ }
8
15
  export declare class ProcessManager {
9
16
  private processes;
10
17
  private logDir;
@@ -32,6 +39,12 @@ export declare class ProcessManager {
32
39
  * Returns batches of component names that can start concurrently.
33
40
  */
34
41
  private topologicalSort;
42
+ /**
43
+ * Snapshot the live, spawned processes for persistence to state.json.
44
+ * Only includes components that actually spawned (have a pid). Because we
45
+ * spawn detached, the child is its own group leader, so pgid === pid.
46
+ */
47
+ getRecordedProcesses(): Record<string, RecordedProcessInfo>;
35
48
  /** Get status summary for all processes */
36
49
  getStatus(): Array<{
37
50
  name: string;
@@ -18,6 +18,28 @@ const COLORS = [
18
18
  "\x1b[31m", // red
19
19
  ];
20
20
  const RESET = "\x1b[0m";
21
+ /**
22
+ * Signal a detached child's whole process group (negative pid). Falls back to
23
+ * signaling just the child handle if the group signal fails (e.g. the group is
24
+ * already gone). Best-effort: swallows ESRCH so shutdown stays idempotent.
25
+ */
26
+ function killGroupOrSelf(proc, pid, signal) {
27
+ if (pid !== undefined) {
28
+ try {
29
+ process.kill(-pid, signal);
30
+ return;
31
+ }
32
+ catch {
33
+ // Group gone or not a group leader — fall through to handle kill.
34
+ }
35
+ }
36
+ try {
37
+ proc?.kill(signal);
38
+ }
39
+ catch {
40
+ // Already exited.
41
+ }
42
+ }
21
43
  export class ProcessManager {
22
44
  processes = new Map();
23
45
  logDir;
@@ -73,11 +95,20 @@ export class ProcessManager {
73
95
  const color = COLORS[colorIdx];
74
96
  const maxNameLen = Math.max(...[...this.processes.keys()].map((n) => n.length));
75
97
  const paddedName = name.padEnd(maxNameLen);
98
+ // `detached: true` makes the child the leader of a new process group
99
+ // (pgid === pid). That lets `launch down` signal the whole group later via
100
+ // a negative pid, killing the app AND any children it spawned — matching
101
+ // the foreground SIGINT behavior across sessions. We still keep the handle
102
+ // so the foreground session can kill it directly on Ctrl+C.
76
103
  proc.process = spawn("sh", ["-c", proc.command], {
77
104
  env: { ...process.env, ...proc.env },
78
105
  cwd: proc.cwd,
79
106
  stdio: ["ignore", "pipe", "pipe"],
107
+ detached: true,
80
108
  });
109
+ if (proc.process.pid !== undefined) {
110
+ proc.startedAt = new Date().toISOString();
111
+ }
81
112
  // Pipe stdout with prefix
82
113
  proc.process.stdout?.on("data", (data) => {
83
114
  const lines = data.toString().split("\n");
@@ -125,15 +156,19 @@ export class ProcessManager {
125
156
  resolve();
126
157
  return;
127
158
  }
159
+ const pid = proc.process.pid;
128
160
  const timeout = setTimeout(() => {
129
- proc.process?.kill("SIGKILL");
161
+ // Escalate to the whole group so stray children die too.
162
+ killGroupOrSelf(proc.process, pid, "SIGKILL");
130
163
  }, 10_000);
131
164
  proc.process.once("exit", () => {
132
165
  clearTimeout(timeout);
133
166
  proc.status = "stopped";
134
167
  resolve();
135
168
  });
136
- proc.process.kill("SIGTERM");
169
+ // Children are spawned detached (own process group), so signal the
170
+ // group (negative pid) to reap any grandchildren too.
171
+ killGroupOrSelf(proc.process, pid, "SIGTERM");
137
172
  });
138
173
  }
139
174
  /**
@@ -175,6 +210,26 @@ export class ProcessManager {
175
210
  }
176
211
  return batches;
177
212
  }
213
+ /**
214
+ * Snapshot the live, spawned processes for persistence to state.json.
215
+ * Only includes components that actually spawned (have a pid). Because we
216
+ * spawn detached, the child is its own group leader, so pgid === pid.
217
+ */
218
+ getRecordedProcesses() {
219
+ const out = {};
220
+ for (const [name, proc] of this.processes) {
221
+ const pid = proc.process?.pid;
222
+ if (pid === undefined || proc.startedAt === undefined)
223
+ continue;
224
+ out[name] = {
225
+ pid,
226
+ pgid: pid,
227
+ startedAt: proc.startedAt,
228
+ command: proc.command,
229
+ };
230
+ }
231
+ return out;
232
+ }
178
233
  /** Get status summary for all processes */
179
234
  getStatus() {
180
235
  return [...this.processes.entries()].map(([name, proc]) => ({
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Cross-session app-process termination for `launch down`.
3
+ *
4
+ * The foreground `launch up` session kills its children directly via the
5
+ * ProcessManager on Ctrl+C. But `launch down` may run from a *different* shell
6
+ * (or after the up-session has exited), so it has no live ChildProcess handles —
7
+ * only the pids recorded in `.launchfile/state.json`. This module signals those
8
+ * recorded process groups, with liveness + identity checks to avoid killing an
9
+ * unrelated process that happens to have inherited a recycled pid.
10
+ *
11
+ * ## pid-reuse identity guarantee (read this before trusting the kill)
12
+ *
13
+ * macOS recycles pids. A pid we recorded at `up` time may, by `down` time,
14
+ * belong to a completely unrelated process. Before sending any signal we:
15
+ *
16
+ * 1. **Liveness** — `process.kill(pid, 0)` throws ESRCH if no such process
17
+ * exists. If it's dead, we skip (already stopped).
18
+ * 2. **Identity** — we compare the recorded spawn time against the live
19
+ * process's actual start time via `ps -o lstart= -p <pid>`. If the live
20
+ * process started meaningfully later than we recorded, the pid was
21
+ * recycled and we REFUSE to signal it.
22
+ *
23
+ * This is a best-effort guarantee, not a cryptographic one. Its honest limits:
24
+ * - `ps lstart` has ~1s resolution, so we allow a small tolerance window. A
25
+ * recycled process that started within that window of the original spawn
26
+ * could theoretically slip through — vanishingly unlikely in practice.
27
+ * - If `ps` is unavailable/fails, we fall back to liveness-only and signal
28
+ * conservatively ONLY the process group (never a bare pid), accepting the
29
+ * small residual risk rather than orphaning the user's processes forever.
30
+ * - We signal the process GROUP (negative pgid) to also reap children the app
31
+ * spawned, matching the foreground SIGINT behavior. A recycled *group*
32
+ * leader is the residual risk the start-time check exists to close.
33
+ */
34
+ /** Signal-sending surface, injectable so tests never touch real processes. */
35
+ export interface SignalFns {
36
+ /**
37
+ * Mirror of `process.kill`. Sending signal `0` performs a liveness probe
38
+ * (throws ESRCH if the target does not exist). A negative pid targets a
39
+ * process group.
40
+ */
41
+ kill: (pid: number, signal: NodeJS.Signals | 0) => void;
42
+ /**
43
+ * Returns the live process's start time (epoch ms) for `pid`, or null if the
44
+ * process is gone or the start time can't be determined.
45
+ */
46
+ startTime: (pid: number) => Promise<number | null>;
47
+ }
48
+ /** Outcome of attempting to stop one recorded process, for logging/tests. */
49
+ export type StopOutcome = {
50
+ component: string;
51
+ result: "stopped";
52
+ } | {
53
+ component: string;
54
+ result: "already-dead";
55
+ } | {
56
+ component: string;
57
+ result: "identity-mismatch";
58
+ } | {
59
+ component: string;
60
+ result: "error";
61
+ error: string;
62
+ };
63
+ /** A short-lived recorded process, structurally matching ProcessState. */
64
+ export interface RecordedProcess {
65
+ pid: number;
66
+ pgid: number;
67
+ startedAt: string;
68
+ command: string;
69
+ }
70
+ /** Default real implementation backed by `process.kill` and `ps`. */
71
+ export declare const realSignalFns: SignalFns;
72
+ /**
73
+ * Verify a recorded process is still the one we started.
74
+ *
75
+ * Returns:
76
+ * - "alive-verified": process exists AND start time is consistent → safe to signal
77
+ * - "alive-unverified": process exists but start time couldn't be read → signal group only, cautiously
78
+ * - "dead": no such process (ESRCH) → already stopped, skip
79
+ * - "mismatch": process exists but started too late → recycled pid, DO NOT signal
80
+ */
81
+ export declare function checkIdentity(rec: RecordedProcess, fns: SignalFns): Promise<"alive-verified" | "alive-unverified" | "dead" | "mismatch">;
82
+ /**
83
+ * Stop one recorded process: graceful SIGTERM to the group, then escalate to
84
+ * SIGKILL after `graceMs` if it's still alive. Always targets the process GROUP
85
+ * (negative pgid) so child processes the app spawned die too.
86
+ */
87
+ export declare function stopProcess(component: string, rec: RecordedProcess, fns: SignalFns, opts?: {
88
+ graceMs?: number;
89
+ sleep?: (ms: number) => Promise<void>;
90
+ }): Promise<StopOutcome>;
91
+ /**
92
+ * Stop every recorded process. Pure orchestration over `stopProcess`; returns
93
+ * one outcome per component so the caller can report and tests can assert.
94
+ */
95
+ export declare function stopRecordedProcesses(processes: Record<string, RecordedProcess>, fns?: SignalFns, opts?: {
96
+ graceMs?: number;
97
+ sleep?: (ms: number) => Promise<void>;
98
+ }): Promise<StopOutcome[]>;
99
+ //# sourceMappingURL=process-stopper.d.ts.map
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Cross-session app-process termination for `launch down`.
3
+ *
4
+ * The foreground `launch up` session kills its children directly via the
5
+ * ProcessManager on Ctrl+C. But `launch down` may run from a *different* shell
6
+ * (or after the up-session has exited), so it has no live ChildProcess handles —
7
+ * only the pids recorded in `.launchfile/state.json`. This module signals those
8
+ * recorded process groups, with liveness + identity checks to avoid killing an
9
+ * unrelated process that happens to have inherited a recycled pid.
10
+ *
11
+ * ## pid-reuse identity guarantee (read this before trusting the kill)
12
+ *
13
+ * macOS recycles pids. A pid we recorded at `up` time may, by `down` time,
14
+ * belong to a completely unrelated process. Before sending any signal we:
15
+ *
16
+ * 1. **Liveness** — `process.kill(pid, 0)` throws ESRCH if no such process
17
+ * exists. If it's dead, we skip (already stopped).
18
+ * 2. **Identity** — we compare the recorded spawn time against the live
19
+ * process's actual start time via `ps -o lstart= -p <pid>`. If the live
20
+ * process started meaningfully later than we recorded, the pid was
21
+ * recycled and we REFUSE to signal it.
22
+ *
23
+ * This is a best-effort guarantee, not a cryptographic one. Its honest limits:
24
+ * - `ps lstart` has ~1s resolution, so we allow a small tolerance window. A
25
+ * recycled process that started within that window of the original spawn
26
+ * could theoretically slip through — vanishingly unlikely in practice.
27
+ * - If `ps` is unavailable/fails, we fall back to liveness-only and signal
28
+ * conservatively ONLY the process group (never a bare pid), accepting the
29
+ * small residual risk rather than orphaning the user's processes forever.
30
+ * - We signal the process GROUP (negative pgid) to also reap children the app
31
+ * spawned, matching the foreground SIGINT behavior. A recycled *group*
32
+ * leader is the residual risk the start-time check exists to close.
33
+ */
34
+ import { execFile } from "node:child_process";
35
+ /**
36
+ * Identity tolerance: how much later than `startedAt` a live process may report
37
+ * having started before we treat it as a recycled pid. `ps lstart` rounds to
38
+ * whole seconds and there's scheduling slop between our `Date.now()` snapshot
39
+ * and the kernel's recorded start, so we allow a few seconds of forward drift.
40
+ */
41
+ const START_TIME_TOLERANCE_MS = 3000;
42
+ /** Default real implementation backed by `process.kill` and `ps`. */
43
+ export const realSignalFns = {
44
+ kill: (pid, signal) => {
45
+ process.kill(pid, signal);
46
+ },
47
+ startTime: (pid) => queryStartTime(pid),
48
+ };
49
+ /**
50
+ * Query a process's start time via `ps -o lstart= -p <pid>` using array args
51
+ * (no shell, no interpolation). Returns epoch ms, or null on any failure.
52
+ */
53
+ function queryStartTime(pid) {
54
+ return new Promise((resolve) => {
55
+ execFile("ps", ["-o", "lstart=", "-p", String(pid)], (error, stdout) => {
56
+ if (error) {
57
+ resolve(null);
58
+ return;
59
+ }
60
+ const text = stdout.trim();
61
+ if (!text) {
62
+ resolve(null);
63
+ return;
64
+ }
65
+ const parsed = Date.parse(text);
66
+ resolve(Number.isNaN(parsed) ? null : parsed);
67
+ });
68
+ });
69
+ }
70
+ /**
71
+ * Verify a recorded process is still the one we started.
72
+ *
73
+ * Returns:
74
+ * - "alive-verified": process exists AND start time is consistent → safe to signal
75
+ * - "alive-unverified": process exists but start time couldn't be read → signal group only, cautiously
76
+ * - "dead": no such process (ESRCH) → already stopped, skip
77
+ * - "mismatch": process exists but started too late → recycled pid, DO NOT signal
78
+ */
79
+ export async function checkIdentity(rec, fns) {
80
+ // Liveness probe.
81
+ try {
82
+ fns.kill(rec.pid, 0);
83
+ }
84
+ catch (err) {
85
+ if (isErrno(err) && err.code === "ESRCH")
86
+ return "dead";
87
+ // EPERM means it exists but we can't signal it — treat as alive-unverified.
88
+ if (isErrno(err) && err.code === "EPERM")
89
+ return "alive-unverified";
90
+ return "dead";
91
+ }
92
+ const liveStart = await fns.startTime(rec.pid);
93
+ if (liveStart === null)
94
+ return "alive-unverified";
95
+ const recordedStart = Date.parse(rec.startedAt);
96
+ if (Number.isNaN(recordedStart))
97
+ return "alive-unverified";
98
+ // If the live process started meaningfully *after* we recorded the spawn,
99
+ // the original exited and the pid was recycled. Refuse to signal it.
100
+ if (liveStart > recordedStart + START_TIME_TOLERANCE_MS)
101
+ return "mismatch";
102
+ return "alive-verified";
103
+ }
104
+ /**
105
+ * Stop one recorded process: graceful SIGTERM to the group, then escalate to
106
+ * SIGKILL after `graceMs` if it's still alive. Always targets the process GROUP
107
+ * (negative pgid) so child processes the app spawned die too.
108
+ */
109
+ export async function stopProcess(component, rec, fns, opts = {}) {
110
+ const graceMs = opts.graceMs ?? 5000;
111
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
112
+ const identity = await checkIdentity(rec, fns);
113
+ if (identity === "dead")
114
+ return { component, result: "already-dead" };
115
+ if (identity === "mismatch")
116
+ return { component, result: "identity-mismatch" };
117
+ // Target the process group. pgid is the leader pid because we spawned detached.
118
+ const groupTarget = -rec.pgid;
119
+ try {
120
+ fns.kill(groupTarget, "SIGTERM");
121
+ }
122
+ catch (err) {
123
+ if (isErrno(err) && err.code === "ESRCH")
124
+ return { component, result: "already-dead" };
125
+ return { component, result: "error", error: errMessage(err) };
126
+ }
127
+ // Wait for graceful exit, then escalate if the leader is still alive.
128
+ await sleep(graceMs);
129
+ let stillAlive = true;
130
+ try {
131
+ fns.kill(rec.pid, 0);
132
+ }
133
+ catch {
134
+ stillAlive = false;
135
+ }
136
+ if (stillAlive) {
137
+ try {
138
+ fns.kill(groupTarget, "SIGKILL");
139
+ }
140
+ catch (err) {
141
+ if (!(isErrno(err) && err.code === "ESRCH")) {
142
+ return { component, result: "error", error: errMessage(err) };
143
+ }
144
+ }
145
+ }
146
+ return { component, result: "stopped" };
147
+ }
148
+ /**
149
+ * Stop every recorded process. Pure orchestration over `stopProcess`; returns
150
+ * one outcome per component so the caller can report and tests can assert.
151
+ */
152
+ export async function stopRecordedProcesses(processes, fns = realSignalFns, opts = {}) {
153
+ const outcomes = [];
154
+ for (const [component, rec] of Object.entries(processes)) {
155
+ outcomes.push(await stopProcess(component, rec, fns, opts));
156
+ }
157
+ return outcomes;
158
+ }
159
+ function isErrno(err) {
160
+ return typeof err === "object" && err !== null && "code" in err;
161
+ }
162
+ function errMessage(err) {
163
+ return err instanceof Error ? err.message : String(err);
164
+ }
165
+ //# sourceMappingURL=process-stopper.js.map
@@ -4,12 +4,30 @@
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";
8
+ /**
9
+ * Source-mode run resolution (D-38, precedence `dev` > `image` > `start`).
10
+ * This provider runs apps from source. A component is source-runnable when it
11
+ * declares `dev`, or a `start` with no `image` — an `image` without a `dev`
12
+ * override stays artifact-mode, which this source-only provider can't launch.
13
+ */
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;
7
17
  export interface LaunchUpOpts {
8
18
  withOptional?: boolean;
9
19
  noBuild?: boolean;
10
20
  detach?: boolean;
11
21
  dryRun?: boolean;
12
22
  projectDir?: string;
23
+ /**
24
+ * Component selector (#77): if non-empty, these components plus their
25
+ * transitive downward `depends_on` closure are started (D-41), and the
26
+ * `requires` of every closure member are provisioned. The start-set is the
27
+ * SDK's `selectionClosure` — the same definition the Docker provider uses —
28
+ * so both yield the identical running topology (P-5). Empty = all components.
29
+ */
30
+ components?: string[];
13
31
  }
14
32
  export declare function launchUp(opts?: LaunchUpOpts): Promise<void>;
15
33
  export declare function launchDown(opts?: {