@launchfile/macos-dev 0.8.0 → 0.10.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.
@@ -10,6 +10,7 @@
10
10
  import { shell, shellOk } from "../shell.js";
11
11
  import { generatePassword } from "../secret-generator.js";
12
12
  import { assertSafeIdentifier, assertSafePassword, SAFE_IDENTIFIER, } from "./identifiers.js";
13
+ import { namedDatabase } from "./uses.js";
13
14
  const DEFAULT_PORT = 5432;
14
15
  const DEFAULT_HOST = "localhost";
15
16
  const READY_TIMEOUT_SECONDS = 10;
@@ -28,6 +29,20 @@ export class PostgresProvisioner {
28
29
  async isRunning() {
29
30
  return this.#shellOk("pg_isready", ["-q"]);
30
31
  }
32
+ /**
33
+ * Whether `database` exists on the server. psql exits 0 whenever the query
34
+ * ran, a zero-row SELECT included, so the exit code says nothing about
35
+ * existence — only the `1` the row prints does. The caller validates the
36
+ * name before it reaches the SQL here.
37
+ */
38
+ async #databaseExists(port, database) {
39
+ const result = await this.#shell("psql", [
40
+ ...psqlArgs(port, "postgres"),
41
+ "-tAc",
42
+ `SELECT 1 FROM pg_database WHERE datname='${database}'`,
43
+ ], { allowFailure: true, silent: true });
44
+ return result.exitCode === 0 && result.stdout.trim() === "1";
45
+ }
31
46
  async provision(req, opts, existingState) {
32
47
  // Ensure postgres is running
33
48
  if (!(await this.isRunning())) {
@@ -77,12 +92,7 @@ export class PostgresProvisioner {
77
92
  // silent: this command embeds the generated DB password; don't echo it (CWE-532).
78
93
  { allowFailure: true, silent: true });
79
94
  // Create database (idempotent)
80
- const dbExists = await this.#shellOk("psql", [
81
- ...psqlArgs(port, "postgres"),
82
- "-tAc",
83
- `SELECT 1 FROM pg_database WHERE datname='${dbName}'`,
84
- ]);
85
- if (!dbExists) {
95
+ if (!(await this.#databaseExists(port, dbName))) {
86
96
  await this.#shell("createdb", ["-h", DEFAULT_HOST, "-p", String(port), "-O", user, dbName], { allowFailure: true });
87
97
  }
88
98
  // Handle extensions from config
@@ -104,6 +114,19 @@ export class PostgresProvisioner {
104
114
  ], { allowFailure: true });
105
115
  }
106
116
  }
