@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.
@@ -32,6 +32,24 @@ export interface LaunchUpOpts {
32
32
  * name is ambiguous. Relative paths resolve against the current directory.
33
33
  */
34
34
  storage?: Record<string, string>;
35
+ /**
36
+ * Orchestrator-supplied publication context (D-58): the public URL of the
37
+ * app's PRIMARY endpoint when routing is owned outside this host — a reverse
38
+ * proxy, tunnel, or edge in front of the processes this provider starts.
39
+ * `$app.*` then resolves from it instead of from `http://localhost:<port>`,
40
+ * this provider's own routing answer, which upstream routing has made no
41
+ * longer the address anyone reaches the app at.
42
+ *
43
+ * Absolute `http`/`https` WHATWG URL, no userinfo, query, or fragment; a
44
+ * malformed value is refused (`InvalidAppUrlError`), never degraded into a
45
+ * guessed or localhost `$app.*` (D-58 rule 3). One URL asserts the primary
46
+ * endpoint only (rule 4) — per-component addresses stay `$components.*`.
47
+ * Persisted in `.launchfile/state.json` so `env` and `bootstrap` resolve
48
+ * what the `up` that set it resolved; a later run that omits it keeps the
49
+ * recorded value, and one that supplies a different value replaces it
50
+ * (D-49), matching `@launchfile/docker`.
51
+ */
52
+ appUrl?: string;
35
53
  }
36
54
  /**
37
55
  * Components this provider must refuse, mapped to the capabilities it cannot
@@ -69,14 +87,20 @@ export declare function scheduleWarning(component: string, schedule: string): st
69
87
  export declare function applyHostCapabilityRefusals(launch: NormalizedLaunch): "ok" | "none-left";
70
88
  /**
71
89
  * Components this provider must refuse because they require a public HTTPS
72
- * origin (D-60 rule 5), mapped to the entries it cannot satisfy.
90
+ * origin it cannot supply (D-60 rule 5), mapped to the entries and the reason.
73
91
  *
74
- * This provider has no orchestrator-facing publication channel ([#294]) and no
75
- * edge of its own, so it can neither provision the origin nor accept a supplied
76
- * one — it refuses, which PROVIDERS.md §10 item 5 makes conformant. `supports:`
77
- * entries are not refused: the component runs, degraded.
92
+ * This provider has no edge of its own, so it cannot provision the origin. It
93
+ * can accept one through the publication-context channel (`appUrl`, D-58) —
94
+ * which for this type IS the supplied-resource channel (PROVIDERS.md §7) —
95
+ * when the URL's scheme is `https`; then the entry is satisfied and nothing is
96
+ * refused. With no URL, or one whose scheme is not `https`, the entry cannot
97
+ * be satisfied and refusing is what PROVIDERS.md §10 item 5 makes conformant.
98
+ * `supports:` entries are never refused: the component runs, degraded.
99
+ *
100
+ * `appUrl` is the effective publication URL — supplied on this run or recorded
101
+ * by an earlier one — normalized.
78
102
  */
79
- export declare function refusedHttpsOrigins(launch: NormalizedLaunch): Map<string, string[]>;
103
+ export declare function refusedHttpsOrigins(launch: NormalizedLaunch, appUrl?: string): Map<string, string[]>;
80
104
  /**
81
105
  * Remove every component whose required `https-origin` this provider cannot
82
106
  * satisfy, and say so on stderr. Same shape and same reason as
@@ -85,7 +109,7 @@ export declare function refusedHttpsOrigins(launch: NormalizedLaunch): Map<strin
85
109
  * an app that declared it needs HTTPS would run over plain HTTP behind a
86
110
  * message nobody acted on.
87
111
  */
88
- export declare function applyHttpsOriginRefusals(launch: NormalizedLaunch): "ok" | "none-left";
112
+ export declare function applyHttpsOriginRefusals(launch: NormalizedLaunch, appUrl?: string): "ok" | "none-left";
89
113
  /**
90
114
  * Components this provider must refuse because native TLS was selected on a
91
115
  * listener it cannot activate (D-61 rule 5), mapped to the entries.
@@ -109,6 +133,44 @@ export declare function refusedCertificates(launch: NormalizedLaunch, withOption
109
133
  * {@link applyHostCapabilityRefusals}: the removal IS the refusal.
110
134
  */
111
135
  export declare function applyCertificateRefusals(launch: NormalizedLaunch, withOptional: boolean): "ok" | "none-left";
