@launchfile/macos-dev 0.1.4 → 0.2.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 (107) hide show
  1. package/dist/__tests__/bootstrap.test.d.ts +2 -0
  2. package/dist/__tests__/bootstrap.test.js +90 -0
  3. package/dist/__tests__/dry-run.test.js +21 -9
  4. package/dist/__tests__/env-writer.test.js +82 -8
  5. package/dist/bootstrap.d.ts +58 -0
  6. package/dist/bootstrap.js +221 -0
  7. package/dist/env-writer.d.ts +14 -11
  8. package/dist/env-writer.js +30 -3
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.js +1 -0
  11. package/dist/provider.js +30 -48
  12. package/package.json +8 -3
  13. package/CLAUDE.md +0 -37
  14. package/dist/__tests__/dry-run.test.d.ts.map +0 -1
  15. package/dist/__tests__/dry-run.test.js.map +0 -1
  16. package/dist/__tests__/env-writer.test.d.ts.map +0 -1
  17. package/dist/__tests__/env-writer.test.js.map +0 -1
  18. package/dist/__tests__/lockfile-detect.test.d.ts.map +0 -1
  19. package/dist/__tests__/lockfile-detect.test.js.map +0 -1
  20. package/dist/__tests__/port-allocator.test.d.ts.map +0 -1
  21. package/dist/__tests__/port-allocator.test.js.map +0 -1
  22. package/dist/__tests__/secret-generator.test.d.ts.map +0 -1
  23. package/dist/__tests__/secret-generator.test.js.map +0 -1
  24. package/dist/__tests__/state.test.d.ts.map +0 -1
  25. package/dist/__tests__/state.test.js.map +0 -1
  26. package/dist/cli.d.ts.map +0 -1
  27. package/dist/cli.js.map +0 -1
  28. package/dist/env-writer.d.ts.map +0 -1
  29. package/dist/env-writer.js.map +0 -1
  30. package/dist/health.d.ts.map +0 -1
  31. package/dist/health.js.map +0 -1
  32. package/dist/index.d.ts.map +0 -1
  33. package/dist/index.js.map +0 -1
  34. package/dist/lockfile-detect.d.ts.map +0 -1
  35. package/dist/lockfile-detect.js.map +0 -1
  36. package/dist/port-allocator.d.ts.map +0 -1
  37. package/dist/port-allocator.js.map +0 -1
  38. package/dist/prereqs.d.ts.map +0 -1
  39. package/dist/prereqs.js.map +0 -1
  40. package/dist/process-manager.d.ts.map +0 -1
  41. package/dist/process-manager.js.map +0 -1
  42. package/dist/provider.d.ts.map +0 -1
  43. package/dist/provider.js.map +0 -1
  44. package/dist/resources/index.d.ts.map +0 -1
  45. package/dist/resources/index.js.map +0 -1
  46. package/dist/resources/mysql.d.ts.map +0 -1
  47. package/dist/resources/mysql.js.map +0 -1
  48. package/dist/resources/postgres.d.ts.map +0 -1
  49. package/dist/resources/postgres.js.map +0 -1
  50. package/dist/resources/redis.d.ts.map +0 -1
  51. package/dist/resources/redis.js.map +0 -1
  52. package/dist/resources/sqlite.d.ts.map +0 -1
  53. package/dist/resources/sqlite.js.map +0 -1
  54. package/dist/resources/types.d.ts.map +0 -1
  55. package/dist/resources/types.js.map +0 -1
  56. package/dist/runtimes/bun.d.ts.map +0 -1
  57. package/dist/runtimes/bun.js.map +0 -1
  58. package/dist/runtimes/index.d.ts.map +0 -1
  59. package/dist/runtimes/index.js.map +0 -1
  60. package/dist/runtimes/node.d.ts.map +0 -1
  61. package/dist/runtimes/node.js.map +0 -1
  62. package/dist/runtimes/python.d.ts.map +0 -1
  63. package/dist/runtimes/python.js.map +0 -1
  64. package/dist/runtimes/ruby.d.ts.map +0 -1
  65. package/dist/runtimes/ruby.js.map +0 -1
  66. package/dist/runtimes/types.d.ts.map +0 -1
  67. package/dist/runtimes/types.js.map +0 -1
  68. package/dist/secret-generator.d.ts.map +0 -1
  69. package/dist/secret-generator.js.map +0 -1
  70. package/dist/shell.d.ts.map +0 -1
  71. package/dist/shell.js.map +0 -1
  72. package/dist/state.d.ts.map +0 -1
  73. package/dist/state.js.map +0 -1
  74. package/dist/storage.d.ts.map +0 -1
  75. package/dist/storage.js.map +0 -1
  76. package/src/__tests__/dry-run.test.ts +0 -157
  77. package/src/__tests__/env-writer.test.ts +0 -156
  78. package/src/__tests__/lockfile-detect.test.ts +0 -74
  79. package/src/__tests__/port-allocator.test.ts +0 -59
  80. package/src/__tests__/secret-generator.test.ts +0 -31
  81. package/src/__tests__/state.test.ts +0 -33
  82. package/src/cli.ts +0 -78
  83. package/src/env-writer.ts +0 -231
  84. package/src/health.ts +0 -80
  85. package/src/index.ts +0 -9
  86. package/src/lockfile-detect.ts +0 -48
  87. package/src/port-allocator.ts +0 -97
  88. package/src/prereqs.ts +0 -32
  89. package/src/process-manager.ts +0 -242
  90. package/src/provider.ts +0 -415
  91. package/src/resources/index.ts +0 -29
  92. package/src/resources/mysql.ts +0 -98
  93. package/src/resources/postgres.ts +0 -133
  94. package/src/resources/redis.ts +0 -58
  95. package/src/resources/sqlite.ts +0 -52
  96. package/src/resources/types.ts +0 -47
  97. package/src/runtimes/bun.ts +0 -25
  98. package/src/runtimes/index.ts +0 -24
  99. package/src/runtimes/node.ts +0 -71
  100. package/src/runtimes/python.ts +0 -42
  101. package/src/runtimes/ruby.ts +0 -55
  102. package/src/runtimes/types.ts +0 -16
  103. package/src/secret-generator.ts +0 -32
  104. package/src/shell.ts +0 -70
  105. package/src/state.ts +0 -92
  106. package/src/storage.ts +0 -67
  107. package/tsconfig.json +0 -18
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=bootstrap.test.d.ts.map
@@ -0,0 +1,90 @@
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,18 +1,28 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { readFile } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
3
4
  import { join } from "node:path";