117
+ // Named `database` uses (SPEC.md § Resource uses): one more database per
118
+ // name, `<instance>_<name>`, created the same way as the app's own.
119
+ // Security: a use name is schema-validated (^[a-z][a-z0-9-]*$) and the
120
+ // hyphens become underscores, so the identifier check cannot fail on a
121
+ // name that reached here through the parser; it guards the SQL below
122
+ // all the same.
123
+ const databases = (opts.databases ?? []).map((name) => namedDatabase(dbName, name));
124
+ for (const database of databases) {
125
+ assertSafeIdentifier(database, "database name");
126
+ if (!(await this.#databaseExists(port, database))) {
127
+ await this.#shell("createdb", ["-h", DEFAULT_HOST, "-p", String(port), "-O", user, database], { allowFailure: true });
128
+ }
129
+ }
107
130
  const url = `postgresql://${user}:${password}@${DEFAULT_HOST}:${port}/${dbName}`;
108
131
  const properties = {
109
132
  url,
@@ -121,13 +144,16 @@ export class PostgresProvisioner {
121
144
  dbName,
122
145
  user,
123
146
  password,
147
+ ...(databases.length > 0 ? { databases } : {}),
124
148
  };
125
149
  return { properties, state };
126
150
  }
127
- async destroy(state) {
151
+ async destroy(state, _opts) {
128
152
  // Security: state values come from disk (state.json) — validate before SQL interpolation
129
- if (state.dbName && SAFE_IDENTIFIER.test(state.dbName)) {
130
- await this.#shell("dropdb", ["-h", DEFAULT_HOST, "--if-exists", state.dbName], { allowFailure: true });
153
+ for (const database of [state.dbName, ...(state.databases ?? [])]) {
154
+ if (!database || !SAFE_IDENTIFIER.test(database))
155
+ continue;
156
+ await this.#shell("dropdb", ["-h", DEFAULT_HOST, "--if-exists", database], { allowFailure: true });
131
157
  }
132
158
  if (state.user && SAFE_IDENTIFIER.test(state.user)) {
133
159
  await this.#shell("psql", [
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import type { NormalizedRequirement } from "@launchfile/sdk";
5
5
  import type { ResourceState } from "../state.js";
6
- import type { ProvisionOpts, ResourceProperties, ResourceProvisioner, ShellRunner } from "./types.js";
6
+ import type { DestroyOpts, ProvisionOpts, ResourceProperties, ResourceProvisioner, ShellRunner } from "./types.js";
7
7
  export declare class RedisProvisioner implements ResourceProvisioner {
8
8
  #private;
9
9
  readonly type = "redis";
@@ -13,6 +13,6 @@ export declare class RedisProvisioner implements ResourceProvisioner {
13
13
  properties: ResourceProperties;
14
14
  state: ResourceState;
15
15
  }>;
16
- destroy(_state: ResourceState): Promise<void>;
16
+ destroy(_state: ResourceState, _opts: DestroyOpts): Promise<void>;
17
17
  }
18
18
  //# sourceMappingURL=redis.d.ts.map
@@ -44,7 +44,7 @@ export class RedisProvisioner {
44
44
  };
45
45
  return { properties, state };
46
46
  }
47
- async destroy(_state) {
47
+ async destroy(_state, _opts) {
48
48
  // Redis is shared, don't stop the service
49
49
  // Could flush a specific database prefix, but not worth the complexity
50
50
  }
@@ -3,7 +3,7 @@
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 { DestroyOpts, ProvisionOpts, ResourceProperties, ResourceProvisioner } from "./types.js";
7
7
  export declare class SqliteProvisioner implements ResourceProvisioner {
8
8
  readonly type = "sqlite";
9
9
  isRunning(): Promise<boolean>;
@@ -11,6 +11,6 @@ export declare class SqliteProvisioner implements ResourceProvisioner {
11
11
  properties: ResourceProperties;
12
12
  state: ResourceState;
13
13
  }>;
14
- destroy(state: ResourceState): Promise<void>;
14
+ destroy(state: ResourceState, opts: DestroyOpts): Promise<void>;
15
15
  }
16
16
  //# sourceMappingURL=sqlite.d.ts.map
@@ -2,7 +2,7 @@
2
2
  * SQLite resource provisioner — just creates a directory for the DB file.
3
3
  */
4
4
  import { mkdir } from "node:fs/promises";
5
- import { join } from "node:path";
5
+ import { basename, dirname, join, resolve, sep } from "node:path";
6
6
  export class SqliteProvisioner {
7
7
  type = "sqlite";
8
8
  async isRunning() {
@@ -28,11 +28,32 @@ export class SqliteProvisioner {
28
28
  };
29
29
  return { properties, state };
30
30
  }
31
- async destroy(state) {
32
- if (state.dbName) {
33
- const { rm } = await import("node:fs/promises");
34
- await rm(state.dbName, { force: true });
31
+ async destroy(state, opts) {
32
+ if (!state.dbName)
33
+ return;
34
+ // state.json is repo-supplied and parsed without validation (state.ts),
35
+ // so dbName is untrusted here. The repo also supplies .launchfile/, so
36
+ // the data directory itself can be a symlink: resolve() is lexical and
37
+ // would not see it, while fs.rm follows symlinked components at the OS
38
+ // layer. Anchor on realpath(projectDir), which the caller supplies, and
39
+ // compare the target's real parent against it.
40
+ // The trailing separator matters: a bare startsWith(root) would leave a
41
+ // sibling directory named `sqlite-evil` deletable.
42
+ const { realpath, rm } = await import("node:fs/promises");
43
+ const projectRoot = await realpath(opts.projectDir).catch(() => null);
44
+ if (projectRoot === null)
45
+ return;
46
+ const root = join(projectRoot, ".launchfile", "data", "sqlite");
47
+ const target = resolve(projectRoot, state.dbName);
48
+ const parent = await realpath(dirname(target)).catch(() => null);
49
+ if (parent === null)
50
+ return; // nothing at that path to delete
51
+ const real = join(parent, basename(target));
52
+ if (!real.startsWith(root + sep)) {
53
+ console.warn(` ! sqlite: refusing to delete ${target} — outside ${root}`);
54
+ return;
35
55
  }
56
+ await rm(real, { force: true });
36
57
  }
37
58
  }
38
59
  //# sourceMappingURL=sqlite.js.map
@@ -42,6 +42,23 @@ export interface ShellRunner {
42
42
  export interface ProvisionOpts {
43
43
  appName: string;
44
44
  projectDir: string;
45
+ /**
46
+ * The names of the entry's named `database` uses (SPEC.md § Resource
47
+ * uses), pooled across same-name entries. A SQL provisioner creates one
48
+ * more database per name beside the app's — `<instance>_<name>` — and
49
+ * records them in state so `destroy` drops them. Other provisioners
50
+ * ignore it.
51
+ */
52
+ databases?: readonly string[];
53
+ }
54
+ /**
55
+ * The trusted context a teardown runs in. `state.json` is repo-supplied and
56
+ * parsed without validation (`state.ts`), so a provisioner cannot treat a
57
+ * stored path or identifier as its own; `projectDir` comes from the caller and
58
+ * is the only trustworthy boundary a destroy can confine itself to.
59
+ */
60
+ export interface DestroyOpts {
61
+ projectDir: string;
45
62
  }
46
63
  export interface ResourceProvisioner {
47
64
  readonly type: string;
@@ -53,6 +70,6 @@ export interface ResourceProvisioner {
53
70
  state: ResourceState;
54
71
  }>;
55
72
  /** Drop app-specific databases/users (destroy mode) */
56
- destroy(state: ResourceState): Promise<void>;
73
+ destroy(state: ResourceState, opts: DestroyOpts): Promise<void>;
57
74
  }
58
75
  //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Coverage of a `requires`/`supports` entry's declared `uses` (SPEC.md
3
+ * § Resource uses) by this provider's provisioners.
4
+ *
5
+ * A declared use is covered when the provisioner can hand over what the use
6
+ * asks for and register its properties under `<use>.<property>` — the dotted
7
+ * keys `$<resource>.<use>.<property>` resolves from. A named occurrence of a
8
+ * repeatable use (`- db: cache`) registers under `<use>.<name>.<property>`
9
+ * and is addressed as `$<resource>.<use>.<name>.<property>`. A use this
10
+ * provider cannot cover, a token it does not recognise, or a name on a use
11
+ * that does not repeat, refuses the component (PROVIDERS.md §10 item 5,
12
+ * D-64): no provider can claim to cover a use it does not know. This
13
+ * provider has no supplied-resource channel, so provision-and-cover or
14
+ * refuse are its only outcomes.
15
+ *
16
+ * Every function here takes **use keys** — `db` for a bare use, `db.cache`
17
+ * for a named one — the spelling `useKeys` from `@launchfile/sdk` produces
18
+ * and the resolver context lists.
19
+ *
20
+ * What each provisioner gives:
21
+ *
22
+ * - **redis** — the machine's Homebrew Redis, shared by every app on it. `db`
23
+ * is one numbered database on that server, allocated per use key within
24
+ * the app by {@link allocateDbIndexes}; `pubsub` and `server` are covered by
25
+ * the server itself and register nothing beyond the instance vocabulary.
26
+ * The server is shared across apps on this machine exactly as it is for an
27
+ * entry with no `uses` — a machine-wide index allocation is not attempted
28
+ * here.
29
+ * - **postgres / mysql / mariadb** — a per-app database on the local server.
30
+ * A bare `database` is that database; each named `database` is one more
31
+ * database on the server, `<instance>_<name>` ({@link namedDatabase}),
32
+ * created by the provisioner's own create-database path; `server` is the
33
+ * local instance.
34
+ */
35
+ import { type NormalizedLaunch } from "@launchfile/sdk";
36
+ import type { ResourceProperties } from "./types.js";
37
+ /**
38
+ * The redis database index allocated to each `db` use key of one resource —
39
+ * `db` for the bare use, `db.<name>` for a named one. Produced per resource
40
+ * by {@link allocateDbIndexes}.
41
+ */
42
+ export type DbIndexes = Readonly<Record<string, number>>;
43
+ /**
44
+ * The database a named `database` use gets on the local server:
45
+ * `<instance database>_<name>`, hyphens as underscores so the name is one SQL
46
+ * identifier on every engine. The same rule as `@launchfile/docker`.
47
+ */
48
+ export declare function namedDatabase(instance: string, name: string): string;
49
+ /**
50
+ * `url` with its path replaced by `/<database>`, the query and fragment kept:
51
+ * the instance URL selects the instance database, a named use's URL selects
52
+ * its own.
53
+ */
54
+ export declare function withDatabasePath(url: string, database: string): string;
55
+ /**
56
+ * The properties this provider registers for one declared use key of a
57
+ * resource it provisions, or `undefined` when it cannot cover it — a token it
58
+ * does not know, or a name on a use that does not repeat. `dbIndexes` carries
59
+ * the numbered database allocated to each `db` key when the type is redis.
60
+ */
61
+ export declare function coverUse(type: string, key: string, base: ResourceProperties, dbIndexes: DbIndexes): Record<string, string | number> | undefined;
62
+ /**
63
+ * The declared use keys this provider cannot cover for `type`, each spelled
64
+ * as the file spells it (`db: cache`) — the refusal's content. A name on a
65
+ * use that does not repeat says so, since the token itself is one this
66
+ * provider knows.
67
+ */
68
+ export declare function uncoveredUses(type: string, keys: readonly string[]): string[];
69
+ /**
70
+ * The declared use keys this provider covers for `type`, in order. Used to
71
+ * register a resource from the pooled uses of every same-name entry: a
72
+ * pooled key this provider does not cover belongs to an unfulfilled
73
+ * `supports` entry (a `requires` one refused its component already) and
74
+ * registers nothing.
75
+ */
76
+ export declare function coveredUses(type: string, keys: readonly string[]): string[];
77
+ /**
78
+ * The entry's property map with every declared use's properties registered
79
+ * under `<use>.<property>` / `<use>.<name>.<property>`. Callers refuse before
80
+ * reaching here, so an uncovered use is a caller bug and throws.
81
+ */
82
+ export declare function withCoveredUses(type: string, keys: readonly string[] | undefined, base: ResourceProperties, dbIndexes: DbIndexes): ResourceProperties;
83
+ /**
84
+ * The names of the named `database` uses among `keys`, sorted and unique —
85
+ * the extra databases a SQL provisioner creates beside the app's. Empty for
86
+ * any other token, named or not.
87
+ */
88
+ export declare function namedDatabases(keys: readonly string[]): string[];
89
+ /**
90
+ * One numbered redis database per `db` use key, app-wide, keyed by resource
91
+ * name — the same allocation as `@launchfile/docker` (D-65 conformance), so
92
+ * one file yields the same `db.*` values under each provider:
93
+ *
94
+ * - redis resources take blocks in the order their first `db`-declaring entry
95
+ * appears, walking components in file order and `requires` before
96
+ * `supports` — a `supports` entry's `db` takes its index whether or not the
97
+ * entry is fulfilled;
98
+ * - within a block the bare `db` (if any same-name entry declares it) takes
99
+ * the first index and the named `db` uses follow in name order, so
100
+ * `- db: sessions` beside `- db: cache` is the later index whichever is
101
+ * written first.
102
+ *
103
+ * Index 0 is the first allocated. Same-name entries pool their keys (D-24).
104
+ * A resource that declares no `db` use has no entry.
105
+ */
106
+ export declare function allocateDbIndexes(launch: NormalizedLaunch): Record<string, DbIndexes>;
107
+ //# sourceMappingURL=uses.d.ts.map
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Coverage of a `requires`/`supports` entry's declared `uses` (SPEC.md
3
+ * § Resource uses) by this provider's provisioners.
4
+ *
5
+ * A declared use is covered when the provisioner can hand over what the use
6
+ * asks for and register its properties under `<use>.<property>` — the dotted
7
+ * keys `$<resource>.<use>.<property>` resolves from. A named occurrence of a
8
+ * repeatable use (`- db: cache`) registers under `<use>.<name>.<property>`
9
+ * and is addressed as `$<resource>.<use>.<name>.<property>`. A use this
10
+ * provider cannot cover, a token it does not recognise, or a name on a use
11
+ * that does not repeat, refuses the component (PROVIDERS.md §10 item 5,
12
+ * D-64): no provider can claim to cover a use it does not know. This
13
+ * provider has no supplied-resource channel, so provision-and-cover or
14
+ * refuse are its only outcomes.
15
+ *
16
+ * Every function here takes **use keys** — `db` for a bare use, `db.cache`
17
+ * for a named one — the spelling `useKeys` from `@launchfile/sdk` produces
18
+ * and the resolver context lists.
19
+ *
20
+ * What each provisioner gives:
21
+ *
22
+ * - **redis** — the machine's Homebrew Redis, shared by every app on it. `db`
23
+ * is one numbered database on that server, allocated per use key within
24
+ * the app by {@link allocateDbIndexes}; `pubsub` and `server` are covered by
25
+ * the server itself and register nothing beyond the instance vocabulary.
26
+ * The server is shared across apps on this machine exactly as it is for an
27
+ * entry with no `uses` — a machine-wide index allocation is not attempted
28
+ * here.
29
+ * - **postgres / mysql / mariadb** — a per-app database on the local server.
30
+ * A bare `database` is that database; each named `database` is one more
31
+ * database on the server, `<instance>_<name>` ({@link namedDatabase}),
32
+ * created by the provisioner's own create-database path; `server` is the
33
+ * local instance.
34
+ */
35
+ import { formatUseKey, parseUseKey, useKeys, } from "@launchfile/sdk";
36
+ /**
37
+ * The database a named `database` use gets on the local server:
38
+ * `<instance database>_<name>`, hyphens as underscores so the name is one SQL
39
+ * identifier on every engine. The same rule as `@launchfile/docker`.
40
+ */
41
+ export function namedDatabase(instance, name) {
42
+ return `${instance}_${name.replace(/-/g, "_")}`;
43
+ }
44
+ /**
45
+ * `url` with its path replaced by `/<database>`, the query and fragment kept:
46
+ * the instance URL selects the instance database, a named use's URL selects
47
+ * its own.
48
+ */
49
+ export function withDatabasePath(url, database) {
50
+ const match = /^([a-z][a-z0-9+.-]*:\/\/[^/?#]*)(?:\/[^?#]*)?(.*)$/i.exec(url);
51
+ if (!match)
52
+ return url;
53
+ return `${match[1]}/${database}${match[2]}`;
54
+ }
55
+ const SQL_SERVER_USES = {
56
+ database: {
57
+ repeatable: true,
58
+ cover: ({ base, name }) => {
59
+ if (name === undefined) {
60
+ return { "database.url": base.url, "database.name": base.name ?? "" };
61
+ }
62
+ const database = namedDatabase(base.name ?? "", name);
63
+ return {
64
+ [`database.${name}.url`]: withDatabasePath(base.url, database),
65
+ [`database.${name}.name`]: database,
66
+ };
67
+ },
68
+ },
69
+ server: { repeatable: false, cover: () => ({}) },
70
+ };
71
+ const COVERAGE = {
72
+ redis: {
73
+ db: {
74
+ repeatable: true,
75
+ cover: ({ base, name, dbIndex }) => {
76
+ // The instance url may already select database 0; the use's
77
+ // url selects the allocated index instead.
78
+ const instance = base.url.replace(/\/\d+$/, "");
79
+ const prefix = name === undefined ? "db" : `db.${name}`;
80
+ return { [`${prefix}.url`]: `${instance}/${dbIndex}`, [`${prefix}.index`]: dbIndex };
81
+ },
82
+ },
83
+ pubsub: { repeatable: false, cover: () => ({}) },
84
+ server: { repeatable: false, cover: () => ({}) },
85
+ },
86
+ postgres: SQL_SERVER_USES,
87
+ mysql: SQL_SERVER_USES,
88
+ mariadb: SQL_SERVER_USES,
89
+ };
90
+ function coverageOf(type, key) {
91
+ const uses = Object.hasOwn(COVERAGE, type) ? COVERAGE[type] : undefined;
92
+ const { use, name } = parseUseKey(key);
93
+ const coverage = uses && Object.hasOwn(uses, use) ? uses[use] : undefined;
94
+ if (!coverage)
95
+ return undefined;
96
+ return name !== undefined && !coverage.repeatable ? undefined : coverage;
97
+ }
98
+ /**
99
+ * The properties this provider registers for one declared use key of a
100
+ * resource it provisions, or `undefined` when it cannot cover it — a token it
101
+ * does not know, or a name on a use that does not repeat. `dbIndexes` carries
102
+ * the numbered database allocated to each `db` key when the type is redis.
103
+ */
104
+ export function coverUse(type, key, base, dbIndexes) {
105
+ const coverage = coverageOf(type, key);
106
+ if (!coverage)
107
+ return undefined;
108
+ const { name } = parseUseKey(key);
109
+ const dbIndex = Object.hasOwn(dbIndexes, key) ? dbIndexes[key] : 0;
110
+ return coverage.cover({ base, name, dbIndex });
111
+ }
112
+ /**
113
+ * The declared use keys this provider cannot cover for `type`, each spelled
114
+ * as the file spells it (`db: cache`) — the refusal's content. A name on a
115
+ * use that does not repeat says so, since the token itself is one this
116
+ * provider knows.
117
+ */
118
+ export function uncoveredUses(type, keys) {
119
+ const uncovered = [];
120
+ for (const key of keys) {
121
+ if (coverageOf(type, key) !== undefined)
122
+ continue;
123
+ const { use, name } = parseUseKey(key);
124
+ const known = Object.hasOwn(COVERAGE, type) && Object.hasOwn(COVERAGE[type], use);
125
+ uncovered.push(name !== undefined && known
126
+ ? `${formatUseKey(key)} (${use} on ${type} is not repeatable and takes no name)`
127
+ : formatUseKey(key));
128
+ }
129
+ return uncovered;
130
+ }
131
+ /**
132
+ * The declared use keys this provider covers for `type`, in order. Used to
133
+ * register a resource from the pooled uses of every same-name entry: a
134
+ * pooled key this provider does not cover belongs to an unfulfilled
135
+ * `supports` entry (a `requires` one refused its component already) and
136
+ * registers nothing.
137
+ */
138
+ export function coveredUses(type, keys) {
139
+ return keys.filter((key) => coverageOf(type, key) !== undefined);
140
+ }
141
+ /**
142
+ * The entry's property map with every declared use's properties registered
143
+ * under `<use>.<property>` / `<use>.<name>.<property>`. Callers refuse before
144
+ * reaching here, so an uncovered use is a caller bug and throws.
145
+ */
146
+ export function withCoveredUses(type, keys, base, dbIndexes) {
147
+ const properties = { ...base };
148
+ for (const key of keys ?? []) {
149
+ const covered = coverUse(type, key, base, dbIndexes);
150
+ if (covered === undefined) {
151
+ throw new Error(`withCoveredUses: use "${formatUseKey(key)}" on ${type} is not covered — the caller must refuse first`);
152
+ }
153
+ Object.assign(properties, covered);
154
+ }
155
+ return properties;
156
+ }
157
+ /**
158
+ * The names of the named `database` uses among `keys`, sorted and unique —
159
+ * the extra databases a SQL provisioner creates beside the app's. Empty for
160
+ * any other token, named or not.
161
+ */
162
+ export function namedDatabases(keys) {
163
+ const names = new Set();
164
+ for (const key of keys) {
165
+ const { use, name } = parseUseKey(key);
166
+ if (use === "database" && name !== undefined)
167
+ names.add(name);
168
+ }
169
+ return [...names].sort();
170
+ }
171
+ /**
172
+ * One numbered redis database per `db` use key, app-wide, keyed by resource
173
+ * name — the same allocation as `@launchfile/docker` (D-65 conformance), so
174
+ * one file yields the same `db.*` values under each provider:
175
+ *
176
+ * - redis resources take blocks in the order their first `db`-declaring entry
177
+ * appears, walking components in file order and `requires` before
178
+ * `supports` — a `supports` entry's `db` takes its index whether or not the
179
+ * entry is fulfilled;
180
+ * - within a block the bare `db` (if any same-name entry declares it) takes
181
+ * the first index and the named `db` uses follow in name order, so
182
+ * `- db: sessions` beside `- db: cache` is the later index whichever is
183
+ * written first.
184
+ *
185
+ * Index 0 is the first allocated. Same-name entries pool their keys (D-24).
186
+ * A resource that declares no `db` use has no entry.
187
+ */
188
+ export function allocateDbIndexes(launch) {
189
+ const pooled = new Map();
190
+ for (const component of Object.values(launch.components)) {
191
+ for (const entry of [...(component.requires ?? []), ...(component.supports ?? [])]) {
192
+ if (entry.host || entry.type !== "redis" || !entry.uses)
193
+ continue;
194
+ const dbKeys = useKeys(entry.uses).filter((key) => parseUseKey(key).use === "db");
195
+ if (dbKeys.length === 0)
196
+ continue;
197
+ const key = entry.name ?? entry.type;
198
+ const keys = pooled.get(key) ?? new Set();
199
+ for (const dbKey of dbKeys)
200
+ keys.add(dbKey);
201
+ pooled.set(key, keys);
202
+ }
203
+ }
204
+ const allocation = {};
205
+ let next = 0;
206
+ for (const [resource, keys] of pooled) {
207
+ const indexes = {};
208
+ if (keys.has("db"))
209
+ indexes.db = next++;
210
+ for (const key of [...keys].filter((k) => k !== "db").sort()) {
211
+ indexes[key] = next++;
212
+ }
213
+ allocation[resource] = indexes;
214
+ }
215
+ return allocation;
216
+ }
217
+ //# sourceMappingURL=uses.js.map
package/dist/state.d.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * Persists secrets, ports, and resource state in .launchfile/state.json
5
5
  * so credentials and ports are stable across restarts.
6
6
  */
7
+ import type { DbIndexes } from "./resources/uses.js";
7
8
  export interface ResourceState {
8
9
  type: string;
9
10
  name: string;
@@ -12,7 +13,31 @@ export interface ResourceState {
12
13
  dbName?: string;
13
14
  user?: string;
14
15
  password?: string;
16
+ /**
17
+ * The numbered database `up` allocated to this resource for its bare redis
18
+ * `db` use (SPEC.md § Resource uses). Recorded so `env` registers the same
19
+ * `db.url` / `db.index` the running app was given, rather than re-deriving
20
+ * an index from a file that may have changed since. Absent on a resource
21
+ * with no bare `db` use and on state written before the index was recorded.
22
+ */
23
+ dbIndex?: number;
24
+ /**
25
+ * The numbered database allocated to each named redis `db` use, by name
26
+ * (`- db: cache` → `{ cache: 1 }`), recorded for the same reason as
27
+ * `dbIndex`. Absent on a resource with no named `db` use.
28
+ */
29
+ namedDbIndexes?: Record<string, number>;
30
+ /**
31
+ * The extra databases the provisioner created for named `database` uses
32
+ * (`<instance>_<name>`), so `destroy` drops what `up` created. Absent on a
33
+ * resource with no named `database` use.
34
+ */
35
+ databases?: string[];
15
36
  }
37
+ /** The `db` use keys → index map `up` recorded on a resource: `db` from `dbIndex`, `db.<name>` from `namedDbIndexes`. */
38
+ export declare function recordedDbIndexes(res: ResourceState): DbIndexes;
39
+ /** `res` with the allocated `db` indexes recorded — the inverse of {@link recordedDbIndexes}. Records nothing for an empty allocation. */
40
+ export declare function withRecordedDbIndexes(res: ResourceState, indexes: DbIndexes): ResourceState;
16
41
  /**
17
42
  * Records a spawned app component process so `launch down` can stop it
18
43
  * from a different shell or after the foreground `launch up` session ends.
@@ -72,6 +97,17 @@ export interface LaunchState {
72
97
  * written before this existed omit it.
73
98
  */
74
99
  operatorStorage?: Record<string, Record<string, string>>;
100
+ /**
101
+ * Orchestrator-supplied publication context (D-58): the normalized public
102
+ * URL `$app.*` resolves from, persisted alongside `ports` so `env` and
103
+ * `bootstrap` resolve the same values as the `up` that set it. A later `up`
104
+ * that supplies a different value replaces it and the derived env recomputes
105
+ * (D-49); one that omits it preserves what is recorded — the same rule
106
+ * `@launchfile/docker` applies to its own `appUrl`. Optional for backward
107
+ * compatibility — absent means this provider's own localhost routing
108
+ * answers.
109
+ */
110
+ appUrl?: string;
75
111
  }
76
112
  export declare function hashLaunchfile(content: string): string;
77
113
  /** Load state from disk, or return null if none exists */
package/dist/state.js CHANGED
@@ -4,10 +4,36 @@
4
4
  * Persists secrets, ports, and resource state in .launchfile/state.json
5
5
  * so credentials and ports are stable across restarts.
6
6
  */
7
- import { readFile, writeFile, mkdir } from "node:fs/promises";
7
+ import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
8
8
  import { join } from "node:path";
9
9
  import { createHash } from "node:crypto";
10
10
  import { registerSecrets } from "./redact.js";
11
+ /** The `db` use keys → index map `up` recorded on a resource: `db` from `dbIndex`, `db.<name>` from `namedDbIndexes`. */
12
+ export function recordedDbIndexes(res) {
13
+ const indexes = {};
14
+ if (res.dbIndex !== undefined)
15
+ indexes.db = res.dbIndex;
16
+ for (const [name, index] of Object.entries(res.namedDbIndexes ?? {})) {
17
+ indexes[`db.${name}`] = index;
18
+ }
19
+ return indexes;
20
+ }
21
+ /** `res` with the allocated `db` indexes recorded — the inverse of {@link recordedDbIndexes}. Records nothing for an empty allocation. */
22
+ export function withRecordedDbIndexes(res, indexes) {
23
+ const named = {};
24
+ let dbIndex;
25
+ for (const [key, index] of Object.entries(indexes)) {
26
+ if (key === "db")
27
+ dbIndex = index;
28
+ else
29
+ named[key.slice("db.".length)] = index;
30
+ }
31
+ return {
32
+ ...res,
33
+ ...(dbIndex !== undefined ? { dbIndex } : {}),
34
+ ...(Object.keys(named).length > 0 ? { namedDbIndexes: named } : {}),
35
+ };
36
+ }
11
37
  const STATE_DIR = ".launchfile";
12
38
  const STATE_FILE = "state.json";
13
39
  function stateDir(projectDir) {
@@ -64,8 +90,15 @@ export async function saveState(projectDir, state) {
64
90
  /** Ensure .launchfile directories exist */
65
91
  export async function ensureDirs(projectDir) {
66
92
  const dirs = ["storage", "tmp", "logs", "data", "env"];
67
- // Security: restrict permissions — these dirs contain secrets, logs, env files
68
- await Promise.all(dirs.map((d) => mkdir(join(projectDir, STATE_DIR, d), { recursive: true, mode: 0o700 })));
93
+ // Security: these dirs hold secrets, logs, and env files. mkdir applies the
94
+ // mode only when it creates the directory, so chmod unconditionally — a dir
95
+ // left by an earlier version or a looser umask must not stay world-readable
96
+ // (CWE-276). Mirrors packages/launchfile/src/state/errors.ts.
97
+ await Promise.all(dirs.map(async (d) => {
98
+ const dir = join(projectDir, STATE_DIR, d);
99
+ await mkdir(dir, { recursive: true, mode: 0o700 });
100
+ await chmod(dir, 0o700);
101
+ }));
69
102
  // Safety net: write a .gitignore inside .launchfile/ so secrets aren't
70
103
  // accidentally committed even if the project's .gitignore doesn't exclude it.
71
104
  await writeFile(join(projectDir, STATE_DIR, ".gitignore"), "*\n", { mode: 0o644 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@launchfile/macos-dev",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "macOS dev provider for Launchfile — run apps locally via brew services and native runtimes",
5
5
  "os": [
6
6
  "darwin"
@@ -36,7 +36,7 @@
36
36
  "directory": "providers/macos-dev"
37
37
  },
38
38
  "dependencies": {
39
- "@launchfile/sdk": "^0.8.0",
39
+ "@launchfile/sdk": "^0.10.0",
40
40
  "semver": "^7.7.4"
41
41
  },
42
42
  "devDependencies": {