136
+ /**
137
+ * Components this provider must refuse because a `requires` entry names a
138
+ * resource type it has no provisioner for (PROVIDERS.md §10 item 5, D-64),
139
+ * mapped to the entries. The ordinary case of {@link refusedHttpsOrigins}: a
140
+ * `kafka` this provider cannot stand up is exactly a `postgres` it cannot
141
+ * stand up.
142
+ *
143
+ * This provider has no supplied-resource channel, so nothing can satisfy such
144
+ * an entry from outside — provision or refuse are its only conformant
145
+ * outcomes. The vocabulary is open (L-4): a type with no provisioner is a
146
+ * normal, permanent state, and the defect is starting the component without
147
+ * the resource its file says it needs. Host-capability entries take rule 9's
148
+ * path and `supports:` entries are optional (D-8); neither is graded here.
149
+ */
150
+ export declare function refusedResourceTypes(launch: NormalizedLaunch): Map<string, string[]>;
151
+ /**
152
+ * Remove every component with a required resource this provider cannot
153
+ * provision, and say so on stderr. Same shape and same reason as
154
+ * {@link applyHostCapabilityRefusals}: the removal IS the refusal.
155
+ */
156
+ export declare function applyResourceTypeRefusals(launch: NormalizedLaunch): "ok" | "none-left";
157
+ /**
158
+ * Components this provider must refuse because a `requires` entry declares a
159
+ * use its provisioner cannot cover (SPEC.md § Resource uses, D-56 rule 1,
160
+ * D-64), mapped to `<entry>: <use>` lines. Graded after
161
+ * {@link refusedResourceTypes}: a type with no provisioner is refused on the
162
+ * type, so only entries whose type this provider stands up reach here. A
163
+ * token this provider does not recognise is uncovered — no provider can
164
+ * claim to cover a use it does not know. `supports:` entries are optional
165
+ * (D-8) and are not graded here.
166
+ */
167
+ export declare function refusedResourceUses(launch: NormalizedLaunch): Map<string, string[]>;
168
+ /**
169
+ * Remove every component with a required use this provider cannot cover, and
170
+ * say so on stderr. Same shape as {@link applyResourceTypeRefusals}: the
171
+ * removal IS the refusal.
172
+ */
173
+ export declare function applyResourceUseRefusals(launch: NormalizedLaunch): "ok" | "none-left";
112
174
  export declare function launchUp(opts?: LaunchUpOpts): Promise<void>;