4
5
  import { readLaunch } from "@launchfile/sdk";
5
6
  import { buildResolverContext, resolveComponentEnv, resolveGenerators, } from "../env-writer.js";
6
7
  import { getProvisioner } from "../resources/index.js";
7
8
  import { allocatePorts } from "../port-allocator.js";
8
- const CATALOG_DIR = join(import.meta.dirname, "../../../../catalog/drafts");
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
+ }
9
19
  /**
10
20
  * Dry-run test: parse a catalog Launchfile, simulate provisioning,
11
21
  * resolve env vars, and verify the output makes sense.
12
22
  */
13
23
  describe("dry-run against catalog", () => {
14
24
  it("resolves miniflux Launchfile", async () => {
15
- const yaml = await readFile(join(CATALOG_DIR, "miniflux/Launchfile"), "utf8");
25
+ const yaml = await readFile(catalogPath("miniflux"), "utf8");
16
26
  const launch = readLaunch(yaml);
17
27
  expect(launch.name).toBe("miniflux");
18
28
  expect(Object.keys(launch.components)).toEqual(["default"]);
@@ -33,7 +43,7 @@ describe("dry-run against catalog", () => {
33
43
  },
34
44
  };
35
45
  const ports = await allocatePorts(launch.components, "miniflux");
36
- const context = buildResolverContext(resourceMap, ports, {});
46
+ const context = buildResolverContext(resourceMap, ports, {}, {});
37
47
  const env = resolveComponentEnv(component, context, resourceMap);
38
48
  await resolveGenerators(component, env);
39
49
  // DATABASE_URL should be resolved from postgres $url
@@ -47,7 +57,7 @@ describe("dry-run against catalog", () => {
47
57
  expect(env.ADMIN_PASSWORD.length).toBeGreaterThan(10);
48
58
  });
49
59
  it("resolves langfuse Launchfile", async () => {
50
- const yaml = await readFile(join(CATALOG_DIR, "langfuse/Launchfile"), "utf8");
60
+ const yaml = await readFile(catalogPath("langfuse"), "utf8");
51
61
  const launch = readLaunch(yaml);
52
62
  expect(launch.name).toBe("langfuse");
53
63
  for (const [name, component] of Object.entries(launch.components)) {
@@ -59,7 +69,7 @@ describe("dry-run against catalog", () => {
59
69
  }
60
70
  });
61
71
  it("resolves chatwoot multi-component Launchfile", async () => {
62
- const yaml = await readFile(join(CATALOG_DIR, "chatwoot/Launchfile"), "utf8");
72
+ const yaml = await readFile(catalogPath("chatwoot"), "utf8");
63
73
  const launch = readLaunch(yaml);
64
74
  expect(launch.name).toBe("chatwoot");
65
75
  const componentNames = Object.keys(launch.components);
@@ -88,7 +98,7 @@ describe("dry-run against catalog", () => {
88
98
  secrets[name] = `test-secret-${name}`;
89
99
  }
90
100
  }
91
- const context = buildResolverContext(resourceMap, ports, secrets);
101
+ const context = buildResolverContext(resourceMap, ports, secrets, {});
92
102
  // Resolve env for each component — should not throw
93
103
  for (const [name, component] of Object.entries(launch.components)) {
94
104
  const env = resolveComponentEnv(component, context, resourceMap);
@@ -100,11 +110,13 @@ describe("dry-run against catalog", () => {
100
110
  it("identifies unsupported resource types", async () => {
101
111
  // Check all catalog Launchfiles for resource types we support
102
112
  const { readdir } = await import("node:fs/promises");
103
- const apps = await readdir(CATALOG_DIR);
113
+ const appsEntries = await readdir(CATALOG_APPS).catch(() => []);
114
+ const draftsEntries = await readdir(CATALOG_DRAFTS).catch(() => []);
115
+ const allApps = [...new Set([...appsEntries, ...draftsEntries])];
104
116
  const unsupported = new Set();
105
- for (const app of apps) {
117
+ for (const app of allApps) {
106
118
  try {
107
- const yaml = await readFile(join(CATALOG_DIR, app, "Launchfile"), "utf8");
119
+ const yaml = await readFile(catalogPath(app), "utf8");
108
120
  const launch = readLaunch(yaml);
109
121
  for (const component of Object.values(launch.components)) {
110
122
  for (const req of [...(component.requires ?? []), ...(component.supports ?? [])]) {
@@ -1,7 +1,8 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { buildResolverContext, resolveComponentEnv, generateSecrets, } from "../env-writer.js";
2
+ import { buildResolverContext, computeAppProperties, resolveComponentEnv, generateSecrets, } from "../env-writer.js";
3
+ const NO_APP = {};
3
4
  describe("buildResolverContext", () => {
4
- it("builds context from resources, ports, and secrets", () => {
5
+ it("builds context from resources, ports, secrets, and app properties", () => {
5
6
  const resources = {
6
7
  postgres: {
7
8
  url: "postgresql://user:pass@localhost:5432/mydb",
@@ -14,7 +15,8 @@ describe("buildResolverContext", () => {
14
15
  };
15
16
  const ports = { backend: 3000, frontend: 3001 };
16
17
  const secrets = { "jwt-secret": "abc123" };
17
- const ctx = buildResolverContext(resources, ports, secrets);
18
+ const app = { name: "my-app", host: "localhost", port: 3000, url: "http://localhost:3000" };
19
+ const ctx = buildResolverContext(resources, ports, secrets, app);
18
20
  expect(ctx.components?.backend).toEqual({
19
21
  url: "http://localhost:3000",
20
22
  host: "localhost",
@@ -22,6 +24,78 @@ describe("buildResolverContext", () => {
22
24
  });
23
25
  expect(ctx.resources?.postgres?.url).toBe("postgresql://user:pass@localhost:5432/mydb");
24
26
  expect(ctx.secrets?.["jwt-secret"]).toBe("abc123");
27
+ expect(ctx.app?.url).toBe("http://localhost:3000");
28
+ expect(ctx.app?.name).toBe("my-app");
29
+ });
30
+ });
31
+ describe("computeAppProperties (D-33)", () => {
32
+ const baseLaunch = (overrides) => ({
33
+ name: "my-app",
34
+ components: {},
35
+ ...overrides,
36
+ });
37
+ it("uses the first exposed component's port for $app.port and $app.url", () => {
38
+ const launch = baseLaunch({
39
+ components: {
40
+ default: {
41
+ provides: [{ protocol: "http", port: 3000, exposed: true }],
42
+ },
43
+ },
44
+ });
45
+ const app = computeAppProperties(launch, { default: 10042 });
46
+ expect(app.name).toBe("my-app");
47
+ expect(app.host).toBe("localhost");
48
+ expect(app.port).toBe(10042);
49
+ expect(app.url).toBe("http://localhost:10042");
50
+ });
51
+ it("picks the first exposed component in declaration order for multi-component apps", () => {
52
+ const launch = baseLaunch({
53
+ components: {
54
+ api: {
55
+ provides: [{ protocol: "http", port: 4000, exposed: true }],
56
+ },
57
+ worker: {
58
+ provides: [],
59
+ },
60
+ ui: {
61
+ provides: [{ protocol: "http", port: 5000, exposed: true }],
62
+ },
63
+ },
64
+ });
65
+ const app = computeAppProperties(launch, { api: 10043, ui: 10044 });
66
+ // Picks "api" because it's first in declaration order with exposed: true.
67
+ expect(app.port).toBe(10043);
68
+ expect(app.url).toBe("http://localhost:10043");
69
+ });
70
+ it("returns port 0 and empty url when no component is exposed", () => {
71
+ const launch = baseLaunch({
72
+ components: {
73
+ worker: {
74
+ provides: [],
75
+ },
76
+ },
77
+ });
78
+ const app = computeAppProperties(launch, {});
79
+ expect(app.port).toBe(0);
80
+ expect(app.url).toBe("");
81
+ expect(app.name).toBe("my-app");
82
+ });
83
+ // End-to-end: a component env var that uses $app.url resolves to the
84
+ // computed app URL. This is the contract that makes Firefly III's
85
+ // `APP_URL: default: $app.url` actually work end-to-end on macos-dev.
86
+ it("$app.url in component env defaults resolves to the computed URL", () => {
87
+ const launch = baseLaunch({
88
+ components: {
89
+ default: {
90
+ provides: [{ protocol: "http", port: 3000, exposed: true }],
91
+ env: { PUBLIC_URL: { default: "$app.url" } },
92
+ },
93
+ },
94
+ });
95
+ const app = computeAppProperties(launch, { default: 10042 });
96
+ const ctx = buildResolverContext({}, { default: 10042 }, {}, app);
97
+ const env = resolveComponentEnv(launch.components.default, ctx, {});
98
+ expect(env.PUBLIC_URL).toBe("http://localhost:10042");
25
99
  });
26
100
  });
27
101
  describe("resolveComponentEnv", () => {
@@ -44,7 +118,7 @@ describe("resolveComponentEnv", () => {
44
118
  port: 5432,
45
119
  },
46
120
  };
47
- const context = buildResolverContext(resourceMap, {}, {});
121
+ const context = buildResolverContext(resourceMap, {}, {}, NO_APP);
48
122
  const env = resolveComponentEnv(component, context, resourceMap);
49
123
  expect(env.DATABASE_URL).toBe("postgresql://user:pass@localhost:5432/mydb");
50
124
  expect(env.DB_HOST).toBe("localhost");
@@ -56,7 +130,7 @@ describe("resolveComponentEnv", () => {
56
130
  LOG_LEVEL: { default: "info" },
57
131
  },
58
132
  };
59
- const context = buildResolverContext({}, {}, {});
133
+ const context = buildResolverContext({}, {}, {}, NO_APP);
60
134
  const env = resolveComponentEnv(component, context, {});
61
135
  expect(env.PORT).toBe("8080");
62
136
  expect(env.LOG_LEVEL).toBe("info");
@@ -67,7 +141,7 @@ describe("resolveComponentEnv", () => {
67
141
  BACKEND_URL: { default: "$components.backend.url" },
68
142
  },
69
143
  };
70
- const context = buildResolverContext({}, { backend: 3000 }, {});
144
+ const context = buildResolverContext({}, { backend: 3000 }, {}, NO_APP);
71
145
  const env = resolveComponentEnv(component, context, {});
72
146
  expect(env.BACKEND_URL).toBe("http://localhost:3000");
73
147
  });
@@ -77,7 +151,7 @@ describe("resolveComponentEnv", () => {
77
151
  SECRET_KEY: { default: "$secrets.my-key" },
78
152
  },
79
153
  };
80
- const context = buildResolverContext({}, {}, { "my-key": "super-secret" });
154
+ const context = buildResolverContext({}, {}, { "my-key": "super-secret" }, NO_APP);
81
155
  const env = resolveComponentEnv(component, context, {});
82
156
  expect(env.SECRET_KEY).toBe("super-secret");
83
157
  });
@@ -100,7 +174,7 @@ describe("resolveComponentEnv", () => {
100
174
  port: 5432,
101
175
  },
102
176
  };
103
- const context = buildResolverContext(resourceMap, {}, {});
177
+ const context = buildResolverContext(resourceMap, {}, {}, NO_APP);
104
178
  const env = resolveComponentEnv(component, context, resourceMap);
105
179
  expect(env.DATABASE_URL).toBe("postgresql://real-url");
106
180
  });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Bootstrap command execution for the macOS Dev provider.
3
+ *
4
+ * Implements the `commands.bootstrap` lifecycle stage introduced by D-34:
5
+ * user-invoked, runs after `start` against the running component, captures
6
+ * stdout via regex patterns, re-runnable, and reports failures rather than
7
+ * deploy-failing. Spec: /spec/SPEC.md § Bootstrap stage.
8
+ *
9
+ * The command is split into argv via whitespace and run through spawn()
10
+ * with shell:false to avoid shell-injection exposure. This means shell
11
+ * metacharacters (pipes, redirects, &&, quoted args with spaces) are not
12
+ * supported — apps that need shell features should wrap them in an
13
+ * image-level script and invoke that script.
14
+ */
15
+ import { type CaptureEntry } from "@launchfile/sdk";
16
+ /**
17
+ * Result of running one bootstrap command. Captures may be empty even on
18
+ * success (the command may not produce matching output), and may be
19
+ * partially populated even on failure (some patterns matched before
20
+ * the command errored out).
21
+ */
22
+ export interface BootstrapResult {
23
+ component: string;
24
+ command: string;
25
+ ok: boolean;
26
+ exitCode: number;
27
+ captures: Record<string, string>;
28
+ /** Per-capture metadata (description, sensitive) for display. */
29
+ captureMeta: Record<string, CaptureEntry>;
30
+ stdout: string;
31
+ stderr: string;
32
+ }
33
+ /**
34
+ * Apply a set of capture patterns to a command's stdout. If a pattern has
35
+ * a capture group, the first group is used; otherwise the full match is
36
+ * used. Invalid regexes are skipped (schema validation catches them earlier).
37
+ *
38
+ * Exported for unit testing.
39
+ */
40
+ export declare function extractCaptures(stdout: string, captures: Record<string, CaptureEntry>): Record<string, string>;
41
+ /** Parse a simple duration string like "5m", "30s", "1h" into milliseconds. Exported for unit testing. */
42
+ export declare function parseDuration(s: string): number;
43
+ /**
44
+ * Public entry point for `launch bootstrap`. Loads the Launchfile, rebuilds
45
+ * the resolver context from persisted state (so $app.url resolves to the
46
+ * same value the running component sees), resolves $-expressions in each
47
+ * bootstrap command, runs them in declaration order, and returns structured
48
+ * results.
49
+ *
50
+ * Does not fail the process on command error — the caller (CLI) decides
51
+ * how to display failures. This matches the "reported, not deploy-failing"
52
+ * semantics in SPEC.md § Bootstrap stage.
53
+ */
54
+ export declare function launchBootstrap(opts?: {
55
+ component?: string;
56
+ projectDir?: string;
57
+ }): Promise<BootstrapResult[]>;
58
+ //# sourceMappingURL=bootstrap.d.ts.map
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Bootstrap command execution for the macOS Dev provider.
3
+ *
4
+ * Implements the `commands.bootstrap` lifecycle stage introduced by D-34:
5
+ * user-invoked, runs after `start` against the running component, captures
6
+ * stdout via regex patterns, re-runnable, and reports failures rather than
7
+ * deploy-failing. Spec: /spec/SPEC.md § Bootstrap stage.
8
+ *
9
+ * The command is split into argv via whitespace and run through spawn()
10
+ * with shell:false to avoid shell-injection exposure. This means shell
11
+ * metacharacters (pipes, redirects, &&, quoted args with spaces) are not
12
+ * supported — apps that need shell features should wrap them in an
13
+ * image-level script and invoke that script.
14
+ */
15
+ import { readFile } from "node:fs/promises";
16
+ import { join } from "node:path";
17
+ import { spawn } from "node:child_process";
18
+ import { readLaunch, resolveExpression, } from "@launchfile/sdk";
19
+ import { loadState } from "./state.js";
20
+ import { buildResolverContext, computeAppProperties, resolveComponentEnv, resolveGenerators, } from "./env-writer.js";
21
+ import { getProvisioner } from "./resources/index.js";
22
+ /**
23
+ * Strip ANSI escape sequences from captured stdout before regex matching.
24
+ * CLI tools that detect a TTY will emit color codes that would otherwise
25
+ * break simple patterns like `https?://\S+`.
26
+ */
27
+ function stripAnsi(s) {
28
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ANSI match
29
+ return s.replace(/\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07/g, "");
30
+ }
31
+ /**
32
+ * Apply a set of capture patterns to a command's stdout. If a pattern has
33
+ * a capture group, the first group is used; otherwise the full match is
34
+ * used. Invalid regexes are skipped (schema validation catches them earlier).
35
+ *
36
+ * Exported for unit testing.
37
+ */
38
+ export function extractCaptures(stdout, captures) {
39
+ const result = {};
40
+ const clean = stripAnsi(stdout);
41
+ for (const [name, def] of Object.entries(captures)) {
42
+ try {
43
+ const match = clean.match(new RegExp(def.pattern));
44
+ if (match) {
45
+ result[name] = (match[1] ?? match[0] ?? "").trim();
46
+ }
47
+ }
48
+ catch {
49
+ // Invalid regex — skip. CaptureEntrySchema validation catches this.
50
+ }
51
+ }
52
+ return result;
53
+ }
54
+ /** Parse a simple duration string like "5m", "30s", "1h" into milliseconds. Exported for unit testing. */
55
+ export function parseDuration(s) {
56
+ const match = /^(\d+)\s*(ms|s|m|h)$/.exec(s.trim());
57
+ if (!match)
58
+ return 120_000;
59
+ const n = Number.parseInt(match[1], 10);
60
+ switch (match[2]) {
61
+ case "ms": return n;
62
+ case "s": return n * 1000;
63
+ case "m": return n * 60 * 1000;
64
+ case "h": return n * 60 * 60 * 1000;
65
+ default: return 120_000;
66
+ }
67
+ }
68
+ /**
69
+ * Run one command using argv-split (no-shell) execution. Returns the
70
+ * structured result; does not throw on command failure.
71
+ */
72
+ async function runOnce(command, opts) {
73
+ const parts = command.trim().split(/\s+/).filter(Boolean);
74
+ if (parts.length === 0) {
75
+ return { exitCode: 1, stdout: "", stderr: "empty command" };
76
+ }
77
+ const [file, ...args] = parts;
78
+ return new Promise((resolveP) => {
79
+ const child = spawn(file, args, {
80
+ cwd: opts.cwd,
81
+ env: { ...process.env, ...opts.env },
82
+ shell: false,
83
+ stdio: ["ignore", "pipe", "pipe"],
84
+ });
85
+ let stdout = "";
86
+ let stderr = "";
87
+ let settled = false;
88
+ const timer = setTimeout(() => {
89
+ if (!settled) {
90
+ settled = true;
91
+ child.kill("SIGTERM");
92
+ resolveP({
93
+ exitCode: 124,
94
+ stdout,
95
+ stderr: stderr + `\n(killed after ${opts.timeoutMs}ms timeout)`,
96
+ });
97
+ }
98
+ }, opts.timeoutMs);
99
+ child.stdout?.on("data", (chunk) => {
100
+ stdout += String(chunk);
101
+ });
102
+ child.stderr?.on("data", (chunk) => {
103
+ stderr += String(chunk);
104
+ });
105
+ child.on("error", (err) => {
106
+ if (!settled) {
107
+ settled = true;
108
+ clearTimeout(timer);
109
+ resolveP({ exitCode: 1, stdout, stderr: `${stderr}\n${err.message}` });
110
+ }
111
+ });
112
+ child.on("close", (code) => {
113
+ if (!settled) {
114
+ settled = true;
115
+ clearTimeout(timer);
116
+ resolveP({ exitCode: code ?? 1, stdout, stderr });
117
+ }
118
+ });
119
+ });
120
+ }
121
+ /**
122
+ * Public entry point for `launch bootstrap`. Loads the Launchfile, rebuilds
123
+ * the resolver context from persisted state (so $app.url resolves to the
124
+ * same value the running component sees), resolves $-expressions in each
125
+ * bootstrap command, runs them in declaration order, and returns structured
126
+ * results.
127
+ *
128
+ * Does not fail the process on command error — the caller (CLI) decides
129
+ * how to display failures. This matches the "reported, not deploy-failing"
130
+ * semantics in SPEC.md § Bootstrap stage.
131
+ */
132
+ export async function launchBootstrap(opts = {}) {
133
+ const projectDir = opts.projectDir ?? process.cwd();
134
+ const launchfileContent = await readFile(join(projectDir, "Launchfile"), "utf8");
135
+ const launch = readLaunch(launchfileContent);
136
+ const state = await loadState(projectDir);
137
+ if (!state) {
138
+ throw new Error("No active launch state. Run `launch up` first.");
139
+ }
140
+ // Rebuild resource map from state so the resolver context has real
141
+ // values at invocation time (same pattern as launchEnv). This calls
142
+ // provisioner.provision() on already-provisioned resources to retrieve
143
+ // their current property values — relies on every provisioner being
144
+ // idempotent: re-running provision() must not corrupt state or
145
+ // re-create the resource. All current provisioners satisfy this; new
146
+ // provisioners must as well.
147
+ const resourceMap = {};
148
+ for (const [name, res] of Object.entries(state.resources)) {
149
+ const provisioner = getProvisioner(res.type);
150
+ if (provisioner) {
151
+ const result = await provisioner.provision({ type: res.type, name: res.name }, { appName: state.appName, projectDir }, res);
152
+ resourceMap[name] = result.properties;
153
+ }
154
+ }
155
+ const appProperties = computeAppProperties(launch, state.ports);
156
+ const context = buildResolverContext(resourceMap, state.ports, state.secrets, appProperties);
157
+ const results = [];
158
+ for (const [name, component] of Object.entries(launch.components)) {
159
+ if (opts.component && name !== opts.component)
160
+ continue;
161
+ const bootstrap = component.commands?.bootstrap;
162
+ if (!bootstrap)
163
+ continue;
164
+ // Resolve $-expressions in the command string (e.g. $app.url) at
165
+ // invocation time. Bootstrap runs after start, so the resolved URL
166
+ // already reflects the actual allocated port.
167
+ const resolvedCommand = resolveExpression(bootstrap.command, context);
168
+ // Resolve env vars so the subprocess gets the same environment as
169
+ // the running component.
170
+ const env = resolveComponentEnv(component, context, resourceMap);
171
+ await resolveGenerators(component, env);
172
+ const port = state.ports[name];
173
+ if (port && !env.PORT)
174
+ env.PORT = String(port);
175
+ console.log(`\n \u2193 Bootstrap [${name}]`);
176
+ console.log(` $ ${resolvedCommand}`);
177
+ const { exitCode, stdout, stderr } = await runOnce(resolvedCommand, {
178
+ cwd: projectDir,
179
+ env,
180
+ timeoutMs: bootstrap.timeout ? parseDuration(bootstrap.timeout) : 120_000,
181
+ });
182
+ const captures = bootstrap.capture
183
+ ? extractCaptures(stdout, bootstrap.capture)
184
+ : {};
185
+ const result = {
186
+ component: name,
187
+ command: resolvedCommand,
188
+ ok: exitCode === 0,
189
+ exitCode,
190
+ captures,
191
+ captureMeta: bootstrap.capture ?? {},
192
+ stdout,
193
+ stderr,
194
+ };
195
+ results.push(result);
196
+ // Print captures inline so the user sees them immediately.
197
+ if (Object.keys(captures).length > 0) {
198
+ console.log("\n Captured:");
199
+ for (const [key, value] of Object.entries(captures)) {
200
+ const meta = bootstrap.capture?.[key];
201
+ const displayValue = meta?.sensitive ? "***" : value;
202
+ const desc = meta?.description ? ` — ${meta.description}` : "";
203
+ console.log(` ${key}: ${displayValue}${desc}`);
204
+ }
205
+ }
206
+ if (exitCode !== 0) {
207
+ console.error(` \u2717 Bootstrap [${name}] failed with exit code ${exitCode}`);
208
+ if (stderr)
209
+ console.error(stderr);
210
+ }
211
+ else {
212
+ console.log(` \u2713 Bootstrap [${name}] complete`);
213
+ }
214
+ }
215
+ if (results.length === 0) {
216
+ const target = opts.component ? ` for component "${opts.component}"` : "";
217
+ console.log(`No bootstrap command declared${target}.`);
218
+ }
219
+ return results;
220
+ }
221
+ //# sourceMappingURL=bootstrap.js.map
@@ -4,22 +4,25 @@
4
4
  * Connects provisioned resource properties to the SDK's expression resolver,
5
5
  * then writes the results to .env files.
6
6
  */
7
- import { type NormalizedComponent, type NormalizedLaunch, type Secret } from "@launchfile/sdk";
7
+ import { type NormalizedComponent, type NormalizedLaunch, type ResolverContext, type Secret } from "@launchfile/sdk";
8
8
  import type { ResourceProperties } from "./resources/types.js";
9
+ export type { ResolverContext };
9
10
  /**
10
- * Context for the SDK's resolveExpression().
11
- * Mirrors the interface from sdk/src/resolver.ts since it's not exported.
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: ""`.
15
+ *
16
+ * For multi-exposed-component apps that need a specific component's URL,
17
+ * use `$components.<name>.url` instead — `$app.*` always points at the
18
+ * first exposed component to give a single, predictable answer.
12
19
  */
13
- export interface ResolverContext {
14
- resource?: Record<string, string | number>;
15
- resources?: Record<string, Record<string, string | number>>;
16
- components?: Record<string, Record<string, string | number>>;
17
- secrets?: Record<string, string>;
18
- }
20
+ export declare function computeAppProperties(launch: NormalizedLaunch, componentPorts: Record<string, number>): Record<string, string | number>;
19
21
  /**
20
- * Build a ResolverContext from provisioned resources, component ports, and secrets.
22
+ * Build a ResolverContext from provisioned resources, component ports,
23
+ * secrets, and (D-33) the platform-injected app properties.
21
24
  */
22
- export declare function buildResolverContext(resourceMap: Record<string, ResourceProperties>, componentPorts: Record<string, number>, secrets: Record<string, string>): ResolverContext;
25
+ export declare function buildResolverContext(resourceMap: Record<string, ResourceProperties>, componentPorts: Record<string, number>, secrets: Record<string, string>, app: Record<string, string | number>): ResolverContext;
23
26
  /**
24
27
  * Resolve all environment variables for a single component.
25
28
  */