@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.
- package/dist/bootstrap.d.ts +56 -8
- package/dist/bootstrap.js +170 -93
- package/dist/env-writer.d.ts +40 -11
- package/dist/env-writer.js +84 -23
- package/dist/health.d.ts +8 -2
- package/dist/health.js +11 -17
- package/dist/prereqs.js +2 -2
- package/dist/process-manager.d.ts +13 -0
- package/dist/process-manager.js +59 -3
- package/dist/process-stopper.d.ts +99 -0
- package/dist/process-stopper.js +165 -0
- package/dist/provider.d.ts +50 -0
- package/dist/provider.js +337 -28
- package/dist/redact.d.ts +55 -0
- package/dist/redact.js +92 -0
- package/dist/resources/identifiers.d.ts +22 -0
- package/dist/resources/identifiers.js +31 -0
- package/dist/resources/index.d.ts +3 -3
- package/dist/resources/index.js +21 -10
- package/dist/resources/mysql.d.ts +3 -1
- package/dist/resources/mysql.js +49 -11
- package/dist/resources/postgres.d.ts +4 -1
- package/dist/resources/postgres.js +63 -17
- package/dist/resources/redis.d.ts +3 -1
- package/dist/resources/redis.js +14 -4
- package/dist/resources/sqlite.js +2 -2
- package/dist/resources/types.d.ts +21 -3
- package/dist/runtimes/bun.js +2 -2
- package/dist/runtimes/installed-versions.d.ts +12 -0
- package/dist/runtimes/installed-versions.js +21 -0
- package/dist/runtimes/node.js +13 -8
- package/dist/runtimes/python.js +14 -7
- package/dist/runtimes/ruby.js +13 -8
- package/dist/secret-generator.js +11 -3
- package/dist/shell.d.ts +28 -6
- package/dist/shell.js +83 -28
- package/dist/state.d.ts +38 -0
- package/dist/state.js +12 -1
- package/dist/storage.d.ts +16 -3
- package/dist/storage.js +23 -6
- package/package.json +7 -4
- package/dist/__tests__/bootstrap.test.d.ts +0 -2
- package/dist/__tests__/bootstrap.test.js +0 -90
- package/dist/__tests__/dry-run.test.d.ts +0 -2
- package/dist/__tests__/dry-run.test.js +0 -141
- package/dist/__tests__/env-writer.test.d.ts +0 -2
- package/dist/__tests__/env-writer.test.js +0 -201
- package/dist/__tests__/lockfile-detect.test.d.ts +0 -2
- package/dist/__tests__/lockfile-detect.test.js +0 -75
- package/dist/__tests__/port-allocator.test.d.ts +0 -2
- package/dist/__tests__/port-allocator.test.js +0 -53
- package/dist/__tests__/secret-generator.test.d.ts +0 -2
- package/dist/__tests__/secret-generator.test.js +0 -26
- package/dist/__tests__/state.test.d.ts +0 -2
- package/dist/__tests__/state.test.js +0 -30
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guards for values that reach a SQL statement by interpolation.
|
|
3
|
+
*
|
|
4
|
+
* On a first run these values are safe by construction: the database and user
|
|
5
|
+
* names derive from a schema-validated app name, and `generatePassword()`
|
|
6
|
+
* emits base64url. Every later run reuses whatever `.launchfile/state.json`
|
|
7
|
+
* holds, and `loadState()` (state.ts) `JSON.parse`s that file with no
|
|
8
|
+
* validation. The file sits inside the cloned repo, so on the reuse path every
|
|
9
|
+
* value below is attacker-controlled.
|
|
10
|
+
*
|
|
11
|
+
* Argv execution keeps the shell out of these commands; these checks keep the
|
|
12
|
+
* SQL parser out of them. Both are needed: `mysql -e` runs `;`-separated
|
|
13
|
+
* statements and connects as root, so a quote that escapes an identifier or a
|
|
14
|
+
* password literal is a full statement injection with no shell involved.
|
|
15
|
+
*/
|
|
16
|
+
/** Alphanumeric + underscore — safe as a SQL identifier and as a shell arg. */
|
|
17
|
+
export const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
18
|
+
/** base64url, the exact alphabet `generatePassword()` emits. No quote fits. */
|
|
19
|
+
export const SAFE_PASSWORD = /^[A-Za-z0-9_-]+$/;
|
|
20
|
+
export function assertSafeIdentifier(value, label) {
|
|
21
|
+
if (!SAFE_IDENTIFIER.test(value)) {
|
|
22
|
+
throw new Error(`Invalid ${label} in .launchfile/state.json: ${JSON.stringify(value)}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function assertSafePassword(value) {
|
|
26
|
+
// The value itself never reaches the message — it is a live credential.
|
|
27
|
+
if (!SAFE_PASSWORD.test(value)) {
|
|
28
|
+
throw new Error("Invalid database password in .launchfile/state.json: expected base64url");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=identifiers.js.map
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Maps resource type names to their provisioner implementations.
|
|
5
5
|
*/
|
|
6
|
-
import type { ResourceProvisioner } from "./types.js";
|
|
7
|
-
export declare function getProvisioner(type: string): ResourceProvisioner | undefined;
|
|
6
|
+
import type { ResourceProvisioner, ShellRunner } from "./types.js";
|
|
7
|
+
export declare function getProvisioner(type: string, deps?: Partial<ShellRunner>): ResourceProvisioner | undefined;
|
|
8
8
|
export declare function supportedResourceTypes(): string[];
|
|
9
|
-
export type { ResourceProvisioner, ResourceProperties, ProvisionOpts } from "./types.js";
|
|
9
|
+
export type { ResourceProvisioner, ResourceProperties, ProvisionOpts, ShellRunner, } from "./types.js";
|
|
10
10
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/resources/index.js
CHANGED
|
@@ -7,17 +7,28 @@ import { PostgresProvisioner } from "./postgres.js";
|
|
|
7
7
|
import { RedisProvisioner } from "./redis.js";
|
|
8
8
|
import { SqliteProvisioner } from "./sqlite.js";
|
|
9
9
|
import { MysqlProvisioner } from "./mysql.js";
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
10
|
+
/**
|
|
11
|
+
* One factory per supported type, rather than one instance, so a caller can
|
|
12
|
+
* supply the shell a provisioner runs against. The conformance test
|
|
13
|
+
* (`__tests__/resource-conformance.test.ts`) is the caller that needs it: it
|
|
14
|
+
* walks this registry so the check covers every supported type, and no type can
|
|
15
|
+
* fall out of the check by being listed in only one of two places.
|
|
16
|
+
*
|
|
17
|
+
* The map has a null prototype, so a lookup keyed by a resource type read out
|
|
18
|
+
* of a Launchfile resolves to `undefined` for `constructor`, `__proto__` and
|
|
19
|
+
* every other `Object.prototype` key rather than an inherited value.
|
|
20
|
+
*/
|
|
21
|
+
const factories = Object.assign(Object.create(null), {
|
|
22
|
+
postgres: (deps) => new PostgresProvisioner(deps),
|
|
23
|
+
redis: (deps) => new RedisProvisioner(deps),
|
|
24
|
+
sqlite: () => new SqliteProvisioner(),
|
|
25
|
+
mysql: (deps) => new MysqlProvisioner(deps),
|
|
26
|
+
mariadb: (deps) => new MysqlProvisioner(deps), // MariaDB is MySQL-compatible
|
|
27
|
+
});
|
|
28
|
+
export function getProvisioner(type, deps) {
|
|
29
|
+
return factories[type]?.(deps);
|
|
19
30
|
}
|
|
20
31
|
export function supportedResourceTypes() {
|
|
21
|
-
return Object.keys(
|
|
32
|
+
return Object.keys(factories);
|
|
22
33
|
}
|
|
23
34
|
//# sourceMappingURL=index.js.map
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import type { NormalizedRequirement } from "@launchfile/sdk";
|
|
5
5
|
import type { ResourceState } from "../state.js";
|
|
6
|
-
import type { ProvisionOpts, ResourceProperties, ResourceProvisioner } from "./types.js";
|
|
6
|
+
import type { ProvisionOpts, ResourceProperties, ResourceProvisioner, ShellRunner } from "./types.js";
|
|
7
7
|
export declare class MysqlProvisioner implements ResourceProvisioner {
|
|
8
|
+
#private;
|
|
8
9
|
readonly type = "mysql";
|
|
10
|
+
constructor(deps?: Partial<ShellRunner>);
|
|
9
11
|
isRunning(): Promise<boolean>;
|
|
10
12
|
provision(req: NormalizedRequirement, opts: ProvisionOpts, existingState?: ResourceState): Promise<{
|
|
11
13
|
properties: ResourceProperties;
|
package/dist/resources/mysql.js
CHANGED
|
@@ -3,22 +3,40 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { shell, shellOk } from "../shell.js";
|
|
5
5
|
import { generatePassword } from "../secret-generator.js";
|
|
6
|
+
import { assertSafeIdentifier, assertSafePassword, SAFE_IDENTIFIER, } from "./identifiers.js";
|
|
6
7
|
const DEFAULT_PORT = 3306;
|
|
7
8
|
const DEFAULT_HOST = "localhost";
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
/** Connection flags shared by every mysql invocation, as argv elements. */
|
|
10
|
+
function mysqlArgs() {
|
|
11
|
+
return ["-h", DEFAULT_HOST, "-u", "root"];
|
|
12
|
+
}
|
|
10
13
|
export class MysqlProvisioner {
|
|
11
14
|
type = "mysql";
|
|
15
|
+
#shell;
|
|
16
|
+
#shellOk;
|
|
17
|
+
constructor(deps = {}) {
|
|
18
|
+
this.#shell = deps.shell ?? shell;
|
|
19
|
+
this.#shellOk = deps.shellOk ?? shellOk;
|
|
20
|
+
}
|
|
12
21
|
async isRunning() {
|
|
13
|
-
return shellOk("mysqladmin
|
|
22
|
+
return this.#shellOk("mysqladmin", [
|
|
23
|
+
"ping",
|
|
24
|
+
"-h",
|
|
25
|
+
DEFAULT_HOST,
|
|
26
|
+
"--silent",
|
|
27
|
+
]);
|
|
14
28
|
}
|
|
15
29
|
async provision(req, opts, existingState) {
|
|
16
30
|
if (!(await this.isRunning())) {
|
|
17
31
|
console.log(" Starting MySQL via brew...");
|
|
18
|
-
const started = await shellOk("brew
|
|
32
|
+
const started = await this.#shellOk("brew", [
|
|
33
|
+
"services",
|
|
34
|
+
"start",
|
|
35
|
+
"mysql",
|
|
36
|
+
]);
|
|
19
37
|
if (!started) {
|
|
20
|
-
await shell("brew install mysql");
|
|
21
|
-
await shell("brew services start mysql");
|
|
38
|
+
await this.#shell("brew", ["install", "mysql"]);
|
|
39
|
+
await this.#shell("brew", ["services", "start", "mysql"]);
|
|
22
40
|
}
|
|
23
41
|
}
|
|
24
42
|
const resourceName = req.name ?? req.type;
|
|
@@ -26,11 +44,27 @@ export class MysqlProvisioner {
|
|
|
26
44
|
const dbName = existingState?.dbName ?? `launchfile_${safeName}`;
|
|
27
45
|
const user = existingState?.user ?? `launchfile_${safeName}`;
|
|
28
46
|
const password = existingState?.password ?? generatePassword();
|
|
47
|
+
// Security: all three are interpolated into the SQL below, and `mysql -e`
|
|
48
|
+
// runs `;`-separated statements as root. Fresh values cannot fail these
|
|
49
|
+
// checks; values reused from state.json are unvalidated JSON (state.ts).
|
|
50
|
+
assertSafeIdentifier(dbName, "database name");
|
|
51
|
+
assertSafeIdentifier(user, "database user");
|
|
52
|
+
assertSafePassword(password);
|
|
29
53
|
const port = DEFAULT_PORT;
|
|
30
54
|
// Create database and user (idempotent)
|
|
31
|
-
await shell(
|
|
32
|
-
await shell(
|
|
33
|
-
|
|
55
|
+
await this.#shell("mysql", [...mysqlArgs(), "-e", `CREATE DATABASE IF NOT EXISTS \`${dbName}\`;`], { allowFailure: true });
|
|
56
|
+
await this.#shell("mysql", [
|
|
57
|
+
...mysqlArgs(),
|
|
58
|
+
"-e",
|
|
59
|
+
`CREATE USER IF NOT EXISTS '${user}'@'${DEFAULT_HOST}' IDENTIFIED BY '${password}';`,
|
|
60
|
+
],
|
|
61
|
+
// silent: this command embeds the generated DB password; don't echo it (CWE-532).
|
|
62
|
+
{ allowFailure: true, silent: true });
|
|
63
|
+
await this.#shell("mysql", [
|
|
64
|
+
...mysqlArgs(),
|
|
65
|
+
"-e",
|
|
66
|
+
`GRANT ALL PRIVILEGES ON \`${dbName}\`.* TO '${user}'@'${DEFAULT_HOST}';`,
|
|
67
|
+
], { allowFailure: true });
|
|
34
68
|
const url = `mysql://${user}:${password}@${DEFAULT_HOST}:${port}/${dbName}`;
|
|
35
69
|
const properties = {
|
|
36
70
|
url,
|
|
@@ -54,10 +88,14 @@ export class MysqlProvisioner {
|
|
|
54
88
|
async destroy(state) {
|
|
55
89
|
// Security: state values come from disk (state.json) — validate before SQL interpolation
|
|
56
90
|
if (state.dbName && SAFE_IDENTIFIER.test(state.dbName)) {
|
|
57
|
-
await shell(
|
|
91
|
+
await this.#shell("mysql", [...mysqlArgs(), "-e", `DROP DATABASE IF EXISTS \`${state.dbName}\`;`], { allowFailure: true });
|
|
58
92
|
}
|
|
59
93
|
if (state.user && SAFE_IDENTIFIER.test(state.user)) {
|
|
60
|
-
await shell(
|
|
94
|
+
await this.#shell("mysql", [
|
|
95
|
+
...mysqlArgs(),
|
|
96
|
+
"-e",
|
|
97
|
+
`DROP USER IF EXISTS '${state.user}'@'${DEFAULT_HOST}';`,
|
|
98
|
+
], { allowFailure: true });
|
|
61
99
|
}
|
|
62
100
|
}
|
|
63
101
|
}
|
|
@@ -9,9 +9,12 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { NormalizedRequirement } from "@launchfile/sdk";
|
|
11
11
|
import type { ResourceState } from "../state.js";
|
|
12
|
-
import type { ProvisionOpts, ResourceProperties, ResourceProvisioner } from "./types.js";
|
|
12
|
+
import type { ProvisionOpts, ResourceProperties, ResourceProvisioner, ShellRunner } from "./types.js";
|
|
13
|
+
export type { ShellRunner };
|
|
13
14
|
export declare class PostgresProvisioner implements ResourceProvisioner {
|
|
15
|
+
#private;
|
|
14
16
|
readonly type = "postgres";
|
|
17
|
+
constructor(deps?: Partial<ShellRunner>);
|
|
15
18
|
isRunning(): Promise<boolean>;
|
|
16
19
|
provision(req: NormalizedRequirement, opts: ProvisionOpts, existingState?: ResourceState): Promise<{
|
|
17
20
|
properties: ResourceProperties;
|
|
@@ -9,43 +9,81 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { shell, shellOk } from "../shell.js";
|
|
11
11
|
import { generatePassword } from "../secret-generator.js";
|
|
12
|
+
import { assertSafeIdentifier, assertSafePassword, SAFE_IDENTIFIER, } from "./identifiers.js";
|
|
12
13
|
const DEFAULT_PORT = 5432;
|
|
13
14
|
const DEFAULT_HOST = "localhost";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
const READY_TIMEOUT_SECONDS = 10;
|
|
16
|
+
/** Connection flags shared by every psql invocation, as argv elements. */
|
|
17
|
+
function psqlArgs(port, database) {
|
|
18
|
+
return ["-h", DEFAULT_HOST, "-p", String(port), database];
|
|
19
|
+
}
|
|
17
20
|
export class PostgresProvisioner {
|
|
18
21
|
type = "postgres";
|
|
22
|
+
#shell;
|
|
23
|
+
#shellOk;
|
|
24
|
+
constructor(deps = {}) {
|
|
25
|
+
this.#shell = deps.shell ?? shell;
|
|
26
|
+
this.#shellOk = deps.shellOk ?? shellOk;
|
|
27
|
+
}
|
|
19
28
|
async isRunning() {
|
|
20
|
-
return shellOk("pg_isready -q");
|
|
29
|
+
return this.#shellOk("pg_isready", ["-q"]);
|
|
21
30
|
}
|
|
22
31
|
async provision(req, opts, existingState) {
|
|
23
32
|
// Ensure postgres is running
|
|
24
33
|
if (!(await this.isRunning())) {
|
|
25
34
|
console.log(" Starting PostgreSQL via brew...");
|
|
26
35
|
// Try to start; if not installed, install first
|
|
27
|
-
const started = await shellOk("brew
|
|
36
|
+
const started = await this.#shellOk("brew", [
|
|
37
|
+
"services",
|
|
38
|
+
"start",
|
|
39
|
+
"postgresql",
|
|
40
|
+
]);
|
|
28
41
|
if (!started) {
|
|
29
42
|
// Try versioned formula
|
|
30
|
-
await shell("brew install postgresql@16");
|
|
31
|
-
await shell("brew services start postgresql@16");
|
|
43
|
+
await this.#shell("brew", ["install", "postgresql@16"]);
|
|
44
|
+
await this.#shell("brew", ["services", "start", "postgresql@16"]);
|
|
32
45
|
}
|
|
33
46
|
}
|
|
34
|
-
//
|
|
35
|
-
|
|
47
|
+
// pg_isready blocks until the server answers or the timeout expires, so a
|
|
48
|
+
// false here means Postgres never came up — every psql call below would
|
|
49
|
+
// fail with a connection error that says nothing about the real cause.
|
|
50
|
+
const ready = await this.#shellOk("pg_isready", [
|
|
51
|
+
`--timeout=${READY_TIMEOUT_SECONDS}`,
|
|
52
|
+
]);
|
|
53
|
+
if (!ready) {
|
|
54
|
+
throw new Error(`PostgreSQL did not accept connections within ${READY_TIMEOUT_SECONDS}s. ` +
|
|
55
|
+
`Check \`brew services list\` and \`pg_isready\`.`);
|
|
56
|
+
}
|
|
36
57
|
// Determine database and user names
|
|
37
58
|
const resourceName = req.name ?? req.type;
|
|
38
59
|
const safeName = opts.appName.replace(/-/g, "_");
|
|
39
60
|
const dbName = existingState?.dbName ?? `launchfile_${safeName}`;
|
|
40
61
|
const user = existingState?.user ?? `launchfile_${safeName}`;
|
|
41
62
|
const password = existingState?.password ?? generatePassword();
|
|
63
|
+
// Security: all three are interpolated into the psql commands below. A
|
|
64
|
+
// schema-validated app name and a generated password can never fail these
|
|
65
|
+
// checks, but existingState comes from the on-disk state file, which is
|
|
66
|
+
// JSON.parse'd without validation (state.ts).
|
|
67
|
+
assertSafeIdentifier(dbName, "database name");
|
|
68
|
+
assertSafeIdentifier(user, "database user");
|
|
69
|
+
assertSafePassword(password);
|
|
42
70
|
const port = DEFAULT_PORT;
|
|
43
71
|
// Create user (idempotent)
|
|
44
|
-
await shell(
|
|
72
|
+
await this.#shell("psql", [
|
|
73
|
+
...psqlArgs(port, "postgres"),
|
|
74
|
+
"-c",
|
|
75
|
+
`DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${user}') THEN CREATE ROLE ${user} WITH LOGIN PASSWORD '${password}' CREATEDB; END IF; END $$;`,
|
|
76
|
+
],
|
|
77
|
+
// silent: this command embeds the generated DB password; don't echo it (CWE-532).
|
|
78
|
+
{ allowFailure: true, silent: true });
|
|
45
79
|
// Create database (idempotent)
|
|
46
|
-
const dbExists = await shellOk(
|
|
80
|
+
const dbExists = await this.#shellOk("psql", [
|
|
81
|
+
...psqlArgs(port, "postgres"),
|
|
82
|
+
"-tAc",
|
|
83
|
+
`SELECT 1 FROM pg_database WHERE datname='${dbName}'`,
|
|
84
|
+
]);
|
|
47
85
|
if (!dbExists) {
|
|
48
|
-
await shell(
|
|
86
|
+
await this.#shell("createdb", ["-h", DEFAULT_HOST, "-p", String(port), "-O", user, dbName], { allowFailure: true });
|
|
49
87
|
}
|
|
50
88
|
// Handle extensions from config
|
|
51
89
|
// Security: extension names come from Launchfile config (untrusted input).
|
|
@@ -59,7 +97,11 @@ export class PostgresProvisioner {
|
|
|
59
97
|
console.warn(` Skipping invalid extension name: ${ext}`);
|
|
60
98
|
continue;
|
|
61
99
|
}
|
|
62
|
-
await shell(
|
|
100
|
+
await this.#shell("psql", [
|
|
101
|
+
...psqlArgs(port, dbName),
|
|
102
|
+
"-c",
|
|
103
|
+
`CREATE EXTENSION IF NOT EXISTS "${ext}";`,
|
|
104
|
+
], { allowFailure: true });
|
|
63
105
|
}
|
|
64
106
|
}
|
|
65
107
|
const url = `postgresql://${user}:${password}@${DEFAULT_HOST}:${port}/${dbName}`;
|
|
@@ -85,12 +127,16 @@ export class PostgresProvisioner {
|
|
|
85
127
|
async destroy(state) {
|
|
86
128
|
// Security: state values come from disk (state.json) — validate before SQL interpolation
|
|
87
129
|
if (state.dbName && SAFE_IDENTIFIER.test(state.dbName)) {
|
|
88
|
-
await shell(
|
|
89
|
-
allowFailure: true,
|
|
90
|
-
});
|
|
130
|
+
await this.#shell("dropdb", ["-h", DEFAULT_HOST, "--if-exists", state.dbName], { allowFailure: true });
|
|
91
131
|
}
|
|
92
132
|
if (state.user && SAFE_IDENTIFIER.test(state.user)) {
|
|
93
|
-
await shell(
|
|
133
|
+
await this.#shell("psql", [
|
|
134
|
+
"-h",
|
|
135
|
+
DEFAULT_HOST,
|
|
136
|
+
"postgres",
|
|
137
|
+
"-c",
|
|
138
|
+
`DROP ROLE IF EXISTS ${state.user};`,
|
|
139
|
+
], { allowFailure: true });
|
|
94
140
|
}
|
|
95
141
|
// Don't stop the brew service — it's shared
|
|
96
142
|
}
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import type { NormalizedRequirement } from "@launchfile/sdk";
|
|
5
5
|
import type { ResourceState } from "../state.js";
|
|
6
|
-
import type { ProvisionOpts, ResourceProperties, ResourceProvisioner } from "./types.js";
|
|
6
|
+
import type { ProvisionOpts, ResourceProperties, ResourceProvisioner, ShellRunner } from "./types.js";
|
|
7
7
|
export declare class RedisProvisioner implements ResourceProvisioner {
|
|
8
|
+
#private;
|
|
8
9
|
readonly type = "redis";
|
|
10
|
+
constructor(deps?: Partial<ShellRunner>);
|
|
9
11
|
isRunning(): Promise<boolean>;
|
|
10
12
|
provision(req: NormalizedRequirement, _opts: ProvisionOpts, _existingState?: ResourceState): Promise<{
|
|
11
13
|
properties: ResourceProperties;
|
package/dist/resources/redis.js
CHANGED
|
@@ -6,16 +6,26 @@ const DEFAULT_PORT = 6379;
|
|
|
6
6
|
const DEFAULT_HOST = "localhost";
|
|
7
7
|
export class RedisProvisioner {
|
|
8
8
|
type = "redis";
|
|
9
|
+
#shell;
|
|
10
|
+
#shellOk;
|
|
11
|
+
constructor(deps = {}) {
|
|
12
|
+
this.#shell = deps.shell ?? shell;
|
|
13
|
+
this.#shellOk = deps.shellOk ?? shellOk;
|
|
14
|
+
}
|
|
9
15
|
async isRunning() {
|
|
10
|
-
return shellOk("redis-cli ping");
|
|
16
|
+
return this.#shellOk("redis-cli", ["ping"]);
|
|
11
17
|
}
|
|
12
18
|
async provision(req, _opts, _existingState) {
|
|
13
19
|
if (!(await this.isRunning())) {
|
|
14
20
|
console.log(" Starting Redis via brew...");
|
|
15
|
-
const started = await shellOk("brew
|
|
21
|
+
const started = await this.#shellOk("brew", [
|
|
22
|
+
"services",
|
|
23
|
+
"start",
|
|
24
|
+
"redis",
|
|
25
|
+
]);
|
|
16
26
|
if (!started) {
|
|
17
|
-
await shell("brew install redis");
|
|
18
|
-
await shell("brew services start redis");
|
|
27
|
+
await this.#shell("brew", ["install", "redis"]);
|
|
28
|
+
await this.#shell("brew", ["services", "start", "redis"]);
|
|
19
29
|
}
|
|
20
30
|
}
|
|
21
31
|
const port = DEFAULT_PORT;
|
package/dist/resources/sqlite.js
CHANGED
|
@@ -14,10 +14,10 @@ export class SqliteProvisioner {
|
|
|
14
14
|
const dataDir = join(opts.projectDir, ".launchfile", "data", "sqlite");
|
|
15
15
|
await mkdir(dataDir, { recursive: true });
|
|
16
16
|
const dbPath = join(dataDir, `${safeName}.db`);
|
|
17
|
+
// SPEC.md § Resource Property Vocabulary gives sqlite `url` and `path`
|
|
18
|
+
// only. A file has no host and no port, so neither is exposed.
|
|
17
19
|
const properties = {
|
|
18
20
|
url: `sqlite://${dbPath}`,
|
|
19
|
-
host: "",
|
|
20
|
-
port: 0,
|
|
21
21
|
path: dbPath,
|
|
22
22
|
};
|
|
23
23
|
const state = {
|
|
@@ -5,12 +5,22 @@
|
|
|
5
5
|
* to handle install, start, database creation, and teardown.
|
|
6
6
|
*/
|
|
7
7
|
import type { NormalizedRequirement } from "@launchfile/sdk";
|
|
8
|
+
import type { shell, shellOk } from "../shell.js";
|
|
8
9
|
import type { ResourceState } from "../state.js";
|
|
9
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Properties that a provisioned resource exposes for expression resolution.
|
|
12
|
+
*
|
|
13
|
+
* Only `url` is required. A resource type addressed by something other than a
|
|
14
|
+
* network endpoint — sqlite is a file — has no host and no port, and fabricating
|
|
15
|
+
* `host: ""` / `port: 0` to satisfy the type would report a value an app could
|
|
16
|
+
* act on (PROVIDERS.md §10.8, D-52). Omitted properties resolve to the empty
|
|
17
|
+
* string through the standard unknown-property rule, so leaving them out costs
|
|
18
|
+
* the caller nothing.
|
|
19
|
+
*/
|
|
10
20
|
export interface ResourceProperties {
|
|
11
21
|
url: string;
|
|
12
|
-
host
|
|
13
|
-
port
|
|
22
|
+
host?: string;
|
|
23
|
+
port?: number;
|
|
14
24
|
user?: string;
|
|
15
25
|
password?: string;
|
|
16
26
|
name?: string;
|
|
@@ -21,6 +31,14 @@ export interface ResourceProperties {
|
|
|
21
31
|
region?: string;
|
|
22
32
|
[key: string]: string | number | undefined;
|
|
23
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* The shell surface a provisioner runs against. Injecting it lets tests drive
|
|
36
|
+
* the provisioning sequence without Homebrew or a live server.
|
|
37
|
+
*/
|
|
38
|
+
export interface ShellRunner {
|
|
39
|
+
shell: typeof shell;
|
|
40
|
+
shellOk: typeof shellOk;
|
|
41
|
+
}
|
|
24
42
|
export interface ProvisionOpts {
|
|
25
43
|
appName: string;
|
|
26
44
|
projectDir: string;
|
package/dist/runtimes/bun.js
CHANGED
|
@@ -9,8 +9,8 @@ export class BunInstaller {
|
|
|
9
9
|
return undefined;
|
|
10
10
|
}
|
|
11
11
|
async install(_version) {
|
|
12
|
-
if (!(await shellOk("which bun"))) {
|
|
13
|
-
await shell("brew install bun");
|
|
12
|
+
if (!(await shellOk("which", ["bun"]))) {
|
|
13
|
+
await shell("brew", ["install", "bun"]);
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
async shellEnv(_version) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exact-match lookup against a version manager's installed list.
|
|
3
|
+
*
|
|
4
|
+
* `rbenv versions --bare` and `pyenv versions --bare` both print one version
|
|
5
|
+
* per line, so the lines are compared directly. A
|
|
6
|
+
* `… --bare | grep -q "^${version}$"` pipe would be wrong twice over: it feeds
|
|
7
|
+
* an untrusted version string to a shell, and `grep` reads it as a regex, so
|
|
8
|
+
* every `.` matches any character — an installed `3x1` would satisfy a request
|
|
9
|
+
* for `3.1` and silently skip the install.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isInstalled(tool: "rbenv" | "pyenv", version: string): Promise<boolean>;
|
|
12
|
+
//# sourceMappingURL=installed-versions.d.ts.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exact-match lookup against a version manager's installed list.
|
|
3
|
+
*
|
|
4
|
+
* `rbenv versions --bare` and `pyenv versions --bare` both print one version
|
|
5
|
+
* per line, so the lines are compared directly. A
|
|
6
|
+
* `… --bare | grep -q "^${version}$"` pipe would be wrong twice over: it feeds
|
|
7
|
+
* an untrusted version string to a shell, and `grep` reads it as a regex, so
|
|
8
|
+
* every `.` matches any character — an installed `3x1` would satisfy a request
|
|
9
|
+
* for `3.1` and silently skip the install.
|
|
10
|
+
*/
|
|
11
|
+
import { shell } from "../shell.js";
|
|
12
|
+
export async function isInstalled(tool, version) {
|
|
13
|
+
const result = await shell(tool, ["versions", "--bare"], {
|
|
14
|
+
silent: true,
|
|
15
|
+
allowFailure: true,
|
|
16
|
+
});
|
|
17
|
+
if (result.exitCode !== 0)
|
|
18
|
+
return false;
|
|
19
|
+
return result.stdout.split("\n").some((line) => line.trim() === version);
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=installed-versions.js.map
|
package/dist/runtimes/node.js
CHANGED
|
@@ -30,23 +30,28 @@ export class NodeInstaller {
|
|
|
30
30
|
return undefined;
|
|
31
31
|
}
|
|
32
32
|
async install(version) {
|
|
33
|
+
// `version` is the verbatim contents of .nvmrc / .node-version /
|
|
34
|
+
// package.json engines.node in the target repo — untrusted input. It goes
|
|
35
|
+
// in as an argv element so it can never be read as shell syntax.
|
|
33
36
|
// Prefer fnm (faster), fall back to nvm
|
|
34
|
-
if (await shellOk("which fnm")) {
|
|
35
|
-
await shell(
|
|
37
|
+
if (await shellOk("which", ["fnm"])) {
|
|
38
|
+
await shell("fnm", ["install", version]);
|
|
36
39
|
}
|
|
37
|
-
else if (await shellOk("which nvm")) {
|
|
38
|
-
await shell(
|
|
40
|
+
else if (await shellOk("which", ["nvm"])) {
|
|
41
|
+
await shell("nvm", ["install", version]);
|
|
39
42
|
}
|
|
40
43
|
else {
|
|
41
44
|
console.log(" Installing fnm via brew...");
|
|
42
|
-
await shell("brew install fnm");
|
|
43
|
-
await shell(
|
|
45
|
+
await shell("brew", ["install", "fnm"]);
|
|
46
|
+
await shell("fnm", ["install", version]);
|
|
44
47
|
}
|
|
45
48
|
}
|
|
46
49
|
async shellEnv(_version) {
|
|
47
|
-
if (await shellOk("which fnm")) {
|
|
50
|
+
if (await shellOk("which", ["fnm"])) {
|
|
48
51
|
try {
|
|
49
|
-
const result = await shell("fnm env --shell bash", {
|
|
52
|
+
const result = await shell("fnm", ["env", "--shell", "bash"], {
|
|
53
|
+
silent: true,
|
|
54
|
+
});
|
|
50
55
|
return parseShellEnv(result.stdout);
|
|
51
56
|
}
|
|
52
57
|
catch {
|
package/dist/runtimes/python.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { readFile } from "node:fs/promises";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { shell, shellOk } from "../shell.js";
|
|
7
|
+
import { isInstalled } from "./installed-versions.js";
|
|
7
8
|
export class PythonInstaller {
|
|
8
9
|
runtime = "python";
|
|
9
10
|
async detectVersion(projectDir) {
|
|
@@ -16,17 +17,23 @@ export class PythonInstaller {
|
|
|
16
17
|
}
|
|
17
18
|
}
|
|
18
19
|
async install(version) {
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
// `version` is the verbatim contents of .python-version in the target repo
|
|
21
|
+
// — untrusted input. It goes in as an argv element so it can never be read
|
|
22
|
+
// as shell syntax.
|
|
23
|
+
if (!(await shellOk("which", ["pyenv"]))) {
|
|
24
|
+
await shell("brew", ["install", "pyenv"]);
|
|
21
25
|
}
|
|
22
|
-
if (!(await
|
|
23
|
-
await shell(
|
|
26
|
+
if (!(await isInstalled("pyenv", version))) {
|
|
27
|
+
await shell("pyenv", ["install", version]);
|
|
24
28
|
}
|
|
25
|
-
await shell(
|
|
29
|
+
await shell("pyenv", ["local", version]);
|
|
26
30
|
}
|
|
27
31
|
async shellEnv(_version) {
|
|
28
|
-
if (await shellOk("which pyenv")) {
|
|
29
|
-
const result = await shell("pyenv root", {
|
|
32
|
+
if (await shellOk("which", ["pyenv"])) {
|
|
33
|
+
const result = await shell("pyenv", ["root"], {
|
|
34
|
+
silent: true,
|
|
35
|
+
allowFailure: true,
|
|
36
|
+
});
|
|
30
37
|
if (result.exitCode === 0) {
|
|
31
38
|
const root = result.stdout.trim();
|
|
32
39
|
return { PATH: `${root}/shims:${process.env.PATH ?? ""}` };
|
package/dist/runtimes/ruby.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { readFile } from "node:fs/promises";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { shell, shellOk } from "../shell.js";
|
|
7
|
+
import { isInstalled } from "./installed-versions.js";
|
|
7
8
|
export class RubyInstaller {
|
|
8
9
|
runtime = "ruby";
|
|
9
10
|
async detectVersion(projectDir) {
|
|
@@ -16,19 +17,23 @@ export class RubyInstaller {
|
|
|
16
17
|
}
|
|
17
18
|
}
|
|
18
19
|
async install(version) {
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
// `version` is the verbatim contents of .ruby-version in the target repo —
|
|
21
|
+
// untrusted input. It goes in as an argv element so it can never be read
|
|
22
|
+
// as shell syntax.
|
|
23
|
+
if (!(await shellOk("which", ["rbenv"]))) {
|
|
24
|
+
await shell("brew", ["install", "rbenv", "ruby-build"]);
|
|
21
25
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
await shell(`rbenv install ${version}`);
|
|
26
|
+
if (!(await isInstalled("rbenv", version))) {
|
|
27
|
+
await shell("rbenv", ["install", version]);
|
|
25
28
|
}
|
|
26
|
-
await shell(
|
|
29
|
+
await shell("rbenv", ["local", version]);
|
|
27
30
|
}
|
|
28
31
|
async shellEnv(_version) {
|
|
29
|
-
if (await shellOk("which rbenv")) {
|
|
32
|
+
if (await shellOk("which", ["rbenv"])) {
|
|
30
33
|
try {
|
|
31
|
-
const result = await shell("rbenv init - bash", {
|
|
34
|
+
const result = await shell("rbenv", ["init", "-", "bash"], {
|
|
35
|
+
silent: true,
|
|
36
|
+
});
|
|
32
37
|
return parseRbenvEnv(result.stdout);
|
|
33
38
|
}
|
|
34
39
|
catch {
|
package/dist/secret-generator.js
CHANGED
|
@@ -3,13 +3,19 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
5
5
|
import { allocatePort } from "./port-allocator.js";
|
|
6
|
+
import { registerSecret } from "./redact.js";
|
|
6
7
|
/**
|
|
7
8
|
* Generate a value based on the generator type.
|
|
8
9
|
*/
|
|
9
10
|
export async function generateValue(generator, existingPorts) {
|
|
10
11
|
switch (generator) {
|
|
11
|
-
case "secret":
|
|
12
|
-
|
|
12
|
+
case "secret": {
|
|
13
|
+
// Spec-defined output: 32 bytes of cryptographically random data,
|
|
14
|
+
// hex-encoded (64 characters). See spec/DESIGN.md D-47.
|
|
15
|
+
const secret = randomBytes(32).toString("hex");
|
|
16
|
+
registerSecret(secret);
|
|
17
|
+
return secret;
|
|
18
|
+
}
|
|
13
19
|
case "uuid":
|
|
14
20
|
return randomUUID();
|
|
15
21
|
case "port": {
|
|
@@ -21,6 +27,8 @@ export async function generateValue(generator, existingPorts) {
|
|
|
21
27
|
/** Generate a password suitable for database users */
|
|
22
28
|
export function generatePassword() {
|
|
23
29
|
// URL-safe so it can go in connection strings without encoding
|
|
24
|
-
|
|
30
|
+
const password = randomBytes(24).toString("base64url");
|
|
31
|
+
registerSecret(password);
|
|
32
|
+
return password;
|
|
25
33
|
}
|
|
26
34
|
//# sourceMappingURL=secret-generator.js.map
|