113
175
  export declare function launchDown(opts?: {
114
176
  destroy?: boolean;
package/dist/provider.js CHANGED
@@ -7,11 +7,12 @@
7
7
  import { accessSync, constants as fsConstants } from "node:fs";
8
8
  import { readFile } from "node:fs/promises";
9
9
  import { join, resolve as resolvePath } from "node:path";
10
- import { CERTIFICATE, certificateBindings, indexOperatorStoragePaths, MissingOperatorStoragePathError, readLaunch, resolveSourcePrepareCommand, resolveSourceRunCommand, selectionClosure, UnboundOperatorStorageError, unsuppliedRequiredEnv, } from "@launchfile/sdk";
10
+ import { CERTIFICATE, certificateBindings, indexOperatorStoragePaths, MissingOperatorStoragePathError, normalizeAppUrl, readLaunch, resolveSourcePrepareCommand, resolveSourceRunCommand, selectionClosure, UnboundOperatorStorageError, unsuppliedRequiredEnv, appEndpointReferences, useKeys, } from "@launchfile/sdk";
11
11
  import { checkPrereqs } from "./prereqs.js";
12
- import { loadState, initState, saveState, ensureDirs } from "./state.js";
13
- import { buildResolverContext, computeAppProperties, resolveComponentEnv, generateSecrets, resolveGenerators, writeEnvFile, } from "./env-writer.js";
14
- import { getProvisioner } from "./resources/index.js";
12
+ import { HTTPS_ORIGIN, httpsOriginSatisfied, httpsOriginShortfall, } from "./https-origin.js";
13
+ import { loadState, initState, saveState, ensureDirs, withRecordedDbIndexes } from "./state.js";
14
+ import { declaredUses, registerResource, resolveComponentEnv, resolverContextFor, resourceMapFromState, generateSecrets, resolveGenerators, writeEnvFile, } from "./env-writer.js";
15
+ import { allocateDbIndexes, getProvisioner, namedDatabases, uncoveredUses, } from "./resources/index.js";
15
16
  import { allocatePorts } from "./port-allocator.js";
16
17
  import { getRuntimeInstaller } from "./runtimes/index.js";
17
18
  import { detectPackageManager } from "./lockfile-detect.js";
@@ -121,23 +122,29 @@ export function applyHostCapabilityRefusals(launch) {
121
122
  launch.components = Object.fromEntries(Object.entries(launch.components).filter(([n]) => !refused.has(n)));
122
123
  return Object.keys(launch.components).length === 0 ? "none-left" : "ok";
123
124
  }
124
- /** The backing-service type that declares the app's public HTTPS origin (D-60). */
125
- const HTTPS_ORIGIN = "https-origin";
126
125
  /**
127
126
  * Components this provider must refuse because they require a public HTTPS
128
- * origin (D-60 rule 5), mapped to the entries it cannot satisfy.
127
+ * origin it cannot supply (D-60 rule 5), mapped to the entries and the reason.
129
128
  *
130
- * This provider has no orchestrator-facing publication channel ([#294]) and no
131
- * edge of its own, so it can neither provision the origin nor accept a supplied
132
- * one — it refuses, which PROVIDERS.md §10 item 5 makes conformant. `supports:`
133
- * entries are not refused: the component runs, degraded.
129
+ * This provider has no edge of its own, so it cannot provision the origin. It
130
+ * can accept one through the publication-context channel (`appUrl`, D-58) —
131
+ * which for this type IS the supplied-resource channel (PROVIDERS.md §7) —
132
+ * when the URL's scheme is `https`; then the entry is satisfied and nothing is
133
+ * refused. With no URL, or one whose scheme is not `https`, the entry cannot
134
+ * be satisfied and refusing is what PROVIDERS.md §10 item 5 makes conformant.
135
+ * `supports:` entries are never refused: the component runs, degraded.
136
+ *
137
+ * `appUrl` is the effective publication URL — supplied on this run or recorded
138
+ * by an earlier one — normalized.
134
139
  */
135
- export function refusedHttpsOrigins(launch) {
140
+ export function refusedHttpsOrigins(launch, appUrl) {
136
141
  const refused = new Map();
142
+ if (httpsOriginSatisfied(appUrl))
143
+ return refused;
137
144
  for (const [name, c] of Object.entries(launch.components)) {
138
145
  const entries = (c.requires ?? [])
139
146
  .filter((r) => r.type === HTTPS_ORIGIN)
140
- .map((r) => `${r.name ?? r.type} (endpoint "${r.endpoint ?? "?"}")`);
147
+ .map((r) => httpsOriginShortfall(r, appUrl));
141
148
  if (entries.length > 0)
142
149
  refused.set(name, entries);
143
150
  }
@@ -151,11 +158,11 @@ export function refusedHttpsOrigins(launch) {
151
158
  * an app that declared it needs HTTPS would run over plain HTTP behind a
152
159
  * message nobody acted on.
153
160
  */
154
- export function applyHttpsOriginRefusals(launch) {
155
- const refused = refusedHttpsOrigins(launch);
161
+ export function applyHttpsOriginRefusals(launch, appUrl) {
162
+ const refused = refusedHttpsOrigins(launch, appUrl);
156
163
  for (const [name, entries] of refused) {
157
164
  console.error(` Refused: ${name} requires a public HTTPS origin this provider cannot supply ` +
158
- `(${entries.join("; ")}) — it has no edge and no publication channel — component not started`);
165
+ `(${entries.join("; ")}) — component not started`);
159
166
  }
160
167
  if (refused.size === 0)
161
168
  return "ok";
@@ -211,8 +218,101 @@ export function applyCertificateRefusals(launch, withOptional) {
211
218
  launch.components = Object.fromEntries(Object.entries(launch.components).filter(([n]) => !refused.has(n)));
212
219
  return Object.keys(launch.components).length === 0 ? "none-left" : "ok";
213
220
  }
221
+ /**
222
+ * Components this provider must refuse because a `requires` entry names a
223
+ * resource type it has no provisioner for (PROVIDERS.md §10 item 5, D-64),
224
+ * mapped to the entries. The ordinary case of {@link refusedHttpsOrigins}: a
225
+ * `kafka` this provider cannot stand up is exactly a `postgres` it cannot
226
+ * stand up.
227
+ *
228
+ * This provider has no supplied-resource channel, so nothing can satisfy such
229
+ * an entry from outside — provision or refuse are its only conformant
230
+ * outcomes. The vocabulary is open (L-4): a type with no provisioner is a
231
+ * normal, permanent state, and the defect is starting the component without
232
+ * the resource its file says it needs. Host-capability entries take rule 9's
233
+ * path and `supports:` entries are optional (D-8); neither is graded here.
234
+ */
235
+ export function refusedResourceTypes(launch) {
236
+ const refused = new Map();
237
+ for (const [name, component] of Object.entries(launch.components)) {
238
+ const entries = (component.requires ?? [])
239
+ .filter((r) => !r.host && r.type !== HTTPS_ORIGIN && !getProvisioner(r.type))
240
+ .map((r) => (r.name === undefined ? r.type : `${r.name} (type "${r.type}")`));
241
+ if (entries.length > 0)
242
+ refused.set(name, entries);
243
+ }
244
+ return refused;
245
+ }
246
+ /**
247
+ * Remove every component with a required resource this provider cannot
248
+ * provision, and say so on stderr. Same shape and same reason as
249
+ * {@link applyHostCapabilityRefusals}: the removal IS the refusal.
250
+ */
251
+ export function applyResourceTypeRefusals(launch) {
252
+ const refused = refusedResourceTypes(launch);
253
+ for (const [name, entries] of refused) {
254
+ const noun = entries.length === 1 ? "a resource" : "resources";
255
+ console.error(` Refused: ${name} requires ${noun} this provider cannot provision ` +
256
+ `(${entries.join("; ")}) — it has no provisioner for the type and no channel to ` +
257
+ "receive one through; use a provider that provisions it — component not started");
258
+ }
259
+ if (refused.size === 0)
260
+ return "ok";
261
+ launch.components = Object.fromEntries(Object.entries(launch.components).filter(([n]) => !refused.has(n)));
262
+ return Object.keys(launch.components).length === 0 ? "none-left" : "ok";
263
+ }
264
+ /**
265
+ * Components this provider must refuse because a `requires` entry declares a
266
+ * use its provisioner cannot cover (SPEC.md § Resource uses, D-56 rule 1,
267
+ * D-64), mapped to `<entry>: <use>` lines. Graded after
268
+ * {@link refusedResourceTypes}: a type with no provisioner is refused on the
269
+ * type, so only entries whose type this provider stands up reach here. A
270
+ * token this provider does not recognise is uncovered — no provider can
271
+ * claim to cover a use it does not know. `supports:` entries are optional
272
+ * (D-8) and are not graded here.
273
+ */
274
+ export function refusedResourceUses(launch) {
275
+ const refused = new Map();
276
+ for (const [name, component] of Object.entries(launch.components)) {
277
+ const entries = [];
278
+ for (const req of component.requires ?? []) {
279
+ if (req.host || req.type === HTTPS_ORIGIN || !req.uses || !getProvisioner(req.type))
280
+ continue;
281
+ const resourceName = req.name ?? req.type;
282
+ for (const use of uncoveredUses(req.type, useKeys(req.uses))) {
283
+ entries.push(`${resourceName}: ${use}`);
284
+ }
285
+ }
286
+ if (entries.length > 0)
287
+ refused.set(name, entries);
288
+ }
289
+ return refused;
290
+ }
291
+ /**
292
+ * Remove every component with a required use this provider cannot cover, and
293
+ * say so on stderr. Same shape as {@link applyResourceTypeRefusals}: the
294
+ * removal IS the refusal.
295
+ */
296
+ export function applyResourceUseRefusals(launch) {
297
+ const refused = refusedResourceUses(launch);
298
+ for (const [name, entries] of refused) {
299
+ const noun = entries.length === 1 ? "a use" : "uses";
300
+ console.error(` Refused: ${name} requires ${noun} of a resource this provider cannot cover ` +
301
+ `(${entries.join("; ")}) — a provider covers every declared use or refuses; declare only ` +
302
+ "what the app uses, or use a provider that covers it — component not started");
303
+ }
304
+ if (refused.size === 0)
305
+ return "ok";
306
+ launch.components = Object.fromEntries(Object.entries(launch.components).filter(([n]) => !refused.has(n)));
307
+ return Object.keys(launch.components).length === 0 ? "none-left" : "ok";
308
+ }
214
309
  export async function launchUp(opts = {}) {
215
310
  const projectDir = opts.projectDir ?? process.cwd();
311
+ // Publication context (D-58): validated and normalized before anything is
312
+ // checked, provisioned, or started — a malformed URL is an expected refusal
313
+ // (InvalidAppUrlError), never a degraded $app.* or a silent fallback to this
314
+ // provider's own localhost answer.
315
+ const suppliedAppUrl = opts.appUrl === undefined ? undefined : normalizeAppUrl(opts.appUrl);
216
316
  // 1. Check prerequisites
217
317
  const prereqs = await checkPrereqs();
218
318
  if (!prereqs.ok) {
@@ -267,11 +367,25 @@ export async function launchUp(opts = {}) {
267
367
  console.error("Every selected component requires a host capability this provider cannot grant.");
268
368
  process.exit(1);
269
369
  }
270
- // 2a-bis. A required `https-origin` is refused for the same reason and in
271
- // the same way (D-60 rule 5, PROVIDERS.md §10 item 5): this provider has
272
- // no edge and no publication channel, so it cannot satisfy the entry, and
273
- // starting the component anyway is the silent success the type removes.
274
- if (applyHttpsOriginRefusals(launch) === "none-left") {
370
+ // 2a-bis. A required `https-origin` (D-60 rule 5, PROVIDERS.md §10 item 5)
371
+ // is satisfied by the publication context when its scheme is https —
372
+ // supplied on this run, or recorded by an earlier one and preserved on
373
+ // omission, which is why state is loaded here rather than after the
374
+ // refusals (loading writes nothing; the first write is `ensureDirs`).
375
+ // Otherwise it is refused in the same way as a host capability: starting
376
+ // the component anyway is the silent success the type removes.
377
+ let state = await loadState(projectDir);
378
+ if (!state) {
379
+ state = initState(launch.name, launchfileContent);
380
+ }
381
+ // Publication context (D-58), the same preservation rule the docker provider
382
+ // applies: a supplied value replaces the recorded one — the derived $app.*
383
+ // env recomputes below from it (D-49) — while omission keeps what is
384
+ // recorded, so a later plain `up` cannot silently flip a proxied deployment
385
+ // back to localhost.
386
+ if (suppliedAppUrl !== undefined)
387
+ state.appUrl = suppliedAppUrl;
388
+ if (applyHttpsOriginRefusals(launch, state.appUrl) === "none-left") {
275
389
  console.error("Every selected component requires a public HTTPS origin this provider cannot supply.");
276
390
  process.exit(1);
277
391
  }
@@ -283,6 +397,24 @@ export async function launchUp(opts = {}) {
283
397
  console.error("Every selected component selected native TLS this provider cannot activate.");
284
398
  process.exit(1);
285
399
  }
400
+ // 2a-quater. A required resource type this provider has no provisioner for
401
+ // is refused the same way (D-64, PROVIDERS.md §10 item 5). Decided after
402
+ // the selector narrowed `launch.components`, so an unsatisfiable entry on a
403
+ // component outside the start-set blocks nothing (§10 item 5's selector
404
+ // rule), and before anything is provisioned, installed, wired or started.
405
+ if (applyResourceTypeRefusals(launch) === "none-left") {
406
+ console.error("Every selected component requires a resource this provider cannot provision.");
407
+ process.exit(1);
408
+ }
409
+ // 2a-quinquies. A required use this provider's provisioner cannot cover is
410
+ // refused the same way (SPEC.md § Resource uses, D-56 rule 1): a provider
411
+ // covers every declared use or refuses, and never hands over less than the
412
+ // entry declares. Graded here, after the type refusal, for the same
413
+ // selector and before-anything-happens reasons.
414
+ if (applyResourceUseRefusals(launch) === "none-left") {
415
+ console.error("Every selected component requires a use of a resource this provider cannot cover.");
416
+ process.exit(1);
417
+ }
286
418
  // An optional capability is not refused — the component runs, degraded.
287
419
  for (const [name, c] of Object.entries(launch.components)) {
288
420
  for (const sup of c.supports ?? []) {
@@ -291,11 +423,16 @@ export async function launchUp(opts = {}) {
291
423
  `${capability}=${String(value)} not granted — running degraded`);
292
424
  }
293
425
  }
426
+ // The optional mood of `https-origin` (D-60 rule 6): satisfied, it is
427
+ // wired like any other resource at step 8; unsatisfied, the component
428
+ // still runs, its set_env is absent, and the shortfall is named.
429
+ if (httpsOriginSatisfied(state.appUrl))
430
+ continue;
294
431
  for (const sup of c.supports ?? []) {
295
432
  if (sup.type !== HTTPS_ORIGIN)
296
433
  continue;
297
- console.warn(` Warning: ${name}: optional public HTTPS origin ` +
298
- `${sup.name ?? sup.type} (endpoint "${sup.endpoint ?? "?"}") not satisfied — running degraded`);
434
+ console.warn(` Warning: ${name}: optional public HTTPS origin not satisfied ` +
435
+ `(${httpsOriginShortfall(sup, state.appUrl)}) — running degraded`);
299
436
  }
300
437
  }
301
438
  const componentNames = Object.keys(launch.components);
@@ -431,39 +568,59 @@ export async function launchUp(opts = {}) {
431
568
  for (const key of storageIndex.unusedKeys(usedStorageKeys)) {
432
569
  console.warn(` Warning: --storage ${key} matches no \`content: operator\` volume — ignored`);
433
570
  }
434
- // 3. Load or init state
435
- let state = await loadState(projectDir);
436
- if (!state) {
437
- state = initState(launch.name, launchfileContent);
438
- }
571
+ // 3. State: loaded at step 2a-bis, where the https-origin refusal reads it.
439
572
  // 4. Ensure directories
440
573
  await ensureDirs(projectDir);
441
574
  // 5. Generate secrets
442
575
  state.secrets = await generateSecrets(launch.secrets, state.secrets);
443
- // 6. Provision required resources
576
+ // 6. Provision required resources. Each declared use's `<use>.<property>`
577
+ // keys join the provisioner's instance vocabulary in the map; each redis
578
+ // `db` use key gets one numbered database from one app-wide allocation
579
+ // that is stable between runs and identical on both reference providers
580
+ // (`allocateDbIndexes`), and each named `database` use is one more
581
+ // database the SQL provisioner creates. A resource is provisioned once per
582
+ // name and registered from the pooled uses of every same-name entry (D-24:
583
+ // they describe one resource) — the set the resolver context below treats
584
+ // strictly — so the first entry seen cannot hide keys a later one declares.
444
585
  const resourceMap = {};
586
+ const uses = declaredUses(launch);
587
+ const dbIndexes = allocateDbIndexes(launch);
588
+ const provisionOpts = (resourceName) => ({
589
+ appName: launch.name,
590
+ projectDir,
591
+ databases: namedDatabases(uses[resourceName] ?? []),
592
+ });
445
593
  for (const [_compName, component] of Object.entries(launch.components)) {
446
594
  for (const req of component.requires ?? []) {
447
595
  if (req.host)
448
596
  continue; // capability, not a backing service (D-44)
597
+ // Satisfied through the publication context, not provisioned; wired
598
+ // at step 8 once $app.* is known. A refused one is already gone.
599
+ if (req.type === HTTPS_ORIGIN)
600
+ continue;
449
601
  const resourceName = req.name ?? req.type;
450
602
  if (resourceMap[resourceName])
451
603
  continue; // Already provisioned
452
604
  const provisioner = getProvisioner(req.type);
453
605
  if (!provisioner) {
454
- console.warn(` ! No provisioner for resource type: ${req.type} (skipping)`);
455
- continue;
606
+ // Step 2a-quater removed every component with such an entry, so
607
+ // reaching here is a bug in this file, not a launch outcome.
608
+ throw new Error(`no provisioner for resource type "${req.type}" on ${_compName} — the component must be refused first`);
456
609
  }
610
+ const indexes = dbIndexes[resourceName] ?? {};
457
611
  if (opts.dryRun) {
458
612
  console.log(` [dry-run] Would provision ${req.type} as "${resourceName}"`);
459
- resourceMap[resourceName] = { url: "", host: "localhost", port: 0 }; // placeholder for dedup
613
+ resourceMap[resourceName] = registerResource(req.type, resourceName, uses, { url: "", host: "localhost", port: 0 }, // placeholder for dedup
614
+ indexes);
460
615
  continue;
461
616
  }
462
617
  process.stdout.write(` \u2193 Provisioning ${req.type}...`);
463
618
  const existing = state.resources[resourceName];
464
- const result = await provisioner.provision(req, { appName: launch.name, projectDir }, existing);
465
- resourceMap[resourceName] = result.properties;
466
- state.resources[resourceName] = result.state;
619
+ const result = await provisioner.provision(req, provisionOpts(resourceName), existing);
620
+ resourceMap[resourceName] = registerResource(req.type, resourceName, uses, result.properties, indexes);
621
+ // The indexes ride along in state so `env` answers `db.*` with the
622
+ // databases the running app was given.
623
+ state.resources[resourceName] = withRecordedDbIndexes(result.state, indexes);
467
624
  console.log(" done");
468
625
  }
469
626
  // Optional supports resources
@@ -471,23 +628,37 @@ export async function launchUp(opts = {}) {
471
628
  for (const sup of component.supports ?? []) {
472
629
  if (sup.host)
473
630
  continue; // capability, not a backing service (D-44)
631
+ if (sup.type === HTTPS_ORIGIN)
632
+ continue; // publication context, step 8
474
633
  const resourceName = sup.name ?? sup.type;
475
634
  if (resourceMap[resourceName])
476
635
  continue;
477
636
  const provisioner = getProvisioner(sup.type);
478
637
  if (!provisioner)
479
638
  continue;
639
+ // An optional entry declaring a use this provider cannot cover is
640
+ // left unfulfilled — bindings absent, said out loud — never refused
641
+ // (D-8). The same shortfall on `requires:` refused the component
642
+ // at step 2a-quinquies.
643
+ const uncovered = sup.uses ? uncoveredUses(sup.type, useKeys(sup.uses)) : [];
644
+ if (uncovered.length > 0) {
645
+ console.warn(` Warning: ${_compName}: optional resource ${resourceName} not satisfied — this provider ` +
646
+ `does not cover ${uncovered.length === 1 ? "a use" : "uses"} it declares ` +
647
+ `(${uncovered.join(", ")}); running degraded`);
648
+ continue;
649
+ }
650
+ const indexes = dbIndexes[resourceName] ?? {};
480
651
  if (opts.dryRun) {
481
652
  console.log(` [dry-run] Would provision optional ${sup.type} as "${resourceName}"`);
482
- resourceMap[resourceName] = { url: "", host: "localhost", port: 0 };
653
+ resourceMap[resourceName] = registerResource(sup.type, resourceName, uses, { url: "", host: "localhost", port: 0 }, indexes);
483
654
  continue;
484
655
  }
485
656
  try {
486
657
  process.stdout.write(` \u2193 Provisioning ${sup.type} (optional)...`);
487
658
  const existing = state.resources[resourceName];
488
- const result = await provisioner.provision(sup, { appName: launch.name, projectDir }, existing);
489
- resourceMap[resourceName] = result.properties;
490
- state.resources[resourceName] = result.state;
659
+ const result = await provisioner.provision(sup, provisionOpts(resourceName), existing);
660
+ resourceMap[resourceName] = registerResource(sup.type, resourceName, uses, result.properties, indexes);
661
+ state.resources[resourceName] = withRecordedDbIndexes(result.state, indexes);
491
662
  console.log(" done");
492
663
  }
493
664
  catch {
@@ -499,9 +670,19 @@ export async function launchUp(opts = {}) {
499
670
  // 7. Allocate ports
500
671
  const componentPorts = await allocatePorts(launch.components, launch.name, state.ports);
501
672
  state.ports = componentPorts;
502
- // 8. Build resolver context (including $app.* properties from D-33)
503
- const appProperties = computeAppProperties(launch, componentPorts);
504
- const context = buildResolverContext(resourceMap, componentPorts, state.secrets, appProperties);
673
+ // 8. Build resolver context (including $app.* properties from D-33). A
674
+ // satisfied `https-origin` registers `url` — the same string as `$app.url`
675
+ // (D-60 rule 4) — so its set_env resolves like any provisioned resource's.
676
+ const context = resolverContextFor(launch, resourceMap, state);
677
+ // `$app.endpoints.<name>.*` resolves "" here (D-63 rule 4, #294). Said
678
+ // once per `up`, and only when the file asks, so the empty value is not a
679
+ // silent one (PROVIDERS.md §10 item 8).
680
+ const endpointRefs = appEndpointReferences(launch);
681
+ if (endpointRefs.length > 0) {
682
+ const names = [...new Set(endpointRefs.map((ref) => ref.path[2] ?? "<none>"))];
683
+ console.warn(` ! $app.endpoints.* resolves "" on this provider (${names.map((n) => `$app.endpoints.${n}.*`).join(", ")}) — ` +
684
+ "it publishes one port per component, not one per endpoint (#294)");
685
+ }
505
686
  // 9. Install runtimes
506
687
  for (const [name, component] of Object.entries(launch.components)) {
507
688
  if (!component.runtime) {
@@ -568,7 +749,7 @@ export async function launchUp(opts = {}) {
568
749
  }
569
750
  else {
570
751
  const { mkdir } = await import("node:fs/promises");
571
- await mkdir(join(projectDir, ".launchfile", "env"), { recursive: true });
752
+ await mkdir(join(projectDir, ".launchfile", "env"), { recursive: true, mode: 0o700 });
572
753
  await writeEnvFile(join(projectDir, ".launchfile", "env", `${name}.env`), env);
573
754
  console.log(` \u2193 Wiring ${name} environment... done (${Object.keys(env).length} vars)`);
574
755
  }
@@ -704,7 +885,7 @@ export async function launchDown(opts = {}) {
704
885
  const provisioner = getProvisioner(resourceState.type);
705
886
  if (provisioner) {
706
887
  console.log(` Destroying ${resourceState.type} (${name})...`);
707
- await provisioner.destroy(resourceState);
888
+ await provisioner.destroy(resourceState, { projectDir });
708
889
  }
709
890
  }
710
891
  // Clean up state
@@ -755,17 +936,12 @@ export async function launchEnv(opts = {}) {
755
936
  console.log("No active launch state. Run `launch up` first.");
756
937
  return;
757
938
  }
758
- // Rebuild resource map from state
759
- const resourceMap = {};
760
- for (const [name, res] of Object.entries(state.resources)) {
761
- const provisioner = getProvisioner(res.type);
762
- if (provisioner) {
763
- const result = await provisioner.provision({ type: res.type, name: res.name }, { appName: state.appName, projectDir }, res);
764
- resourceMap[name] = result.properties;
765
- }
766
- }
767
- const appProperties = computeAppProperties(launch, state.ports);
768
- const context = buildResolverContext(resourceMap, state.ports, state.secrets, appProperties);
939
+ // `env` reports what the running app has: the resources registered as `up`
940
+ // registered them — declared uses included, a redis `db` use reading back
941
+ // the index `up` recorded — and `$app.*` from the publication context the
942
+ // last `up` recorded (D-58), not this provider's localhost answer.
943
+ const resourceMap = await resourceMapFromState(launch, state, projectDir);
944
+ const context = resolverContextFor(launch, resourceMap, state);
769
945
  // `env` reports what the running app has, so it reads minted generator
770
946
  // values from the same store `up` persists to (D-49). A value can still be
771
947
  // minted here — a generator declared after the last `up` — and then it is
@@ -6,5 +6,6 @@
6
6
  import type { ResourceProvisioner, ShellRunner } from "./types.js";
7
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, ShellRunner, } from "./types.js";
9
+ export { allocateDbIndexes, coverUse, coveredUses, type DbIndexes, namedDatabase, namedDatabases, uncoveredUses, withCoveredUses, withDatabasePath, } from "./uses.js";
10
+ export type { ResourceProvisioner, ResourceProperties, ProvisionOpts, DestroyOpts, ShellRunner, } from "./types.js";
10
11
  //# sourceMappingURL=index.d.ts.map
@@ -31,4 +31,5 @@ export function getProvisioner(type, deps) {
31
31
  export function supportedResourceTypes() {
32
32
  return Object.keys(factories);
33
33
  }
34
+ export { allocateDbIndexes, coverUse, coveredUses, namedDatabase, namedDatabases, uncoveredUses, withCoveredUses, withDatabasePath, } from "./uses.js";
34
35
  //# sourceMappingURL=index.js.map
@@ -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 MysqlProvisioner implements ResourceProvisioner {
8
8
  #private;
9
9
  readonly type = "mysql";
@@ -13,6 +13,6 @@ export declare class MysqlProvisioner 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=mysql.d.ts.map
@@ -4,6 +4,7 @@
4
4
  import { shell, shellOk } from "../shell.js";
5
5
  import { generatePassword } from "../secret-generator.js";
6
6
  import { assertSafeIdentifier, assertSafePassword, SAFE_IDENTIFIER, } from "./identifiers.js";
7
+ import { namedDatabase } from "./uses.js";
7
8
  const DEFAULT_PORT = 3306;
8
9
  const DEFAULT_HOST = "localhost";
9
10
  /** Connection flags shared by every mysql invocation, as argv elements. */
@@ -65,6 +66,22 @@ export class MysqlProvisioner {
65
66
  "-e",
66
67
  `GRANT ALL PRIVILEGES ON \`${dbName}\`.* TO '${user}'@'${DEFAULT_HOST}';`,
67
68
  ], { allowFailure: true });
69
+ // Named `database` uses (SPEC.md § Resource uses): one more database per
70
+ // name, `<instance>_<name>`, created and granted the same way as the
71
+ // app's own. Security: a use name is schema-validated
72
+ // (^[a-z][a-z0-9-]*$) and the hyphens become underscores, so the
73
+ // identifier check cannot fail on a name that reached here through the
74
+ // parser; it guards the SQL below all the same.
75
+ const databases = (opts.databases ?? []).map((name) => namedDatabase(dbName, name));
76
+ for (const database of databases) {
77
+ assertSafeIdentifier(database, "database name");
78
+ await this.#shell("mysql", [...mysqlArgs(), "-e", `CREATE DATABASE IF NOT EXISTS \`${database}\`;`], { allowFailure: true });
79
+ await this.#shell("mysql", [
80
+ ...mysqlArgs(),
81
+ "-e",
82
+ `GRANT ALL PRIVILEGES ON \`${database}\`.* TO '${user}'@'${DEFAULT_HOST}';`,
83
+ ], { allowFailure: true });
84
+ }
68
85
  const url = `mysql://${user}:${password}@${DEFAULT_HOST}:${port}/${dbName}`;
69
86
  const properties = {
70
87
  url,
@@ -82,13 +99,16 @@ export class MysqlProvisioner {
82
99
  dbName,
83
100
  user,
84
101
  password,
102
+ ...(databases.length > 0 ? { databases } : {}),
85
103
  };
86
104
  return { properties, state };
87
105
  }
88
- async destroy(state) {
106
+ async destroy(state, _opts) {
89
107
  // Security: state values come from disk (state.json) — validate before SQL interpolation
90
- if (state.dbName && SAFE_IDENTIFIER.test(state.dbName)) {
91
- await this.#shell("mysql", [...mysqlArgs(), "-e", `DROP DATABASE IF EXISTS \`${state.dbName}\`;`], { allowFailure: true });
108
+ for (const database of [state.dbName, ...(state.databases ?? [])]) {
109
+ if (!database || !SAFE_IDENTIFIER.test(database))
110
+ continue;
111
+ await this.#shell("mysql", [...mysqlArgs(), "-e", `DROP DATABASE IF EXISTS \`${database}\`;`], { allowFailure: true });
92
112
  }
93
113
  if (state.user && SAFE_IDENTIFIER.test(state.user)) {
94
114
  await this.#shell("mysql", [
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import type { NormalizedRequirement } from "@launchfile/sdk";
11
11
  import type { ResourceState } from "../state.js";
12
- import type { ProvisionOpts, ResourceProperties, ResourceProvisioner, ShellRunner } from "./types.js";
12
+ import type { DestroyOpts, ProvisionOpts, ResourceProperties, ResourceProvisioner, ShellRunner } from "./types.js";
13
13
  export type { ShellRunner };
14
14
  export declare class PostgresProvisioner implements ResourceProvisioner {
15
15
  #private;
@@ -20,6 +20,6 @@ export declare class PostgresProvisioner implements ResourceProvisioner {
20
20
  properties: ResourceProperties;
21
21
  state: ResourceState;
22
22
  }>;
23
- destroy(state: ResourceState): Promise<void>;
23
+ destroy(state: ResourceState, _opts: DestroyOpts): Promise<void>;
24
24
  }
25
25
  //# sourceMappingURL=postgres.d.ts.map