@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/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 commands
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 buildCmd = component.commands?.build?.command;
178
- const cmd = buildCmd ?? pm?.installCommand;
248
+ const prepare = component.commands?.install ?? component.commands?.build;
249
+ const cmd = prepare?.command ?? pm?.installCommand;
179
250
  if (cmd) {
180
- console.log(` \u2193 Building${componentNames.length > 1 ? ` [${name}]` : ""}...`);
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 releaseCmd = component.commands?.release?.command;
191
- if (releaseCmd) {
264
+ const release = component.commands?.release;
265
+ if (release?.command) {
192
266
  console.log(` \u2193 Running release${componentNames.length > 1 ? ` [${name}]` : ""}...`);
193
- await shell(releaseCmd, {
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. Start all components
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
- const startCmd = component.commands?.start?.command;
204
- if (!startCmd) {
205
- console.warn(`\n ! [${name}] No start command — skipping`);
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
- await saveState(projectDir, state);
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
- const env = resolveComponentEnv(component, context, resourceMap);
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: error?.code ?? (typeof error?.code === "number" ? error.code : 0),
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 container paths to local directories under .launchfile/.
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
- * Create local directories for a component's storage volumes.
9
- * Returns a map of container path → local path.
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 container paths to local directories under .launchfile/.
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
- * Create local directories for a component's storage volumes.
11
- * Returns a map of container path → local path.
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 async function provisionStorage(storage, componentName, projectDir) {
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
- const localPath = join(projectDir, STATE_DIR, subdir, componentName, name);
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.2.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.2.0",
36
+ "@launchfile/sdk": "^0.3.0",
37
37
  "semver": "^7.7.4"
38
38
  },
39
39
  "devDependencies": {
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=bootstrap.test.d.ts.map
@@ -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
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=dry-run.test.d.ts.map