@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.
- package/dist/__tests__/bootstrap.test.d.ts +2 -0
- package/dist/__tests__/bootstrap.test.js +90 -0
- package/dist/__tests__/dry-run.test.js +21 -9
- package/dist/__tests__/env-writer.test.js +82 -8
- package/dist/bootstrap.d.ts +58 -0
- package/dist/bootstrap.js +221 -0
- package/dist/env-writer.d.ts +14 -11
- package/dist/env-writer.js +30 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/provider.js +30 -48
- package/package.json +8 -3
- package/CLAUDE.md +0 -37
- package/dist/__tests__/dry-run.test.d.ts.map +0 -1
- package/dist/__tests__/dry-run.test.js.map +0 -1
- package/dist/__tests__/env-writer.test.d.ts.map +0 -1
- package/dist/__tests__/env-writer.test.js.map +0 -1
- package/dist/__tests__/lockfile-detect.test.d.ts.map +0 -1
- package/dist/__tests__/lockfile-detect.test.js.map +0 -1
- package/dist/__tests__/port-allocator.test.d.ts.map +0 -1
- package/dist/__tests__/port-allocator.test.js.map +0 -1
- package/dist/__tests__/secret-generator.test.d.ts.map +0 -1
- package/dist/__tests__/secret-generator.test.js.map +0 -1
- package/dist/__tests__/state.test.d.ts.map +0 -1
- package/dist/__tests__/state.test.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/env-writer.d.ts.map +0 -1
- package/dist/env-writer.js.map +0 -1
- package/dist/health.d.ts.map +0 -1
- package/dist/health.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/lockfile-detect.d.ts.map +0 -1
- package/dist/lockfile-detect.js.map +0 -1
- package/dist/port-allocator.d.ts.map +0 -1
- package/dist/port-allocator.js.map +0 -1
- package/dist/prereqs.d.ts.map +0 -1
- package/dist/prereqs.js.map +0 -1
- package/dist/process-manager.d.ts.map +0 -1
- package/dist/process-manager.js.map +0 -1
- package/dist/provider.d.ts.map +0 -1
- package/dist/provider.js.map +0 -1
- package/dist/resources/index.d.ts.map +0 -1
- package/dist/resources/index.js.map +0 -1
- package/dist/resources/mysql.d.ts.map +0 -1
- package/dist/resources/mysql.js.map +0 -1
- package/dist/resources/postgres.d.ts.map +0 -1
- package/dist/resources/postgres.js.map +0 -1
- package/dist/resources/redis.d.ts.map +0 -1
- package/dist/resources/redis.js.map +0 -1
- package/dist/resources/sqlite.d.ts.map +0 -1
- package/dist/resources/sqlite.js.map +0 -1
- package/dist/resources/types.d.ts.map +0 -1
- package/dist/resources/types.js.map +0 -1
- package/dist/runtimes/bun.d.ts.map +0 -1
- package/dist/runtimes/bun.js.map +0 -1
- package/dist/runtimes/index.d.ts.map +0 -1
- package/dist/runtimes/index.js.map +0 -1
- package/dist/runtimes/node.d.ts.map +0 -1
- package/dist/runtimes/node.js.map +0 -1
- package/dist/runtimes/python.d.ts.map +0 -1
- package/dist/runtimes/python.js.map +0 -1
- package/dist/runtimes/ruby.d.ts.map +0 -1
- package/dist/runtimes/ruby.js.map +0 -1
- package/dist/runtimes/types.d.ts.map +0 -1
- package/dist/runtimes/types.js.map +0 -1
- package/dist/secret-generator.d.ts.map +0 -1
- package/dist/secret-generator.js.map +0 -1
- package/dist/shell.d.ts.map +0 -1
- package/dist/shell.js.map +0 -1
- package/dist/state.d.ts.map +0 -1
- package/dist/state.js.map +0 -1
- package/dist/storage.d.ts.map +0 -1
- package/dist/storage.js.map +0 -1
- package/src/__tests__/dry-run.test.ts +0 -157
- package/src/__tests__/env-writer.test.ts +0 -156
- package/src/__tests__/lockfile-detect.test.ts +0 -74
- package/src/__tests__/port-allocator.test.ts +0 -59
- package/src/__tests__/secret-generator.test.ts +0 -31
- package/src/__tests__/state.test.ts +0 -33
- package/src/cli.ts +0 -78
- package/src/env-writer.ts +0 -231
- package/src/health.ts +0 -80
- package/src/index.ts +0 -9
- package/src/lockfile-detect.ts +0 -48
- package/src/port-allocator.ts +0 -97
- package/src/prereqs.ts +0 -32
- package/src/process-manager.ts +0 -242
- package/src/provider.ts +0 -415
- package/src/resources/index.ts +0 -29
- package/src/resources/mysql.ts +0 -98
- package/src/resources/postgres.ts +0 -133
- package/src/resources/redis.ts +0 -58
- package/src/resources/sqlite.ts +0 -52
- package/src/resources/types.ts +0 -47
- package/src/runtimes/bun.ts +0 -25
- package/src/runtimes/index.ts +0 -24
- package/src/runtimes/node.ts +0 -71
- package/src/runtimes/python.ts +0 -42
- package/src/runtimes/ruby.ts +0 -55
- package/src/runtimes/types.ts +0 -16
- package/src/secret-generator.ts +0 -32
- package/src/shell.ts +0 -70
- package/src/state.ts +0 -92
- package/src/storage.ts +0 -67
- package/tsconfig.json +0 -18
|
@@ -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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
117
|
+
for (const app of allApps) {
|
|
106
118
|
try {
|
|
107
|
-
const yaml = await readFile(
|
|
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
|
|
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
|
|
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
|
package/dist/env-writer.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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,
|
|
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
|
*/
|