@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 +3 -0
- package/dist/env-writer.d.ts +8 -5
- package/dist/env-writer.js +19 -10
- package/dist/process-manager.d.ts +13 -0
- package/dist/process-manager.js +57 -2
- package/dist/process-stopper.d.ts +99 -0
- package/dist/process-stopper.js +165 -0
- package/dist/provider.d.ts +18 -0
- package/dist/provider.js +148 -24
- package/dist/shell.js +1 -1
- package/dist/state.d.ts +28 -0
- package/dist/storage.d.ts +16 -3
- package/dist/storage.js +23 -6
- package/package.json +3 -3
- 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,24 @@
|
|
|
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, 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
|
+
}
|
|
10
27
|
import { checkPrereqs } from "./prereqs.js";
|
|
11
28
|
import { loadState, initState, saveState, ensureDirs } from "./state.js";
|
|
12
29
|
import { buildResolverContext, computeAppProperties, resolveComponentEnv, generateSecrets, resolveGenerators, writeEnvFile, } from "./env-writer.js";
|
|
@@ -14,9 +31,11 @@ import { getProvisioner } from "./resources/index.js";
|
|
|
14
31
|
import { allocatePorts } from "./port-allocator.js";
|
|
15
32
|
import { getRuntimeInstaller } from "./runtimes/index.js";
|
|
16
33
|
import { detectPackageManager } from "./lockfile-detect.js";
|
|
17
|
-
import { provisionStorage } from "./storage.js";
|
|
34
|
+
import { provisionStorage, storagePaths } from "./storage.js";
|
|
18
35
|
import { ProcessManager } from "./process-manager.js";
|
|
36
|
+
import { stopRecordedProcesses } from "./process-stopper.js";
|
|
19
37
|
import { shell } from "./shell.js";
|
|
38
|
+
import { parseDuration } from "./bootstrap.js";
|
|
20
39
|
export async function launchUp(opts = {}) {
|
|
21
40
|
const projectDir = opts.projectDir ?? process.cwd();
|
|
22
41
|
// 1. Check prerequisites
|
|
@@ -38,7 +57,52 @@ export async function launchUp(opts = {}) {
|
|
|
38
57
|
process.exit(1);
|
|
39
58
|
}
|
|
40
59
|
const launch = readLaunch(launchfileContent);
|
|
60
|
+
const allComponentNames = Object.keys(launch.components);
|
|
61
|
+
// Resolve component selector (#77) into its D-41 start-set: selected
|
|
62
|
+
// components + their transitive downward `depends_on` closure (and, by
|
|
63
|
+
// narrowing to that set, every closure member's `requires`). Narrowing
|
|
64
|
+
// launch.components here makes every downstream phase loop in launchUp honor
|
|
65
|
+
// the closure without per-loop edits — dropping it would hand back an app
|
|
66
|
+
// missing the very dependencies a selected component needs to start (D-16).
|
|
67
|
+
// down/status/env re-read the file, so they are unaffected.
|
|
68
|
+
const selection = selectionClosure(launch, opts.components ?? []);
|
|
69
|
+
if (selection.unknown.length > 0 || selection.resources.length > 0) {
|
|
70
|
+
console.error(`\nCannot select: ${[...selection.unknown, ...selection.resources].join(", ")}`);
|
|
71
|
+
for (const r of selection.resources) {
|
|
72
|
+
console.error(` - "${r}" is a backing resource, not a component; select the component that requires it.`);
|
|
73
|
+
}
|
|
74
|
+
for (const u of selection.unknown) {
|
|
75
|
+
console.error(` - "${u}" matches no component. Available: ${allComponentNames.join(", ")}`);
|
|
76
|
+
}
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
if (opts.components && opts.components.length > 0) {
|
|
80
|
+
const startSet = new Set(selection.start);
|
|
81
|
+
launch.components = Object.fromEntries(Object.entries(launch.components).filter(([n]) => startSet.has(n)));
|
|
82
|
+
}
|
|
41
83
|
const componentNames = Object.keys(launch.components);
|
|
84
|
+
// 2b. Source-mode guard (D-38) — fail fast before provisioning anything.
|
|
85
|
+
// Run precedence is `dev` > `image` > `start`: a component runs from source
|
|
86
|
+
// when it declares `dev`, or a `start` with no `image`. An `image` without a
|
|
87
|
+
// `dev` override stays artifact-mode — which this source-only provider can't
|
|
88
|
+
// launch. If nothing is source-runnable, the app belongs on `launchfile up`.
|
|
89
|
+
const sourceRunnable = Object.values(launch.components).filter(isSourceRunnable);
|
|
90
|
+
if (sourceRunnable.length === 0) {
|
|
91
|
+
const hasImage = Object.values(launch.components).some((c) => c.image);
|
|
92
|
+
console.error("Nothing to run from source: no component declares `dev` (or a `start` without an `image`).");
|
|
93
|
+
console.error(hasImage
|
|
94
|
+
? "This app runs from an image — use `launchfile up` to launch the built artifact."
|
|
95
|
+
: "Add a `dev` (or `start`) command to run it from source.");
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
// Mixed app: warn about artifact components (image, no `dev` override) that
|
|
99
|
+
// this source-only provider can't launch — they need `launchfile up`.
|
|
100
|
+
for (const [name, c] of Object.entries(launch.components)) {
|
|
101
|
+
if (!isSourceRunnable(c) && c.image) {
|
|
102
|
+
console.warn(` ! [${name}] has an image and no \`dev\` override — runs as an artifact, ` +
|
|
103
|
+
"skipped in source mode; use `launchfile up` to run it.");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
42
106
|
// 3. Load or init state
|
|
43
107
|
let state = await loadState(projectDir);
|
|
44
108
|
if (!state) {
|
|
@@ -134,15 +198,22 @@ export async function launchUp(opts = {}) {
|
|
|
134
198
|
}
|
|
135
199
|
// 10. Detect package manager
|
|
136
200
|
const pm = await detectPackageManager(projectDir);
|
|
137
|
-
// 11. Create storage directories
|
|
201
|
+
// 11. Create storage directories, capturing each volume's resolved local path
|
|
202
|
+
// so it can be injected as $storage.<name>.path (D-39). Scoped per component.
|
|
203
|
+
const componentStorage = {};
|
|
138
204
|
for (const [name, component] of Object.entries(launch.components)) {
|
|
139
|
-
await provisionStorage(component.storage, name, projectDir);
|
|
205
|
+
const volumeMap = await provisionStorage(component.storage, name, projectDir);
|
|
206
|
+
const storageCtx = {};
|
|
207
|
+
for (const [volName, localPath] of Object.entries(volumeMap)) {
|
|
208
|
+
storageCtx[volName] = { path: localPath };
|
|
209
|
+
}
|
|
210
|
+
componentStorage[name] = storageCtx;
|
|
140
211
|
}
|
|
141
212
|
// 12. Resolve env vars and write .env files
|
|
142
213
|
const allEnvs = {};
|
|
143
214
|
const isSingleComponent = componentNames.length === 1 && componentNames[0] === "default";
|
|
144
215
|
for (const [name, component] of Object.entries(launch.components)) {
|
|
145
|
-
const env = resolveComponentEnv(component, context, resourceMap);
|
|
216
|
+
const env = resolveComponentEnv(component, context, resourceMap, componentStorage[name]);
|
|
146
217
|
await resolveGenerators(component, env);
|
|
147
218
|
const port = componentPorts[name];
|
|
148
219
|
if (port && !env.PORT) {
|
|
@@ -171,62 +242,75 @@ export async function launchUp(opts = {}) {
|
|
|
171
242
|
printSummary(launch, componentPorts, resourceMap);
|
|
172
243
|
return;
|
|
173
244
|
}
|
|
174
|
-
// 14. Run build
|
|
245
|
+
// 14. Run source-mode prepare \u2014 `install ?? build` (D-38), on demand
|
|
175
246
|
if (!opts.noBuild) {
|
|
176
247
|
for (const [name, component] of Object.entries(launch.components)) {
|
|
177
|
-
const
|
|
178
|
-
const cmd =
|
|
248
|
+
const prepare = component.commands?.install ?? component.commands?.build;
|
|
249
|
+
const cmd = prepare?.command ?? pm?.installCommand;
|
|
179
250
|
if (cmd) {
|
|
180
|
-
console.log(` \u2193
|
|
251
|
+
console.log(` \u2193 Preparing${componentNames.length > 1 ? ` [${name}]` : ""}...`);
|
|
181
252
|
await shell(cmd, {
|
|
182
|
-
cwd: projectDir,
|
|
253
|
+
cwd: join(projectDir, component.source ?? component.build?.context ?? "."),
|
|
183
254
|
env: allEnvs[name],
|
|
255
|
+
// Installs/compiles routinely exceed the 2-minute shell default;
|
|
256
|
+
// honor a declared timeout, else allow 10 minutes.
|
|
257
|
+
timeout: prepare?.timeout ? parseDuration(prepare.timeout) : 600_000,
|
|
184
258
|
});
|
|
185
259
|
}
|
|
186
260
|
}
|
|
187
261
|
}
|
|
188
|
-
// 15. Run release commands (migrations)
|
|
262
|
+
// 15. Run release commands (migrations) \u2014 mode-invariant (D-38)
|
|
189
263
|
for (const [name, component] of Object.entries(launch.components)) {
|
|
190
|
-
const
|
|
191
|
-
if (
|
|
264
|
+
const release = component.commands?.release;
|
|
265
|
+
if (release?.command) {
|
|
192
266
|
console.log(` \u2193 Running release${componentNames.length > 1 ? ` [${name}]` : ""}...`);
|
|
193
|
-
await shell(
|
|
194
|
-
cwd: projectDir,
|
|
267
|
+
await shell(release.command, {
|
|
268
|
+
cwd: join(projectDir, component.source ?? component.build?.context ?? "."),
|
|
195
269
|
env: allEnvs[name],
|
|
270
|
+
timeout: release.timeout ? parseDuration(release.timeout) : undefined,
|
|
196
271
|
});
|
|
197
272
|
}
|
|
198
273
|
}
|
|
199
|
-
// 16.
|
|
274
|
+
// 16. Run components from source \u2014 `dev` over `start` (D-38; this provider ignores `image`)
|
|
200
275
|
process.stdout.write(` \u2193 Starting services...`);
|
|
201
276
|
const pm2 = new ProcessManager(projectDir);
|
|
202
277
|
for (const [name, component] of Object.entries(launch.components)) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
278
|
+
// Resolve the source-mode run command (D-38 precedence `dev` > `image` >
|
|
279
|
+
// `start`). Artifact components (image, no `dev` override) resolve to
|
|
280
|
+
// undefined — they were warned by the guard; skip them.
|
|
281
|
+
const startCmd = sourceRunCommand(component);
|
|
282
|
+
if (!startCmd)
|
|
206
283
|
continue;
|
|
207
|
-
}
|
|
208
284
|
pm2.register(name, {
|
|
209
285
|
command: startCmd,
|
|
210
286
|
env: { ...process.env, ...allEnvs[name] },
|
|
211
|
-
cwd: projectDir,
|
|
287
|
+
cwd: join(projectDir, component.source ?? component.build?.context ?? "."),
|
|
212
288
|
dependsOn: component.depends_on,
|
|
213
289
|
health: component.health,
|
|
214
290
|
port: componentPorts[name],
|
|
215
291
|
});
|
|
216
292
|
}
|
|
217
293
|
// Handle Ctrl+C gracefully
|
|
294
|
+
const finalState = state;
|
|
218
295
|
process.on("SIGINT", async () => {
|
|
219
296
|
console.log("\n\nShutting down...");
|
|
220
297
|
await pm2.stopAll();
|
|
221
|
-
|
|
298
|
+
// Processes are now dead; clear the recorded pids so a later `launch down`
|
|
299
|
+
// doesn't try to signal stale (and possibly recycled) pids.
|
|
300
|
+
finalState.processes = {};
|
|
301
|
+
await saveState(projectDir, finalState);
|
|
222
302
|
process.exit(0);
|
|
223
303
|
});
|
|
224
304
|
await pm2.startAll();
|
|
225
305
|
console.log("");
|
|
226
306
|
console.log(` \u2713 All components started`);
|
|
307
|
+
// Record spawned pids so `launch down` can stop them from another shell or
|
|
308
|
+
// after this foreground session ends (closes #49). Backward compatible: the
|
|
309
|
+
// field is optional and absent in pre-existing state files.
|
|
310
|
+
state.processes = pm2.getRecordedProcesses();
|
|
227
311
|
// 17. Print summary
|
|
228
312
|
printSummary(launch, componentPorts, resourceMap);
|
|
229
|
-
// Save final state
|
|
313
|
+
// Save final state (now including recorded pids)
|
|
230
314
|
await saveState(projectDir, state);
|
|
231
315
|
}
|
|
232
316
|
function printSummary(launch, ports, _resources) {
|
|
@@ -244,6 +328,35 @@ export async function launchDown(opts = {}) {
|
|
|
244
328
|
console.log("No active launch state found.");
|
|
245
329
|
return;
|
|
246
330
|
}
|
|
331
|
+
// Stop recorded app processes (closes #49). Backward compatible: state files
|
|
332
|
+
// written before pid persistence simply have no `processes`, so we skip this
|
|
333
|
+
// and behave exactly as before (resources-only down).
|
|
334
|
+
const recorded = state.processes ?? {};
|
|
335
|
+
if (Object.keys(recorded).length > 0) {
|
|
336
|
+
console.log("Stopping app processes...");
|
|
337
|
+
const outcomes = await stopRecordedProcesses(recorded);
|
|
338
|
+
for (const o of outcomes) {
|
|
339
|
+
switch (o.result) {
|
|
340
|
+
case "stopped":
|
|
341
|
+
console.log(` Stopped ${o.component}`);
|
|
342
|
+
break;
|
|
343
|
+
case "already-dead":
|
|
344
|
+
console.log(` ${o.component} was not running`);
|
|
345
|
+
break;
|
|
346
|
+
case "identity-mismatch":
|
|
347
|
+
console.log(` Skipped ${o.component} (pid recycled — left untouched)`);
|
|
348
|
+
break;
|
|
349
|
+
case "error":
|
|
350
|
+
console.log(` Failed to stop ${o.component}: ${o.error}`);
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// Clear recorded pids now that we've handled them.
|
|
355
|
+
state.processes = {};
|
|
356
|
+
if (!opts.destroy) {
|
|
357
|
+
await saveState(projectDir, state);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
247
360
|
if (opts.destroy) {
|
|
248
361
|
console.log("Destroying resources...");
|
|
249
362
|
for (const [name, resourceState] of Object.entries(state.resources)) {
|
|
@@ -262,6 +375,11 @@ export async function launchDown(opts = {}) {
|
|
|
262
375
|
console.log("Stopped. Resources are still running (use --destroy to remove them).");
|
|
263
376
|
}
|
|
264
377
|
}
|
|
378
|
+
// --detach is intentionally left as a follow-up: persisting pids (this PR) is
|
|
379
|
+
// the prerequisite for it. With pids now recorded and a working cross-session
|
|
380
|
+
// `down`, detach becomes "spawn detached + unref + don't install the SIGINT
|
|
381
|
+
// foreground loop, then return" — a self-contained change best done separately
|
|
382
|
+
// so the kill-path fix lands reviewable on its own.
|
|
265
383
|
export async function launchStatus(opts = {}) {
|
|
266
384
|
const projectDir = opts.projectDir ?? process.cwd();
|
|
267
385
|
const state = await loadState(projectDir);
|
|
@@ -310,7 +428,13 @@ export async function launchEnv(opts = {}) {
|
|
|
310
428
|
for (const [name, component] of Object.entries(launch.components)) {
|
|
311
429
|
if (opts.component && name !== opts.component)
|
|
312
430
|
continue;
|
|
313
|
-
|
|
431
|
+
// Resolved storage paths (D-39) — computed, not provisioned (no mkdir):
|
|
432
|
+
// `launchfile env` only prints, and the dirs already exist from `up`.
|
|
433
|
+
const storageCtx = {};
|
|
434
|
+
for (const [volName, localPath] of Object.entries(storagePaths(component.storage, name, projectDir))) {
|
|
435
|
+
storageCtx[volName] = { path: localPath };
|
|
436
|
+
}
|
|
437
|
+
const env = resolveComponentEnv(component, context, resourceMap, storageCtx);
|
|
314
438
|
await resolveGenerators(component, env);
|
|
315
439
|
const port = state.ports[name];
|
|
316
440
|
if (port && !env.PORT)
|
package/dist/shell.js
CHANGED
|
@@ -19,7 +19,7 @@ export async function shell(command, opts = {}) {
|
|
|
19
19
|
return new Promise((resolve, reject) => {
|
|
20
20
|
cpExec(command, execOpts, (error, stdout, stderr) => {
|
|
21
21
|
const result = {
|
|
22
|
-
exitCode:
|
|
22
|
+
exitCode: typeof error?.code === "number" ? error.code : 0,
|
|
23
23
|
stdout: typeof stdout === "string" ? stdout : "",
|
|
24
24
|
stderr: typeof stderr === "string" ? stderr : "",
|
|
25
25
|
};
|
package/dist/state.d.ts
CHANGED
|
@@ -13,6 +13,28 @@ export interface ResourceState {
|
|
|
13
13
|
user?: string;
|
|
14
14
|
password?: string;
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Records a spawned app component process so `launch down` can stop it
|
|
18
|
+
* from a different shell or after the foreground `launch up` session ends.
|
|
19
|
+
*
|
|
20
|
+
* Identity is captured at spawn time so `down` can re-verify the recorded
|
|
21
|
+
* pid still belongs to the process we started before signaling it — guarding
|
|
22
|
+
* against pid-reuse (a recycled pid belonging to an unrelated process).
|
|
23
|
+
*/
|
|
24
|
+
export interface ProcessState {
|
|
25
|
+
/** Leader pid of the spawned shell (`sh -c <command>`). */
|
|
26
|
+
pid: number;
|
|
27
|
+
/**
|
|
28
|
+
* Process group id. Because we spawn detached, the child is its own group
|
|
29
|
+
* leader, so pgid === pid. We persist it explicitly so `down` can signal the
|
|
30
|
+
* whole group (negative pid) and take down child processes too.
|
|
31
|
+
*/
|
|
32
|
+
pgid: number;
|
|
33
|
+
/** ISO timestamp captured immediately after spawn — part of the identity check. */
|
|
34
|
+
startedAt: string;
|
|
35
|
+
/** The shell command string we launched — used as a best-effort identity cross-check. */
|
|
36
|
+
command: string;
|
|
37
|
+
}
|
|
16
38
|
export interface LaunchState {
|
|
17
39
|
version: 1;
|
|
18
40
|
appName: string;
|
|
@@ -22,6 +44,12 @@ export interface LaunchState {
|
|
|
22
44
|
resources: Record<string, ResourceState>;
|
|
23
45
|
secrets: Record<string, string>;
|
|
24
46
|
ports: Record<string, number>;
|
|
47
|
+
/**
|
|
48
|
+
* App component processes recorded at spawn time, keyed by component name.
|
|
49
|
+
* Optional for backward compatibility: state files written before pid
|
|
50
|
+
* persistence existed simply omit this, and `down` tolerates its absence.
|
|
51
|
+
*/
|
|
52
|
+
processes?: Record<string, ProcessState>;
|
|
25
53
|
}
|
|
26
54
|
export declare function hashLaunchfile(content: string): string;
|
|
27
55
|
/** Load state from disk, or return null if none exists */
|
package/dist/storage.d.ts
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Local storage provisioning for Launchfile volumes.
|
|
3
3
|
*
|
|
4
|
-
* Maps
|
|
4
|
+
* Maps named volumes to local directories under .launchfile/, and exposes the
|
|
5
|
+
* resolved local path per volume so the provider can inject it as
|
|
6
|
+
* `$storage.<name>.path` (D-39).
|
|
5
7
|
*/
|
|
6
8
|
import type { StorageVolume } from "@launchfile/sdk";
|
|
7
9
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
+
* Compute the local path this provider uses for each of a component's storage
|
|
11
|
+
* volumes — keyed by **volume name** (the `storage:` key), which is what
|
|
12
|
+
* `$storage.<name>.path` resolves against (D-39). Pure: no filesystem side
|
|
13
|
+
* effects, so callers that only need the resolved paths (e.g. `launchfile env`)
|
|
14
|
+
* can use it without provisioning. Persistent volumes live under
|
|
15
|
+
* `.launchfile/storage/<component>/<name>`; ephemeral ones under `.../tmp/...`.
|
|
16
|
+
*/
|
|
17
|
+
export declare function storagePaths(storage: Record<string, StorageVolume> | undefined, componentName: string, projectDir: string): Record<string, string>;
|
|
18
|
+
/**
|
|
19
|
+
* Create the local directories for a component's storage volumes and return the
|
|
20
|
+
* volume-name → local-path map (the home-#3 value the resolver injects as
|
|
21
|
+
* `$storage.<name>.path`, D-39). Previously the return was keyed by container
|
|
22
|
+
* path and discarded at the call site — the path is now captured and delivered.
|
|
10
23
|
*/
|
|
11
24
|
export declare function provisionStorage(storage: Record<string, StorageVolume> | undefined, componentName: string, projectDir: string): Promise<Record<string, string>>;
|
|
12
25
|
/** Clean up ephemeral (non-persistent) storage for a component */
|
package/dist/storage.js
CHANGED
|
@@ -1,25 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Local storage provisioning for Launchfile volumes.
|
|
3
3
|
*
|
|
4
|
-
* Maps
|
|
4
|
+
* Maps named volumes to local directories under .launchfile/, and exposes the
|
|
5
|
+
* resolved local path per volume so the provider can inject it as
|
|
6
|
+
* `$storage.<name>.path` (D-39).
|
|
5
7
|
*/
|
|
6
8
|
import { mkdir, rm } from "node:fs/promises";
|
|
7
9
|
import { join } from "node:path";
|
|
8
10
|
const STATE_DIR = ".launchfile";
|
|
9
11
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
+
* Compute the local path this provider uses for each of a component's storage
|
|
13
|
+
* volumes — keyed by **volume name** (the `storage:` key), which is what
|
|
14
|
+
* `$storage.<name>.path` resolves against (D-39). Pure: no filesystem side
|
|
15
|
+
* effects, so callers that only need the resolved paths (e.g. `launchfile env`)
|
|
16
|
+
* can use it without provisioning. Persistent volumes live under
|
|
17
|
+
* `.launchfile/storage/<component>/<name>`; ephemeral ones under `.../tmp/...`.
|
|
12
18
|
*/
|
|
13
|
-
export
|
|
19
|
+
export function storagePaths(storage, componentName, projectDir) {
|
|
14
20
|
if (!storage)
|
|
15
21
|
return {};
|
|
16
22
|
const volumeMap = {};
|
|
17
23
|
for (const [name, volume] of Object.entries(storage)) {
|
|
18
24
|
const persistent = volume.persistent !== false; // default true
|
|
19
25
|
const subdir = persistent ? "storage" : "tmp";
|
|
20
|
-
|
|
26
|
+
volumeMap[name] = join(projectDir, STATE_DIR, subdir, componentName, name);
|
|
27
|
+
}
|
|
28
|
+
return volumeMap;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Create the local directories for a component's storage volumes and return the
|
|
32
|
+
* volume-name → local-path map (the home-#3 value the resolver injects as
|
|
33
|
+
* `$storage.<name>.path`, D-39). Previously the return was keyed by container
|
|
34
|
+
* path and discarded at the call site — the path is now captured and delivered.
|
|
35
|
+
*/
|
|
36
|
+
export async function provisionStorage(storage, componentName, projectDir) {
|
|
37
|
+
const volumeMap = storagePaths(storage, componentName, projectDir);
|
|
38
|
+
for (const localPath of Object.values(volumeMap)) {
|
|
21
39
|
await mkdir(localPath, { recursive: true });
|
|
22
|
-
volumeMap[volume.path] = localPath;
|
|
23
40
|
}
|
|
24
41
|
return volumeMap;
|
|
25
42
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@launchfile/macos-dev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "macOS dev provider for Launchfile — run apps locally via brew services and native runtimes",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
8
8
|
"scripts": {
|
|
9
|
-
"build": "tsc",
|
|
9
|
+
"build": "tsc -p tsconfig.build.json",
|
|
10
10
|
"test": "vitest run",
|
|
11
11
|
"test:watch": "vitest",
|
|
12
12
|
"typecheck": "tsc --noEmit",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"directory": "providers/macos-dev"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@launchfile/sdk": "^0.
|
|
36
|
+
"@launchfile/sdk": "^0.3.0",
|
|
37
37
|
"semver": "^7.7.4"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { extractCaptures, parseDuration } from "../bootstrap.js";
|
|
3
|
-
describe("extractCaptures (D-34)", () => {
|
|
4
|
-
it("extracts a single-group regex from stdout", () => {
|
|
5
|
-
const captures = {
|
|
6
|
-
invite_link: {
|
|
7
|
-
pattern: "https?://\\S+",
|
|
8
|
-
description: "One-time invite link",
|
|
9
|
-
sensitive: true,
|
|
10
|
-
},
|
|
11
|
-
};
|
|
12
|
-
const stdout = "Admin created.\nInvite URL: https://example.com/invite/abc123\n";
|
|
13
|
-
expect(extractCaptures(stdout, captures)).toEqual({
|
|
14
|
-
invite_link: "https://example.com/invite/abc123",
|
|
15
|
-
});
|
|
16
|
-
});
|
|
17
|
-
it("uses first capture group when present", () => {
|
|
18
|
-
const captures = {
|
|
19
|
-
password: {
|
|
20
|
-
pattern: "Admin password: (.+)",
|
|
21
|
-
},
|
|
22
|
-
};
|
|
23
|
-
const stdout = "Admin password: s3cret!\n";
|
|
24
|
-
expect(extractCaptures(stdout, captures)).toEqual({
|
|
25
|
-
password: "s3cret!",
|
|
26
|
-
});
|
|
27
|
-
});
|
|
28
|
-
it("returns empty result when no patterns match", () => {
|
|
29
|
-
const captures = {
|
|
30
|
-
token: { pattern: "token=(\\S+)" },
|
|
31
|
-
};
|
|
32
|
-
expect(extractCaptures("nothing to see here", captures)).toEqual({});
|
|
33
|
-
});
|
|
34
|
-
it("strips ANSI escape codes before matching", () => {
|
|
35
|
-
const captures = {
|
|
36
|
-
url: { pattern: "https?://\\S+" },
|
|
37
|
-
};
|
|
38
|
-
// Simulates colorized output from a CLI tool that detects a TTY
|
|
39
|
-
const stdout = "\x1b[32mSuccess:\x1b[0m visit \x1b[4mhttps://example.com/admin\x1b[0m to continue";
|
|
40
|
-
expect(extractCaptures(stdout, captures)).toEqual({
|
|
41
|
-
url: "https://example.com/admin",
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
it("skips invalid regex without throwing", () => {
|
|
45
|
-
const captures = {
|
|
46
|
-
bad: { pattern: "(unclosed" },
|
|
47
|
-
good: { pattern: "ok" },
|
|
48
|
-
};
|
|
49
|
-
const result = extractCaptures("ok here", captures);
|
|
50
|
-
// good matches; bad is skipped
|
|
51
|
-
expect(result.good).toBe("ok");
|
|
52
|
-
expect(result.bad).toBeUndefined();
|
|
53
|
-
});
|
|
54
|
-
it("handles multiple captures in one stdout", () => {
|
|
55
|
-
const captures = {
|
|
56
|
-
user: { pattern: "user=(\\S+)" },
|
|
57
|
-
pw: { pattern: "pw=(\\S+)" },
|
|
58
|
-
};
|
|
59
|
-
const stdout = "Created user=alice pw=hunter2 expires=1h\n";
|
|
60
|
-
expect(extractCaptures(stdout, captures)).toEqual({
|
|
61
|
-
user: "alice",
|
|
62
|
-
pw: "hunter2",
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
it("returns full match when pattern has no capture group", () => {
|
|
66
|
-
const captures = {
|
|
67
|
-
hash: { pattern: "[a-f0-9]{8}" },
|
|
68
|
-
};
|
|
69
|
-
const stdout = "Commit: deadbeef\n";
|
|
70
|
-
expect(extractCaptures(stdout, captures)).toEqual({
|
|
71
|
-
hash: "deadbeef",
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
describe("parseDuration", () => {
|
|
76
|
-
it("parses ms, s, m, h units", () => {
|
|
77
|
-
expect(parseDuration("500ms")).toBe(500);
|
|
78
|
-
expect(parseDuration("30s")).toBe(30_000);
|
|
79
|
-
expect(parseDuration("5m")).toBe(300_000);
|
|
80
|
-
expect(parseDuration("2h")).toBe(7_200_000);
|
|
81
|
-
});
|
|
82
|
-
it("tolerates whitespace", () => {
|
|
83
|
-
expect(parseDuration(" 10s ")).toBe(10_000);
|
|
84
|
-
});
|
|
85
|
-
it("falls back to default for invalid input", () => {
|
|
86
|
-
expect(parseDuration("bogus")).toBe(120_000);
|
|
87
|
-
expect(parseDuration("")).toBe(120_000);
|
|
88
|
-
});
|
|
89
|
-
});
|
|
90
|
-
//# sourceMappingURL=bootstrap.test.js.map
|