@launchfile/macos-dev 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/bootstrap.d.ts +56 -8
  2. package/dist/bootstrap.js +170 -93
  3. package/dist/env-writer.d.ts +40 -11
  4. package/dist/env-writer.js +84 -23
  5. package/dist/health.d.ts +8 -2
  6. package/dist/health.js +11 -17
  7. package/dist/prereqs.js +2 -2
  8. package/dist/process-manager.d.ts +13 -0
  9. package/dist/process-manager.js +59 -3
  10. package/dist/process-stopper.d.ts +99 -0
  11. package/dist/process-stopper.js +165 -0
  12. package/dist/provider.d.ts +50 -0
  13. package/dist/provider.js +337 -28
  14. package/dist/redact.d.ts +55 -0
  15. package/dist/redact.js +92 -0
  16. package/dist/resources/identifiers.d.ts +22 -0
  17. package/dist/resources/identifiers.js +31 -0
  18. package/dist/resources/index.d.ts +3 -3
  19. package/dist/resources/index.js +21 -10
  20. package/dist/resources/mysql.d.ts +3 -1
  21. package/dist/resources/mysql.js +49 -11
  22. package/dist/resources/postgres.d.ts +4 -1
  23. package/dist/resources/postgres.js +63 -17
  24. package/dist/resources/redis.d.ts +3 -1
  25. package/dist/resources/redis.js +14 -4
  26. package/dist/resources/sqlite.js +2 -2
  27. package/dist/resources/types.d.ts +21 -3
  28. package/dist/runtimes/bun.js +2 -2
  29. package/dist/runtimes/installed-versions.d.ts +12 -0
  30. package/dist/runtimes/installed-versions.js +21 -0
  31. package/dist/runtimes/node.js +13 -8
  32. package/dist/runtimes/python.js +14 -7
  33. package/dist/runtimes/ruby.js +13 -8
  34. package/dist/secret-generator.js +11 -3
  35. package/dist/shell.d.ts +28 -6
  36. package/dist/shell.js +83 -28
  37. package/dist/state.d.ts +38 -0
  38. package/dist/state.js +12 -1
  39. package/dist/storage.d.ts +16 -3
  40. package/dist/storage.js +23 -6
  41. package/package.json +7 -4
  42. package/dist/__tests__/bootstrap.test.d.ts +0 -2
  43. package/dist/__tests__/bootstrap.test.js +0 -90
  44. package/dist/__tests__/dry-run.test.d.ts +0 -2
  45. package/dist/__tests__/dry-run.test.js +0 -141
  46. package/dist/__tests__/env-writer.test.d.ts +0 -2
  47. package/dist/__tests__/env-writer.test.js +0 -201
  48. package/dist/__tests__/lockfile-detect.test.d.ts +0 -2
  49. package/dist/__tests__/lockfile-detect.test.js +0 -75
  50. package/dist/__tests__/port-allocator.test.d.ts +0 -2
  51. package/dist/__tests__/port-allocator.test.js +0 -53
  52. package/dist/__tests__/secret-generator.test.d.ts +0 -2
  53. package/dist/__tests__/secret-generator.test.js +0 -26
  54. package/dist/__tests__/state.test.d.ts +0 -2
  55. package/dist/__tests__/state.test.js +0 -30
package/dist/shell.d.ts CHANGED
@@ -1,5 +1,16 @@
1
1
  /**
2
- * Shell execution helper with timeout, logging, and structured results.
2
+ * Command execution helpers with timeout, logging, and structured results.
3
+ *
4
+ * Two entry points, and the difference between them is a security boundary:
5
+ *
6
+ * - `shell(cmd, args, opts)` passes arguments straight to the OS via
7
+ * `execFile`. No shell is involved, so metacharacters in an argument are
8
+ * inert. Every command this provider builds itself uses this form — a value
9
+ * spliced into a command string would be CWE-78.
10
+ * - `shellScript(command, opts)` runs a command string through `/bin/sh -c`.
11
+ * It exists only for command strings the Launchfile author wrote for their
12
+ * own app (`commands:`, `health:`, `release:`), where shell syntax is the
13
+ * documented contract. Never build one of these by interpolation.
3
14
  */
