@launchfile/macos-dev 0.3.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/shell.d.ts CHANGED
@@ -1,5 +1,16 @@
1
1
  /**
2
- * Shell execution helper with timeout, logging, and structured results.
2
+ * Command execution helpers with timeout, logging, and structured results.
3
+ *
4
+ * Two entry points, and the difference between them is a security boundary:
5
+ *
6
+ * - `shell(cmd, args, opts)` passes arguments straight to the OS via
7
+ * `execFile`. No shell is involved, so metacharacters in an argument are
8
+ * inert. Every command this provider builds itself uses this form — a value
9
+ * spliced into a command string would be CWE-78.
10
+ * - `shellScript(command, opts)` runs a command string through `/bin/sh -c`.
11
+ * It exists only for command strings the Launchfile author wrote for their
12
+ * own app (`commands:`, `health:`, `release:`), where shell syntax is the
13
+ * documented contract. Never build one of these by interpolation.
3
14
  */
4
15
  export interface ShellResult {
5
16
  exitCode: number;
@@ -14,12 +25,23 @@ export interface ShellOpts {
14
25
  silent?: boolean;
15
26
  }
16
27
  /**
17
- * Run a shell command and return structured output.
18
- * Throws on non-zero exit code unless `allowFailure` is set.
28
+ * Run a command with an argument array. Arguments reach the OS directly, so
29
+ * they are never parsed as shell syntax.
19
30
  */
20
- export declare function shell(command: string, opts?: ShellOpts & {
31
+ export declare function shell(cmd: string, args: string[], opts?: ShellOpts & {
32
+ allowFailure?: boolean;
33
+ }): Promise<ShellResult>;
34
+ /** Run a command with an argument array, return true if exit code is 0. */
35
+ export declare function shellOk(cmd: string, args: string[], opts?: ShellOpts): Promise<boolean>;
36
+ /**
37
+ * Run an author-written command string through `/bin/sh -c`.
38
+ *
39
+ * The shell is the point here: `commands:`, `health:` and `release:` values are
40
+ * documented as shell commands, and app authors rely on pipes, `&&` and
41
+ * variable expansion in them. The string must come from the Launchfile
42
+ * verbatim — building one by interpolating a value makes it injectable.
43
+ */
44
+ export declare function shellScript(command: string, opts?: ShellOpts & {
21
45
  allowFailure?: boolean;
22
46
  }): Promise<ShellResult>;
23
- /** Run a command, return true if exit code is 0 */
24
- export declare function shellOk(command: string, opts?: ShellOpts): Promise<boolean>;
25
47
  //# sourceMappingURL=shell.d.ts.map
package/dist/shell.js CHANGED
@@ -1,46 +1,101 @@
1
1
  /**
2
- * Shell execution helper with timeout, logging, and structured results.
2
+ * Command execution helpers with timeout, logging, and structured results.
3
+ *
4
+ * Two entry points, and the difference between them is a security boundary:
5
+ *
6
+ * - `shell(cmd, args, opts)` passes arguments straight to the OS via
7
+ * `execFile`. No shell is involved, so metacharacters in an argument are
8
+ * inert. Every command this provider builds itself uses this form — a value
9
+ * spliced into a command string would be CWE-78.
10
+ * - `shellScript(command, opts)` runs a command string through `/bin/sh -c`.
11
+ * It exists only for command strings the Launchfile author wrote for their
12
+ * own app (`commands:`, `health:`, `release:`), where shell syntax is the
13
+ * documented contract. Never build one of these by interpolation.
3
14
  */
4
- import { exec as cpExec } from "node:child_process";
15
+ import { exec as cpExec, execFile as cpExecFile, } from "node:child_process";
16
+ import { redactSecrets } from "./redact.js";
17
+ const DEFAULT_TIMEOUT_MS = 120_000;
18
+ const MAX_BUFFER = 10 * 1024 * 1024;
19
+ function toResult(error, stdout, stderr) {
20
+ const result = {
21
+ exitCode: typeof error?.code === "number" ? error.code : 0,
22
+ stdout: typeof stdout === "string" ? stdout : "",
23
+ stderr: typeof stderr === "string" ? stderr : "",
24
+ };
25
+ // Node sets error.code to the exit code on non-zero; a signal or a spawn
26
+ // failure leaves it non-numeric, so surface those as a generic failure.
27
+ if (error && result.exitCode === 0)
28
+ result.exitCode = 1;
29
+ return result;
30
+ }
31
+ function failure(display, result) {
32
+ // The message reaches the user and may be logged by a caller; both the
33
+ // command and the child's stderr can echo a secret back, so neither goes
34
+ // in unscrubbed (D-18, CWE-532).
35
+ return Object.assign(new Error(`Command failed: ${redactSecrets(display)}\n${redactSecrets(result.stderr)}`), { result });
36
+ }
5
37
  /**
6
- * Run a shell command and return structured output.
7
- * Throws on non-zero exit code unless `allowFailure` is set.
38
+ * Run a command with an argument array. Arguments reach the OS directly, so
39
+ * they are never parsed as shell syntax.
8
40
  */
9
- export async function shell(command, opts = {}) {
41
+ export async function shell(cmd, args, opts = {}) {
42
+ const display = [cmd, ...args].join(" ");
10
43
  if (!opts.silent) {
11
- console.log(` $ ${command}`);
44
+ // An argument can carry a resolved secret (a generated DB password, a
45
+ // credential-bearing URL). Scrub before echo.
46
+ console.log(` $ ${redactSecrets(display)}`);
12
47
  }
13
48
  const execOpts = {
14
49
  cwd: opts.cwd,
15
50
  env: opts.env ? { ...process.env, ...opts.env } : undefined,
16
- timeout: opts.timeout ?? 120_000,
17
- maxBuffer: 10 * 1024 * 1024,
51
+ timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS,
52
+ maxBuffer: MAX_BUFFER,
18
53
  };
19
54
  return new Promise((resolve, reject) => {
20
- cpExec(command, execOpts, (error, stdout, stderr) => {
21
- const result = {
22
- exitCode: typeof error?.code === "number" ? error.code : 0,
23
- stdout: typeof stdout === "string" ? stdout : "",
24
- stderr: typeof stderr === "string" ? stderr : "",
25
- };
26
- // Node's exec sets error.code to the exit code on non-zero
27
- if (error && result.exitCode === 0) {
28
- result.exitCode = 1;
29
- }
30
- if (error && !opts.allowFailure) {
31
- reject(Object.assign(new Error(`Command failed: ${command}\n${result.stderr}`), {
32
- result,
33
- }));
34
- }
35
- else {
55
+ cpExecFile(cmd, args, execOpts, (error, stdout, stderr) => {
56
+ const result = toResult(error, stdout, stderr);
57
+ if (error && !opts.allowFailure)
58
+ reject(failure(display, result));
59
+ else
36
60
  resolve(result);
37
- }
38
61
  });
39
62
  });
40
63
  }
41
- /** Run a command, return true if exit code is 0 */
42
- export async function shellOk(command, opts) {
43
- const result = await shell(command, { ...opts, allowFailure: true, silent: true });
64
+ /** Run a command with an argument array, return true if exit code is 0. */
65
+ export async function shellOk(cmd, args, opts) {
66
+ const result = await shell(cmd, args, {
67
+ ...opts,
68
+ allowFailure: true,
69
+ silent: true,
70
+ });
44
71
  return result.exitCode === 0;
45
72
  }
73
+ /**
74
+ * Run an author-written command string through `/bin/sh -c`.
75
+ *
76
+ * The shell is the point here: `commands:`, `health:` and `release:` values are
77
+ * documented as shell commands, and app authors rely on pipes, `&&` and
78
+ * variable expansion in them. The string must come from the Launchfile
79
+ * verbatim — building one by interpolating a value makes it injectable.
80
+ */
81
+ export async function shellScript(command, opts = {}) {
82
+ if (!opts.silent) {
83
+ console.log(` $ ${redactSecrets(command)}`);
84
+ }
85
+ const execOpts = {
86
+ cwd: opts.cwd,
87
+ env: opts.env ? { ...process.env, ...opts.env } : undefined,
88
+ timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS,
89
+ maxBuffer: MAX_BUFFER,
90
+ };
91
+ return new Promise((resolve, reject) => {
92
+ cpExec(command, execOpts, (error, stdout, stderr) => {
93
+ const result = toResult(error, stdout, stderr);
94
+ if (error && !opts.allowFailure)
95
+ reject(failure(command, result));
96
+ else
97
+ resolve(result);
98
+ });
99
+ });
100
+ }
46
101
  //# sourceMappingURL=shell.js.map
package/dist/state.d.ts CHANGED
@@ -50,6 +50,16 @@ export interface LaunchState {
50
50
  * persistence existed simply omit this, and `down` tolerates its absence.
51
51
  */
52
52
  processes?: Record<string, ProcessState>;
53
+ /**
54
+ * Minted `env:`-level generator values (D-49: generate once, then
55
+ * preserve), keyed `<component>.<ENV_NAME>` — one entry per declaration
56
+ * (D-25), so same-named variables on different components hold independent
57
+ * values. `generator: port` values are never stored here (ports are
58
+ * re-allocated each run). Disjoint from `secrets` on purpose: these names
59
+ * must not become resolvable as `$secrets.<name>`. Optional for backward
60
+ * compatibility: older state files omit it and load as a first run.
61
+ */
62
+ generatedEnv?: Record<string, string>;
53
63
  }
54
64
  export declare function hashLaunchfile(content: string): string;
55
65
  /** Load state from disk, or return null if none exists */
package/dist/state.js CHANGED
@@ -7,6 +7,7 @@
7
7
  import { readFile, writeFile, mkdir } from "node:fs/promises";
8
8
  import { join } from "node:path";
9
9
  import { createHash } from "node:crypto";
10
+ import { registerSecrets } from "./redact.js";
10
11
  const STATE_DIR = ".launchfile";
11
12
  const STATE_FILE = "state.json";
12
13
  function stateDir(projectDir) {
@@ -22,7 +23,17 @@ export function hashLaunchfile(content) {
22
23
  export async function loadState(projectDir) {
23
24
  try {
24
25
  const raw = await readFile(statePath(projectDir), "utf8");
25
- return JSON.parse(raw);
26
+ const state = JSON.parse(raw);
27
+ // Persisted credentials are reused verbatim in provisioning commands and
28
+ // in `$secrets.*` / `$<resource>.*` expressions, so they must be known to
29
+ // the redactor before anything can echo them.
30
+ registerSecrets(Object.values(state.secrets ?? {}));
31
+ registerSecrets(Object.values(state.resources ?? {}).map((r) => r.password));
32
+ // A value minted in an earlier run and merely reused in this one never
33
+ // passes through generateValue()'s registration, so the loader is the
34
+ // only place it can become scrubbable (D-18).
35
+ registerSecrets(Object.values(state.generatedEnv ?? {}));
36
+ return state;
26
37
  }
27
38
  catch {
28
39
  return null;
package/package.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "@launchfile/macos-dev",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "macOS dev provider for Launchfile — run apps locally via brew services and native runtimes",
5
+ "os": [
6
+ "darwin"
7
+ ],
5
8
  "type": "module",
6
9
  "main": "dist/index.js",
7
10
  "types": "dist/index.d.ts",
@@ -33,14 +36,14 @@
33
36
  "directory": "providers/macos-dev"
34
37
  },
35
38
  "dependencies": {
36
- "@launchfile/sdk": "^0.3.0",
39
+ "@launchfile/sdk": "^0.4.0",
37
40
  "semver": "^7.7.4"
38
41
  },
39
42
  "devDependencies": {
40
43
  "@biomejs/biome": "^2.4.10",
41
44
  "@types/bun": "^1.3.11",
42
45
  "@types/semver": "^7.7.1",
43
- "typescript": "^6.0.0",
46
+ "typescript": "^7.0.2",
44
47
  "vitest": "^4.1.3"
45
48
  }
46
49
  }