@launchfile/macos-dev 0.3.0 → 0.7.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/redact.js ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Secret redaction for anything this provider prints or embeds in an error.
3
+ *
4
+ * Two independent layers, because either alone leaves a hole:
5
+ *
6
+ * 1. A registry of exact secret values. Every generated secret, every
7
+ * persisted `state.secrets` entry, and every resource password registers
8
+ * itself at creation/load time; `redactSecrets` then scrubs those literals
9
+ * out of any string on its way to stdout/stderr or an Error message.
10
+ * 2. A pattern scrub for credentials embedded in URLs
11
+ * (`scheme://user:pass@host`), which catches secrets that never passed
12
+ * through this provider — e.g. a connection string written literally in a
13
+ * Launchfile `env:` value and interpolated into a bootstrap command.
14
+ *
15
+ * The registry is process-global on purpose: a command string is assembled in
16
+ * one module and printed in another, so the scrub has to be reachable from the
17
+ * sink without threading a context object through every call site.
18
+ */
19
+ export const REDACTED = "[REDACTED]";
20
+ /**
21
+ * Values shorter than this are not registered *by inference*. Short strings
22
+ * appear inside unrelated text by coincidence, and scrubbing them would corrupt
23
+ * the output it is meant to protect. Every secret this provider mints is far
24
+ * longer, so the floor never binds on a minted value.
25
+ *
26
+ * The floor is a heuristic, and a heuristic does not overrule an explicit
27
+ * declaration — see `registerDeclaredSecret`.
28
+ */
29
+ const MIN_SECRET_LENGTH = 8;
30
+ const registry = new Set();
31
+ /**
32
+ * Register a value this provider *inferred* is a secret: one it minted itself,
33
+ * or read back out of its own state. Values below `MIN_SECRET_LENGTH` are
34
+ * dropped — nothing declared them sensitive, so a coincidental match would
35
+ * corrupt output for no gain.
36
+ */
37
+ export function registerSecret(value) {
38
+ if (typeof value !== "string")
39
+ return;
40
+ if (value.length < MIN_SECRET_LENGTH)
41
+ return;
42
+ registry.add(value);
43
+ }
44
+ /**
45
+ * Register a value something *declared* is a secret: an `env:` literal marked
46
+ * `sensitive: true` (D-18), or a value handed over on the operator channel
47
+ * (D-52). No length floor applies.
48
+ *
49
+ * `sensitive: true` on a six-digit PIN is the author stating that value must be
50
+ * masked. Dropping it for being short writes the PIN to disk in plaintext
51
+ * (CWE-532) — the exact failure this registry exists to prevent. Honouring the
52
+ * declaration costs an over-redacted diagnostic where the value also occurs by
53
+ * chance, which is recoverable; the alternative is a leaked credential, which
54
+ * is not.
55
+ *
56
+ * The empty string is rejected: it is not a credential, and an empty separator
57
+ * would splice `[REDACTED]` between every character of the text.
58
+ */
59
+ export function registerDeclaredSecret(value) {
60
+ if (typeof value !== "string" || value === "")
61
+ return;
62
+ registry.add(value);
63
+ }
64
+ /** Register many secret values at once. Non-string entries are ignored. */
65
+ export function registerSecrets(values) {
66
+ for (const value of values)
67
+ registerSecret(value);
68
+ }
69
+ /** Drop every registered secret. Exists for test isolation. */
70
+ export function clearRegisteredSecrets() {
71
+ registry.clear();
72
+ }
73
+ // `scheme://user:password@host` — the password group is everything between the
74
+ // first `:` after the userinfo and the `@`. Userinfo cannot contain `/`, `@`,
75
+ // or whitespace, which bounds the match to a single URL.
76
+ const CREDENTIAL_URL = /([a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s/:@]+:)([^\s/@]+)(@)/g;
77
+ /**
78
+ * Scrub registered secrets and URL-embedded credentials out of `text`.
79
+ *
80
+ * Longest registered values are replaced first so that a secret which is a
81
+ * substring of another (a password inside its own connection URL) cannot leave
82
+ * a partial value behind.
83
+ */
84
+ export function redactSecrets(text) {
85
+ let out = text;
86
+ const values = [...registry].sort((a, b) => b.length - a.length);
87
+ for (const value of values) {
88
+ out = out.split(value).join(REDACTED);
89
+ }
90
+ return out.replace(CREDENTIAL_URL, `$1${REDACTED}$3`);
91
+ }
92
+ //# sourceMappingURL=redact.js.map
@@ -0,0 +1,22 @@
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 declare const SAFE_IDENTIFIER: RegExp;
18
+ /** base64url, the exact alphabet `generatePassword()` emits. No quote fits. */
19
+ export declare const SAFE_PASSWORD: RegExp;
20
+ export declare function assertSafeIdentifier(value: string, label: string): void;
21
+ export declare function assertSafePassword(value: string): void;
22
+ //# sourceMappingURL=identifiers.d.ts.map
@@ -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
@@ -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
- const provisioners = {
11
- postgres: new PostgresProvisioner(),
12
- redis: new RedisProvisioner(),
13
- sqlite: new SqliteProvisioner(),
14
- mysql: new MysqlProvisioner(),
15
- mariadb: new MysqlProvisioner(), // MariaDB is MySQL-compatible
16
- };
17
- export function getProvisioner(type) {
18
- return provisioners[type];
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(provisioners);
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;
@@ -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
- // Security: validate identifiers before interpolating into shell/SQL commands
9
- const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-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 ping -h localhost --silent");
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 services start mysql");
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(`mysql -h ${DEFAULT_HOST} -u root -e "CREATE DATABASE IF NOT EXISTS \\\`${dbName}\\\`;"`, { allowFailure: true });
32
- await shell(`mysql -h ${DEFAULT_HOST} -u root -e "CREATE USER IF NOT EXISTS '${user}'@'${DEFAULT_HOST}' IDENTIFIED BY '${password}';"`, { allowFailure: true });
33
- await shell(`mysql -h ${DEFAULT_HOST} -u root -e "GRANT ALL PRIVILEGES ON \\\`${dbName}\\\`.* TO '${user}'@'${DEFAULT_HOST}';"`, { allowFailure: true });
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(`mysql -h ${DEFAULT_HOST} -u root -e "DROP DATABASE IF EXISTS \\\`${state.dbName}\\\`;"`, { allowFailure: true });
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(`mysql -h ${DEFAULT_HOST} -u root -e "DROP USER IF EXISTS '${state.user}'@'${DEFAULT_HOST}';"`, { allowFailure: true });
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
- // Security: validate identifiers before interpolating into shell/SQL commands.
15
- // Only alphanumeric + underscore — safe for SQL identifiers and shell args.
16
- const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
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 services start postgresql");
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
- // Wait for ready
35
- await shell("pg_isready --timeout=10", { allowFailure: true });
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(`psql -h ${DEFAULT_HOST} -p ${port} postgres -c "DO \\$\\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${user}') THEN CREATE ROLE ${user} WITH LOGIN PASSWORD '${password}' CREATEDB; END IF; END \\$\\$;"`, { allowFailure: true });
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(`psql -h ${DEFAULT_HOST} -p ${port} postgres -tAc "SELECT 1 FROM pg_database WHERE datname='${dbName}'"`);
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(`createdb -h ${DEFAULT_HOST} -p ${port} -O ${user} ${dbName}`, { allowFailure: true });
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(`psql -h ${DEFAULT_HOST} -p ${port} ${dbName} -c "CREATE EXTENSION IF NOT EXISTS \\"${ext}\\";"`, { allowFailure: true });
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(`dropdb -h ${DEFAULT_HOST} --if-exists ${state.dbName}`, {
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(`psql -h ${DEFAULT_HOST} postgres -c "DROP ROLE IF EXISTS ${state.user};"`, { allowFailure: true });
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;
@@ -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 services start redis");
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;
@@ -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
- /** Properties that a provisioned resource exposes for expression resolution */
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: string;
13
- port: number;
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;
@@ -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
@@ -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(`fnm install ${version}`);
37
+ if (await shellOk("which", ["fnm"])) {
38
+ await shell("fnm", ["install", version]);
36
39
  }
37
- else if (await shellOk("which nvm")) {
38
- await shell(`nvm install ${version}`);
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(`fnm install ${version}`);
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", { silent: true });
52
+ const result = await shell("fnm", ["env", "--shell", "bash"], {
53
+ silent: true,
54
+ });
50
55
  return parseShellEnv(result.stdout);
51
56
  }
52
57
  catch {