4
15
  export interface ShellResult {
5
16
  exitCode: number;
@@ -14,12 +25,23 @@ export interface ShellOpts {
14
25
  silent?: boolean;
15
26
  }
16
27
  /**
17
- * Run a shell command and return structured output.
18
- * Throws on non-zero exit code unless `allowFailure` is set.
28
+ * Run a command with an argument array. Arguments reach the OS directly, so
29
+ * they are never parsed as shell syntax.
19
30
  */
20
- export declare function shell(command: string, opts?: ShellOpts & {
31
+ export declare function shell(cmd: string, args: string[], opts?: ShellOpts & {
32
+ allowFailure?: boolean;
33
+ }): Promise<ShellResult>;
34
+ /** Run a command with an argument array, return true if exit code is 0. */
35
+ export declare function shellOk(cmd: string, args: string[], opts?: ShellOpts): Promise<boolean>;
36
+ /**
37
+ * Run an author-written command string through `/bin/sh -c`.
38
+ *
39
+ * The shell is the point here: `commands:`, `health:` and `release:` values are
40
+ * documented as shell commands, and app authors rely on pipes, `&&` and
41
+ * variable expansion in them. The string must come from the Launchfile
42
+ * verbatim — building one by interpolating a value makes it injectable.
43
+ */
44
+ export declare function shellScript(command: string, opts?: ShellOpts & {
21
45
  allowFailure?: boolean;
22
46
  }): Promise<ShellResult>;
23
- /** Run a command, return true if exit code is 0 */
24
- export declare function shellOk(command: string, opts?: ShellOpts): Promise<boolean>;
25
47
  //# sourceMappingURL=shell.d.ts.map
package/dist/shell.js CHANGED
@@ -1,46 +1,101 @@
1
1
  /**
2
- * Shell execution helper with timeout, logging, and structured results.
2
+ * Command execution helpers with timeout, logging, and structured results.
3
+ *
4
+ * Two entry points, and the difference between them is a security boundary:
5
+ *
6
+ * - `shell(cmd, args, opts)` passes arguments straight to the OS via
7
+ * `execFile`. No shell is involved, so metacharacters in an argument are
8
+ * inert. Every command this provider builds itself uses this form — a value
9
+ * spliced into a command string would be CWE-78.
10
+ * - `shellScript(command, opts)` runs a command string through `/bin/sh -c`.
11
+ * It exists only for command strings the Launchfile author wrote for their
12
+ * own app (`commands:`, `health:`, `release:`), where shell syntax is the
13
+ * documented contract. Never build one of these by interpolation.
3
14
  */
4
- import { exec as cpExec } from "node:child_process";
15
+ import { exec as cpExec, execFile as cpExecFile, } from "node:child_process";
16
+ import { redactSecrets } from "./redact.js";
17
+ const DEFAULT_TIMEOUT_MS = 120_000;
18
+ const MAX_BUFFER = 10 * 1024 * 1024;
19
+ function toResult(error, stdout, stderr) {
20
+ const result = {
21
+ exitCode: typeof error?.code === "number" ? error.code : 0,
22
+ stdout: typeof stdout === "string" ? stdout : "",
23
+ stderr: typeof stderr === "string" ? stderr : "",
24
+ };
25
+ // Node sets error.code to the exit code on non-zero; a signal or a spawn
26
+ // failure leaves it non-numeric, so surface those as a generic failure.
27
+ if (error && result.exitCode === 0)
28
+ result.exitCode = 1;
29
+ return result;
30
+ }
31
+ function failure(display, result) {
32
+ // The message reaches the user and may be logged by a caller; both the
33
+ // command and the child's stderr can echo a secret back, so neither goes
34
+ // in unscrubbed (D-18, CWE-532).
35
+ return Object.assign(new Error(`Command failed: ${redactSecrets(display)}\n${redactSecrets(result.stderr)}`), { result });
36
+ }
5
37
  /**
6
- * Run a shell command and return structured output.
7
- * Throws on non-zero exit code unless `allowFailure` is set.
38
+ * Run a command with an argument array. Arguments reach the OS directly, so
39
+ * they are never parsed as shell syntax.
8
40
  */
9
- export async function shell(command, opts = {}) {
41
+ export async function shell(cmd, args, opts = {}) {
42
+ const display = [cmd, ...args].join(" ");
10
43
  if (!opts.silent) {
11
- console.log(` $ ${command}`);
44
+ // An argument can carry a resolved secret (a generated DB password, a
45
+ // credential-bearing URL). Scrub before echo.
46
+ console.log(` $ ${redactSecrets(display)}`);
12
47
  }
13
48
  const execOpts = {
14
49
  cwd: opts.cwd,
15
50
  env: opts.env ? { ...process.env, ...opts.env } : undefined,
16
- timeout: opts.timeout ?? 120_000,
17
- maxBuffer: 10 * 1024 * 1024,
51
+ timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS,
52
+ maxBuffer: MAX_BUFFER,
18
53
  };
19
54
  return new Promise((resolve, reject) => {
20
- cpExec(command, execOpts, (error, stdout, stderr) => {
21
- const result = {
22
- exitCode: error?.code ?? (typeof error?.code === "number" ? error.code : 0),
23
- stdout: typeof stdout === "string" ? stdout : "",
24
- stderr: typeof stderr === "string" ? stderr : "",
25
- };
26
- // Node's exec sets error.code to the exit code on non-zero
27
- if (error && result.exitCode === 0) {
28
- result.exitCode = 1;
29
- }
30
- if (error && !opts.allowFailure) {
31
- reject(Object.assign(new Error(`Command failed: ${command}\n${result.stderr}`), {
32
- result,
33
- }));
34
- }
35
- else {
55
+ cpExecFile(cmd, args, execOpts, (error, stdout, stderr) => {
56
+ const result = toResult(error, stdout, stderr);
57
+ if (error && !opts.allowFailure)
58
+ reject(failure(display, result));
59
+ else
36
60
  resolve(result);
37
- }
38
61
  });
39
62
  });
40
63
  }
41
- /** Run a command, return true if exit code is 0 */
42
- export async function shellOk(command, opts) {
43
- const result = await shell(command, { ...opts, allowFailure: true, silent: true });
64
+ /** Run a command with an argument array, return true if exit code is 0. */
65
+ export async function shellOk(cmd, args, opts) {
66
+ const result = await shell(cmd, args, {
67
+ ...opts,
68
+ allowFailure: true,
69
+ silent: true,
70
+ });
44
71
  return result.exitCode === 0;
45
72
  }
73
+ /**
74
+ * Run an author-written command string through `/bin/sh -c`.
75
+ *
76
+ * The shell is the point here: `commands:`, `health:` and `release:` values are
77
+ * documented as shell commands, and app authors rely on pipes, `&&` and
78
+ * variable expansion in them. The string must come from the Launchfile
79
+ * verbatim — building one by interpolating a value makes it injectable.
80
+ */
81
+ export async function shellScript(command, opts = {}) {
82
+ if (!opts.silent) {
83
+ console.log(` $ ${redactSecrets(command)}`);
84
+ }
85
+ const execOpts = {
86
+ cwd: opts.cwd,
87
+ env: opts.env ? { ...process.env, ...opts.env } : undefined,
88
+ timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS,
89
+ maxBuffer: MAX_BUFFER,
90
+ };
91
+ return new Promise((resolve, reject) => {
92
+ cpExec(command, execOpts, (error, stdout, stderr) => {
93
+ const result = toResult(error, stdout, stderr);
94
+ if (error && !opts.allowFailure)
95
+ reject(failure(command, result));
96
+ else
97
+ resolve(result);
98
+ });
99
+ });
100
+ }
46
101
  //# sourceMappingURL=shell.js.map
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,22 @@ 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>;
53
+ /**
54
+ * Minted `env:`-level generator values (D-49: generate once, then
55
+ * preserve), keyed `<component>.<ENV_NAME>` — one entry per declaration
56
+ * (D-25), so same-named variables on different components hold independent
57
+ * values. `generator: port` values are never stored here (ports are
58
+ * re-allocated each run). Disjoint from `secrets` on purpose: these names
59
+ * must not become resolvable as `$secrets.<name>`. Optional for backward
60
+ * compatibility: older state files omit it and load as a first run.
61
+ */
62
+ generatedEnv?: Record<string, string>;
25
63
  }
26
64
  export declare function hashLaunchfile(content: string): string;
27
65
  /** Load state from disk, or return null if none exists */
package/dist/state.js CHANGED
@@ -7,6 +7,7 @@
7
7
  import { readFile, writeFile, mkdir } from "node:fs/promises";
8
8
  import { join } from "node:path";
9
9
  import { createHash } from "node:crypto";
10
+ import { registerSecrets } from "./redact.js";
10
11
  const STATE_DIR = ".launchfile";
11
12
  const STATE_FILE = "state.json";
12
13
  function stateDir(projectDir) {
@@ -22,7 +23,17 @@ export function hashLaunchfile(content) {
22
23
  export async function loadState(projectDir) {
23
24
  try {
24
25
  const raw = await readFile(statePath(projectDir), "utf8");
25
- return JSON.parse(raw);
26
+ const state = JSON.parse(raw);
27
+ // Persisted credentials are reused verbatim in provisioning commands and
28
+ // in `$secrets.*` / `$<resource>.*` expressions, so they must be known to
29
+ // the redactor before anything can echo them.
30
+ registerSecrets(Object.values(state.secrets ?? {}));
31
+ registerSecrets(Object.values(state.resources ?? {}).map((r) => r.password));
32
+ // A value minted in an earlier run and merely reused in this one never
33
+ // passes through generateValue()'s registration, so the loader is the
34
+ // only place it can become scrubbable (D-18).
35
+ registerSecrets(Object.values(state.generatedEnv ?? {}));
36
+ return state;
26
37
  }
27
38
  catch {
28
39
  return null;
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,15 @@
1
1
  {
2
2
  "name": "@launchfile/macos-dev",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "macOS dev provider for Launchfile — run apps locally via brew services and native runtimes",
5
+ "os": [
6
+ "darwin"
7
+ ],
5
8
  "type": "module",
6
9
  "main": "dist/index.js",
7
10
  "types": "dist/index.d.ts",
8
11
  "scripts": {
9
- "build": "tsc",
12
+ "build": "tsc -p tsconfig.build.json",
10
13
  "test": "vitest run",
11
14
  "test:watch": "vitest",
12
15
  "typecheck": "tsc --noEmit",
@@ -33,14 +36,14 @@
33
36
  "directory": "providers/macos-dev"
34
37
  },
35
38
  "dependencies": {
36
- "@launchfile/sdk": "^0.2.0",
39
+ "@launchfile/sdk": "^0.4.0",
37
40
  "semver": "^7.7.4"
38
41
  },
39
42
  "devDependencies": {
40
43
  "@biomejs/biome": "^2.4.10",
41
44
  "@types/bun": "^1.3.11",
42
45
  "@types/semver": "^7.7.1",
43
- "typescript": "^6.0.0",
46
+ "typescript": "^7.0.2",
44
47
  "vitest": "^4.1.3"
45
48
  }
46
49
  }
@@ -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
@@ -1,141 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { readFile } from "node:fs/promises";
3
- import { existsSync } from "node:fs";
4
- import { join } from "node:path";
5
- import { readLaunch } from "@launchfile/sdk";
6
- import { buildResolverContext, resolveComponentEnv, resolveGenerators, } from "../env-writer.js";
7
- import { getProvisioner } from "../resources/index.js";
8
- import { allocatePorts } from "../port-allocator.js";
9
- const CATALOG_ROOT = join(import.meta.dirname, "../../../../catalog");
10
- const CATALOG_APPS = join(CATALOG_ROOT, "apps");
11
- const CATALOG_DRAFTS = join(CATALOG_ROOT, "drafts");
12
- /** Find a Launchfile in apps/ or drafts/ */
13
- function catalogPath(app) {
14
- const appsPath = join(CATALOG_APPS, app, "Launchfile");
15
- if (existsSync(appsPath))
16
- return appsPath;
17
- return join(CATALOG_DRAFTS, app, "Launchfile");
18
- }
19
- /**
20
- * Dry-run test: parse a catalog Launchfile, simulate provisioning,
21
- * resolve env vars, and verify the output makes sense.
22
- */
23
- describe("dry-run against catalog", () => {
24
- it("resolves miniflux Launchfile", async () => {
25
- const yaml = await readFile(catalogPath("miniflux"), "utf8");
26
- const launch = readLaunch(yaml);
27
- expect(launch.name).toBe("miniflux");
28
- expect(Object.keys(launch.components)).toEqual(["default"]);
29
- const component = launch.components.default;
30
- // Verify requires
31
- expect(component.requires).toHaveLength(1);
32
- expect(component.requires[0].type).toBe("postgres");
33
- expect(component.requires[0].set_env?.DATABASE_URL).toBe("$url");
34
- // Simulate provisioned postgres
35
- const resourceMap = {
36
- postgres: {
37
- url: "postgresql://launchfile_miniflux:secret@localhost:5432/launchfile_miniflux",
38
- host: "localhost",
39
- port: 5432,
40
- user: "launchfile_miniflux",
41
- password: "secret",
42
- name: "launchfile_miniflux",
43
- },
44
- };
45
- const ports = await allocatePorts(launch.components, "miniflux");
46
- const context = buildResolverContext(resourceMap, ports, {}, {});
47
- const env = resolveComponentEnv(component, context, resourceMap);
48
- await resolveGenerators(component, env);
49
- // DATABASE_URL should be resolved from postgres $url
50
- expect(env.DATABASE_URL).toBe("postgresql://launchfile_miniflux:secret@localhost:5432/launchfile_miniflux");
51
- // Default env vars should be resolved
52
- expect(env.RUN_MIGRATIONS).toBe("1");
53
- expect(env.CREATE_ADMIN).toBe("1");
54
- expect(env.ADMIN_USERNAME).toBe("admin");
55
- // ADMIN_PASSWORD has generator: secret, so it should be generated
56
- expect(env.ADMIN_PASSWORD).toBeTruthy();
57
- expect(env.ADMIN_PASSWORD.length).toBeGreaterThan(10);
58
- });
59
- it("resolves langfuse Launchfile", async () => {
60
- const yaml = await readFile(catalogPath("langfuse"), "utf8");
61
- const launch = readLaunch(yaml);
62
- expect(launch.name).toBe("langfuse");
63
- for (const [name, component] of Object.entries(launch.components)) {
64
- // Every required resource should have a known provisioner
65
- for (const req of component.requires ?? []) {
66
- const provisioner = getProvisioner(req.type);
67
- expect(provisioner, `No provisioner for ${req.type} in ${name}`).toBeDefined();
68
- }
69
- }
70
- });
71
- it("resolves chatwoot multi-component Launchfile", async () => {
72
- const yaml = await readFile(catalogPath("chatwoot"), "utf8");
73
- const launch = readLaunch(yaml);
74
- expect(launch.name).toBe("chatwoot");
75
- const componentNames = Object.keys(launch.components);
76
- expect(componentNames.length).toBeGreaterThan(1);
77
- // Simulate resources
78
- const resourceMap = {
79
- postgres: {
80
- url: "postgresql://user:pass@localhost:5432/chatwoot",
81
- host: "localhost",
82
- port: 5432,
83
- user: "user",
84
- password: "pass",
85
- name: "chatwoot",
86
- },
87
- redis: {
88
- url: "redis://localhost:6379/0",
89
- host: "localhost",
90
- port: 6379,
91
- },
92
- };
93
- const ports = await allocatePorts(launch.components, "chatwoot");
94
- const secrets = {};
95
- // Generate secrets from launch.secrets
96
- if (launch.secrets) {
97
- for (const [name] of Object.entries(launch.secrets)) {
98
- secrets[name] = `test-secret-${name}`;
99
- }
100
- }
101
- const context = buildResolverContext(resourceMap, ports, secrets, {});
102
- // Resolve env for each component — should not throw
103
- for (const [name, component] of Object.entries(launch.components)) {
104
- const env = resolveComponentEnv(component, context, resourceMap);
105
- await resolveGenerators(component, env);
106
- // Every component should have at least some env vars
107
- expect(Object.keys(env).length, `${name} should have env vars`).toBeGreaterThan(0);
108
- }
109
- });
110
- it("identifies unsupported resource types", async () => {
111
- // Check all catalog Launchfiles for resource types we support
112
- const { readdir } = await import("node:fs/promises");
113
- const appsEntries = await readdir(CATALOG_APPS).catch(() => []);
114
- const draftsEntries = await readdir(CATALOG_DRAFTS).catch(() => []);
115
- const allApps = [...new Set([...appsEntries, ...draftsEntries])];
116
- const unsupported = new Set();
117
- for (const app of allApps) {
118
- try {
119
- const yaml = await readFile(catalogPath(app), "utf8");
120
- const launch = readLaunch(yaml);
121
- for (const component of Object.values(launch.components)) {
122
- for (const req of [...(component.requires ?? []), ...(component.supports ?? [])]) {
123
- if (!getProvisioner(req.type)) {
124
- unsupported.add(req.type);
125
- }
126
- }
127
- }
128
- }
129
- catch {
130
- // Skip apps that can't be parsed
131
- }
132
- }
133
- // Log unsupported types for visibility
134
- if (unsupported.size > 0) {
135
- console.log(` Unsupported resource types in catalog: ${[...unsupported].join(", ")}`);
136
- }
137
- // This test is informational — it doesn't fail
138
- expect(true).toBe(true);
139
- });
140
- });
141
- //# sourceMappingURL=dry-run.test.js.map
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=env-writer.test.d.ts.map