@prisma/composer 0.1.0-dev.15 → 0.1.0-dev.17

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.
@@ -5,7 +5,70 @@ import { Input, StackServices } from "alchemy";
5
5
  import * as Effect from "effect/Effect";
6
6
  import * as Layer from "effect/Layer";
7
7
  import { State } from "alchemy/State/State";
8
- //#region ../../0-framework/1-core/core/dist/app-config-FyPJc4X-.d.mts
8
+ //#region ../../0-framework/1-core/core/dist/app-config-DJdU4ubR.d.mts
9
+ //#region src/container-transport.d.ts
10
+ /**
11
+ * Carries resolved containers from the CLI process into the alchemy process
12
+ * (ADR-0037). A deploy runs as two processes: the CLI resolves each
13
+ * extension's containers, then spawns `alchemy`, which re-imports the config
14
+ * from scratch and needs those containers back — and env vars are the only
15
+ * channel between the two. So the CLI writes each instance's `serialize()`
16
+ * output into one env var per extension, and in the alchemy process
17
+ * `deserializeContainers` reads each var back through the same extension's
18
+ * descriptor. The framework owns the vars; it never reads their contents.
19
+ */
20
+ /** The key an extension resolves a container from: which app, which stage. */
21
+ interface LocateContainerInput {
22
+ /** The application name (root node's name, or `--name`). */
23
+ readonly appName: string;
24
+ /** The named stage, or `undefined` for the default (production) stage. */
25
+ readonly stage: string | undefined;
26
+ }
27
+ /**
28
+ * One resolved container. The framework sees only this interface; the
29
+ * extension that produced the instance narrows it back to its own concrete
30
+ * type wherever the framework hands it back (ADR-0037).
31
+ */
32
+ interface ContainerInstance {
33
+ readonly input: LocateContainerInput;
34
+ /** Serialize to a non-empty string for the process transport above. The format is the extension's own; only its `deserialize` reads it. */
35
+ serialize(): string;
36
+ }
37
+ /**
38
+ * The platform containers an app deploys into, as one lifecycle. `I` is
39
+ * the extension's own instance type — the same descriptor produces and
40
+ * consumes it, so the extension gets full typing internally while the
41
+ * framework stores the erased form. METHOD SYNTAX REQUIRED on all four
42
+ * members: the erased assignment into ExtensionDescriptor compiles only
43
+ * through method bivariance; property-arrow members are checked
44
+ * contravariantly and the assignment fails (same rule as
45
+ * ServiceLowering<P, S> — ADR-0033).
46
+ */
47
+ interface ContainerDescriptor<I extends ContainerInstance = ContainerInstance> {
48
+ /** Resolve the container for (appName, stage), creating anything absent. Called by `deploy`. */
49
+ ensure(input: LocateContainerInput): Promise<I>;
50
+ /** Find the container for (appName, stage); `undefined` when nothing exists. Called by `destroy` — never creates. */
51
+ locate(input: LocateContainerInput): Promise<I | undefined>;
52
+ /** Remove the container after a successful destroy, after every extension's `teardown` has run. Failure policy is the extension's. */
53
+ remove(instance: I): Promise<void>;
54
+ /** Reconstruct an instance from its own `serialize()` output — the far end of the framework's parent→child transport. */
55
+ deserialize(serialized: string): I;
56
+ }
57
+ /** '@prisma/composer-prisma-cloud' → 'PRISMA_COMPOSER_CONTAINER_PRISMA_COMPOSER_PRISMA_CLOUD' */
58
+ declare function containerEnvVarName(extensionId: string): string;
59
+ /** The env entries the CLI sets on the alchemy process: `{ [containerEnvVarName(id)]: instance.serialize() }` for every resolved instance. */
60
+ declare function containerEnv(instances: ReadonlyMap<string, ContainerInstance>): Record<string, string>;
61
+ /** The slice of `PrismaAppConfig.extensions` this module needs — kept narrow so this shared-plane module never imports the control-plane `ExtensionDescriptor`/`PrismaAppConfig` types (ADR-0028's plane split). */
62
+ interface ContainerTransportExtension {
63
+ readonly id: string;
64
+ readonly container?: ContainerDescriptor;
65
+ }
66
+ /**
67
+ * The alchemy-process side: for each extension with a container descriptor
68
+ * whose var is present in `env`, call its deserialize. Absent var → no entry.
69
+ */
70
+ declare function deserializeContainers(extensions: readonly ContainerTransportExtension[], env: Readonly<Record<string, string | undefined>>): ReadonlyMap<string, ContainerInstance>;
71
+ //#endregion
9
72
  //#region src/control/deploy.d.ts
10
73
  /** The Layer shape every Alchemy state store must satisfy — what `LowerOptions.state` and `PrismaAppConfig.state` both traffic in. */
11
74
  type AlchemyStateLayer = Layer.Layer<State, never, StackServices>;
@@ -92,6 +155,13 @@ interface LowerContext {
92
155
  * it with its own type guard.
93
156
  */
94
157
  readonly application: unknown;
158
+ /**
159
+ * The owning extension's resolved container, deserialized from the
160
+ * framework transport; core never reads it — the extension narrows it
161
+ * with its own type guard. `undefined` when the extension declares no
162
+ * container descriptor.
163
+ */
164
+ readonly container: ContainerInstance | undefined;
95
165
  /** Already-lowered deps (topo order). */
96
166
  readonly lowered: ReadonlyMap<NodeId, Outputs>;
97
167
  /** Every provisioned param value minted this lowering, keyed by edge id (ADR-0031). */
@@ -219,10 +289,12 @@ declare function joinDeployment(graph: Graph, entries: readonly {
219
289
  }[]): readonly DeployedNode[];
220
290
  /**
221
291
  * The state-layer precedence a deploy resolves to: an explicit opts.state
222
- * always wins; failing that, the config's own (required) state. A pure
223
- * function so the precedence is testable without booting Alchemy.
292
+ * always wins; failing that, the config's own (required) state descriptor,
293
+ * created with its owning extension's resolved container (`undefined` when
294
+ * that extension declared none). A pure function so the precedence is
295
+ * testable without booting Alchemy.
224
296
  */
225
- declare function resolveStateLayer(opts: LowerOptions, config: PrismaAppConfig): AlchemyStateLayer;
297
+ declare function resolveStateLayer(opts: LowerOptions, config: PrismaAppConfig, containers: ReadonlyMap<string, ContainerInstance>): AlchemyStateLayer;
226
298
  /**
227
299
  * All configured extensions' providers merged, config array order — an
228
300
  * extension without `providers` is skipped; no used-extensions-only
@@ -275,24 +347,38 @@ interface ExtensionDescriptor {
275
347
  * fail the command handles that itself. Async: it talks to the platform.
276
348
  */
277
349
  readonly teardown?: (input: TeardownInput) => Promise<void>;
350
+ /**
351
+ * The extension's container lifecycle, when its platform has containers
352
+ * (ADR-0038). The CLI resolves containers after assembly and before any
353
+ * stack file or Alchemy run (deploy ensures, destroy locates); the
354
+ * resolved instance reaches the alchemy process through the env transport
355
+ * in container-transport.ts.
356
+ */
357
+ readonly container?: ContainerDescriptor;
358
+ }
359
+ /**
360
+ * The deploy's one state store. It names its owning extension so core knows
361
+ * whose resolved container to pass into `create` (ADR-0038).
362
+ */
363
+ interface StateDescriptor {
364
+ /** The owning extension's id — matched against `ExtensionDescriptor.id`. */
365
+ readonly extension: string;
366
+ /** Build the state layer. `container` is the owning extension's resolved instance; `undefined` when it declared no container descriptor. */
367
+ create(container: ContainerInstance | undefined): AlchemyStateLayer;
278
368
  }
279
369
  /** The resolved deploy context handed to an extension's `preflight` hook. */
280
370
  interface PreflightInput {
281
371
  /** The loaded application graph — the manifest of prerequisites is read from it (`provisionManifest`). */
282
372
  readonly graph: Graph;
283
- /** The resolved Prisma Cloud Project id. */
284
- readonly projectId: string;
285
- /** The resolved Branch id for a named stage; `undefined` for the default (production) stage. */
286
- readonly branchId: string | undefined;
373
+ /** The calling extension's own resolved container; `undefined` when it declares no container descriptor. Narrow with the extension's guard. */
374
+ readonly container: ContainerInstance | undefined;
287
375
  /** The stage name (`--stage`), or `undefined` for the default stage — for diagnostics/scope. */
288
376
  readonly stage: string | undefined;
289
377
  }
290
378
  /** The resolved destroy context handed to an extension's `teardown` hook. */
291
379
  interface TeardownInput {
292
- /** The resolved Prisma Cloud Project id. */
293
- readonly projectId: string;
294
- /** The resolved Branch id for a named stage; `undefined` for the default (production) stage. */
295
- readonly branchId: string | undefined;
380
+ /** The calling extension's own resolved container; `undefined` when it declares no container descriptor. Narrow with the extension's guard. */
381
+ readonly container: ContainerInstance | undefined;
296
382
  /** The stage name (`--stage`), or `undefined` for the default stage — for diagnostics/scope. */
297
383
  readonly stage: string | undefined;
298
384
  }
@@ -316,10 +402,10 @@ type NodeDescriptor = ({
316
402
  */
317
403
  interface PrismaAppConfig {
318
404
  readonly extensions: ExtensionDescriptor[];
319
- readonly state: () => AlchemyStateLayer;
405
+ readonly state: StateDescriptor;
320
406
  }
321
407
  /** Typed identity — exists so `prisma-composer.config.ts` gets checked against PrismaAppConfig where it is written. */
322
408
  declare function defineConfig(config: PrismaAppConfig): PrismaAppConfig;
323
409
  //#endregion
324
- export { resolveStateLayer as A, TeardownInput as C, lower as D, joinDeployment as E, lowering as O, ServiceLowering as S, defineConfig as T, PackageInput as _, Bundle as a, ProvisionEdge as b, DeploymentResult as c, LowerError as d, LowerOptions as f, Outputs as g, NodeDescriptor as h, AssembleInput as i, mergedProviders as k, ExtensionDescriptor as l, Lowering as m, ApplicationDescriptor as n, DeployedEntity as o, LoweredResult as p, Artifact as r, DeployedNode as s, AlchemyStateLayer as t, LowerContext as u, PreflightInput as v, buildConfig as w, ProvisionerDescriptor as x, PrismaAppConfig as y };
325
- //# sourceMappingURL=app-config-FyPJc4X--D8hIqPlm.d.mts.map
410
+ export { containerEnvVarName as A, ProvisionEdge as C, TeardownInput as D, StateDescriptor as E, lowering as F, mergedProviders as I, resolveStateLayer as L, deserializeContainers as M, joinDeployment as N, buildConfig as O, lower as P, PrismaAppConfig as S, ServiceLowering as T, Lowering as _, Bundle as a, PackageInput as b, DeployedEntity as c, ExtensionDescriptor as d, LocateContainerInput as f, LoweredResult as g, LowerOptions as h, AssembleInput as i, defineConfig as j, containerEnv as k, DeployedNode as l, LowerError as m, ApplicationDescriptor as n, ContainerDescriptor as o, LowerContext as p, Artifact as r, ContainerInstance as s, AlchemyStateLayer as t, DeploymentResult as u, NodeDescriptor as v, ProvisionerDescriptor as w, PreflightInput as x, Outputs as y };
411
+ //# sourceMappingURL=app-config-DJdU4ubR-BwioNK8j.d.mts.map
package/dist/bin.mjs CHANGED
@@ -2,19 +2,9 @@
2
2
  import { Cli, Command, Option, UsageError } from "clipanion";
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
- import { spawnSync } from "node:child_process";
6
- import { createManagementApiClient } from "@prisma/management-api-sdk";
7
- import * as Context from "effect/Context";
8
- import * as Effect from "effect/Effect";
9
- import * as Layer from "effect/Layer";
10
- import * as Redacted from "effect/Redacted";
11
- import * as Config from "effect/Config";
12
- import * as Data from "effect/Data";
13
- import * as Provider from "alchemy/Provider";
14
- import { Resource } from "alchemy";
15
- import * as Schedule from "effect/Schedule";
16
5
  import * as c12 from "c12";
17
6
  import { pathToFileURL } from "node:url";
7
+ import { spawnSync } from "node:child_process";
18
8
  //#region ../../0-framework/3-tooling/assemble/dist/index.mjs
19
9
  /**
20
10
  * A user-facing assembly failure with a message that already names the fix
@@ -585,157 +575,34 @@ function Load(root, opts) {
585
575
  throw new LoadError("Load expects a service or module root (received another node kind).");
586
576
  }
587
577
  //#endregion
588
- //#region ../../1-prisma-cloud/0-lowering/lowering/dist/http-CJM0E6rj.mjs
589
- /**
590
- * The Prisma service token used to authenticate Management API calls. Kept
591
- * as a Redacted value so it never lands in logs or error output.
592
- */
593
- var PrismaCredentials = class extends Context.Service()("PrismaCredentials") {};
594
- /** Resolve the token from the `PRISMA_SERVICE_TOKEN` environment variable. */
595
- const fromEnv = () => Layer.effect(PrismaCredentials, Effect.gen(function* () {
596
- return { token: yield* Config.redacted("PRISMA_SERVICE_TOKEN") };
597
- }));
598
- /**
599
- * The typed Prisma Management API client, built once from the resolved
600
- * credentials. Providers yield this in their outer Effect and call it inside
601
- * `reconcile` / `delete`.
602
- */
603
- var ManagementClient = class extends Context.Service()("PrismaManagementClient") {};
604
- const layer = () => Layer.effect(ManagementClient, Effect.gen(function* () {
605
- const { token } = yield* PrismaCredentials;
606
- return createManagementApiClient({ token: Redacted.value(token) });
607
- }));
608
- /** A non-2xx response from the Management API (or a transport failure). */
609
- var PrismaApiError = class extends Data.TaggedError("PrismaApiError") {};
610
- const attempt = (f) => Effect.tryPromise({
611
- try: f,
612
- catch: (cause) => new PrismaApiError({
613
- status: 0,
614
- message: String(cause)
615
- })
616
- });
617
- const fail = (r) => Effect.fail(new PrismaApiError({
618
- status: r.response.status,
619
- message: JSON.stringify(r.error)
620
- }));
621
- /** Unwrap `data`, failing on any API error. Preserves the SDK's response type. */
622
- const call = (f) => attempt(f).pipe(Effect.flatMap((r) => r.error !== void 0 || r.data === void 0 ? fail(r) : Effect.succeed(r.data)));
623
- /** Fire-and-forget a call, tolerating a 404 (already deleted). */
624
- const callVoid = (f) => attempt(f).pipe(Effect.flatMap((r) => r.response.status === 404 || r.error === void 0 ? Effect.void : fail(r)));
625
- Resource("Prisma.Bucket");
626
- Resource("Prisma.BucketKey");
627
- Schedule.both(Schedule.exponential("2 seconds", 2), Schedule.during("5 minutes"));
628
- Resource("Prisma.ComputeService");
629
- Resource("Prisma.Deployment");
630
- Resource("Prisma.EnvironmentVariable");
631
- Resource("PrismaCloud.ServiceKey");
632
- Resource("Prisma.Connection");
633
- Resource("Prisma.Database");
634
- Resource("Prisma.Project");
635
- //#endregion
636
- //#region ../../1-prisma-cloud/0-lowering/lowering/dist/index.mjs
637
- /** Raised with `ensure: false` when the app's Project (or a named stage's Branch) doesn't exist. */
638
- var ContainerNotFoundError = class extends Data.TaggedError("ContainerNotFoundError") {};
639
- const listAllProjects = (client) => Effect.gen(function* () {
640
- const projects = [];
641
- let cursor;
642
- for (;;) {
643
- const query = cursor === void 0 ? {} : { cursor };
644
- const page = yield* call(() => client.GET("/v1/projects", { params: { query } }));
645
- projects.push(...page.data);
646
- if (!page.pagination.hasMore || page.pagination.nextCursor === null) break;
647
- cursor = page.pagination.nextCursor;
578
+ //#region ../../0-framework/1-core/core/dist/container-transport-DKmKg5JQ.mjs
579
+ /** '@prisma/composer-prisma-cloud' → 'PRISMA_COMPOSER_CONTAINER_PRISMA_COMPOSER_PRISMA_CLOUD' */
580
+ function containerEnvVarName(extensionId) {
581
+ return `PRISMA_COMPOSER_CONTAINER_${extensionId.toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "")}`;
582
+ }
583
+ function collisionError(a, b, varName) {
584
+ return /* @__PURE__ */ new Error(`Extension ids "${a}" and "${b}" both mangle to the container transport variable "${varName}" — rename one of the extensions.`);
585
+ }
586
+ function emptySerializeError(extensionId) {
587
+ return /* @__PURE__ */ new Error(`Extension "${extensionId}"'s container instance serialized to an empty string — ContainerInstance.serialize() must return a non-empty string.`);
588
+ }
589
+ /** The env entries the CLI sets on the alchemy process: `{ [containerEnvVarName(id)]: instance.serialize() }` for every resolved instance. */
590
+ function containerEnv(instances) {
591
+ const env = {};
592
+ const ownerByVarName = /* @__PURE__ */ new Map();
593
+ for (const [extensionId, instance] of instances) {
594
+ const varName = containerEnvVarName(extensionId);
595
+ const owner = ownerByVarName.get(varName);
596
+ if (owner !== void 0) throw collisionError(owner, extensionId, varName);
597
+ ownerByVarName.set(varName, extensionId);
598
+ const serialized = instance.serialize();
599
+ if (serialized.length === 0) throw emptySerializeError(extensionId);
600
+ env[varName] = serialized;
648
601
  }
649
- return projects;
650
- });
651
- /**
652
- * Workspace ids circulate in two shapes: `wksp_`-prefixed and bare. Compare
653
- * bare-to-bare so a `wksp_`-prefixed API id still matches a bare configured
654
- * one (the same normalization `state/bootstrap.ts` applies to the same
655
- * `/v1/projects` listing).
656
- */
657
- const bareWorkspaceId = (id) => id.startsWith("wksp_") ? id.slice(5) : id;
658
- /**
659
- * Finds the app's Project by name in the workspace — PDP allows duplicate
660
- * project names, so more than one can match; the oldest wins. Creates one
661
- * if none match, unless `ensure` is `false` (find-only — `destroy`), in
662
- * which case an absent Project fails with `ContainerNotFoundError`. No
663
- * ownership marker and no `--project` override (both deferred — see
664
- * ADR-0019).
665
- */
666
- const resolveProject = (client, workspaceId, appName, ensure) => Effect.gen(function* () {
667
- const oldest = (yield* listAllProjects(client)).filter((p) => bareWorkspaceId(p.workspace.id) === bareWorkspaceId(workspaceId) && p.name === appName).sort((a, b) => a.createdAt.localeCompare(b.createdAt))[0];
668
- if (oldest !== void 0) return oldest.id;
669
- if (!ensure) return yield* Effect.fail(new ContainerNotFoundError({ appName }));
670
- return (yield* call(() => client.POST("/v1/projects", { body: {
671
- name: appName,
672
- workspaceId
673
- } }))).data.id;
674
- });
675
- const findBranchId = (client, projectId, gitName) => call(() => client.GET("/v1/projects/{projectId}/branches", { params: {
676
- path: { projectId },
677
- query: { gitName }
678
- } })).pipe(Effect.map((page) => page.data[0]?.id));
679
- /**
680
- * Finds the stage's Branch by its exact `gitName`, creating it if absent
681
- * unless `ensure` is `false` (find-only — `destroy`), in which case an
682
- * absent Branch fails with `ContainerNotFoundError`. The Management API has
683
- * no server-side "create-or-return" idempotency (`POST
684
- * /v1/projects/:id/branches` 409s on a duplicate `gitName`, with no request
685
- * field to make that a no-op), so idempotency is client-side: observe
686
- * first, and on a racing 409 from create, re-observe rather than fail.
687
- */
688
- const resolveBranch = (client, projectId, gitName, appName, ensure) => Effect.gen(function* () {
689
- const existing = yield* findBranchId(client, projectId, gitName);
690
- if (existing !== void 0) return existing;
691
- if (!ensure) return yield* Effect.fail(new ContainerNotFoundError({
692
- appName,
693
- stage: gitName
694
- }));
695
- return yield* call(() => client.POST("/v1/projects/{projectId}/branches", {
696
- params: { path: { projectId } },
697
- body: { gitName }
698
- })).pipe(Effect.map((r) => r.data.id), Effect.catch((err) => err.status === 409 ? findBranchId(client, projectId, gitName).pipe(Effect.flatMap((id) => id === void 0 ? Effect.fail(err) : Effect.succeed(id))) : Effect.fail(err)));
699
- });
700
- /**
701
- * Resolves the two containers a stage's deploy runs into (ADR-0019): the
702
- * app's **Project**, found-or-created by name, and — for a named stage
703
- * only — its **Branch**, found-or-created by `gitName`. The default stage
704
- * (no `stage`) creates no Branch; `branchId` is omitted. With `ensure:
705
- * false` (`destroy`), nothing is created — an absent Project or Branch
706
- * fails with `ContainerNotFoundError` instead.
707
- */
708
- const resolveContainer = (opts) => Effect.gen(function* () {
709
- const client = yield* ManagementClient;
710
- const ensure = opts.ensure ?? true;
711
- const projectId = yield* resolveProject(client, opts.workspaceId, opts.appName, ensure);
712
- if (opts.stage === void 0) return { projectId };
713
- return {
714
- projectId,
715
- branchId: yield* resolveBranch(client, projectId, opts.stage, opts.appName, ensure)
716
- };
717
- });
718
- /**
719
- * Soft-deletes a Branch. Tolerates a 404 (already gone). The API refuses if
720
- * the Branch still has live members or is the production/default Branch —
721
- * that surfaces as a `PrismaApiError`.
722
- */
723
- const deleteBranch = (branchId) => Effect.gen(function* () {
724
- const client = yield* ManagementClient;
725
- yield* callVoid(() => client.DELETE("/v1/branches/{branchId}", { params: { path: { branchId } } }));
726
- });
727
- /**
728
- * Deletes a Project. Tolerates a 404 (already gone). The API refuses with a
729
- * 400 if the Project still has live dependencies (e.g. another stage's
730
- * Branch/resources) — that surfaces as a `PrismaApiError`.
731
- */
732
- const deleteProject = (projectId) => Effect.gen(function* () {
733
- const client = yield* ManagementClient;
734
- yield* callVoid(() => client.DELETE("/v1/projects/{id}", { params: { path: { id: projectId } } }));
735
- });
736
- Provider.ProviderCollection()("Prisma");
602
+ return env;
603
+ }
737
604
  //#endregion
738
- //#region ../../0-framework/3-tooling/cli/dist/cli-BeT-XTbX.mjs
605
+ //#region ../../0-framework/3-tooling/cli/dist/cli-Bc6_i0ls.mjs
739
606
  /**
740
607
  * A user-facing failure with a message that already names the fix (deploy-cli.md
741
608
  * § Error surface). `bin.ts` catches this — and any other Error, including
@@ -747,89 +614,6 @@ var CliError = class extends Error {
747
614
  this.name = "CliError";
748
615
  }
749
616
  };
750
- /**
751
- * Pipeline pre-stack step: resolves the app's Project + (named stage) Branch
752
- * via `@internal/lowering`'s `resolveContainer`, before the generated stack file
753
- * runs — `deploy` creates-if-absent, `destroy` finds only.
754
- */
755
- /** Validates `stage` as a git ref name via `git check-ref-format` — no silent normalization. */
756
- function validateStageName(stage) {
757
- const result = spawnSync("git", ["check-ref-format", `refs/heads/${stage}`], { stdio: "ignore" });
758
- if (result.error) throw new CliError(`git is required to validate --stage "${stage}" (git check-ref-format): ${result.error.message}.`);
759
- if (result.status !== 0) throw new CliError(`Invalid --stage "${stage}": must be a valid git ref name (git check-ref-format rejected "refs/heads/${stage}").`);
760
- }
761
- async function ensureContainers(input, deps) {
762
- const env = input.env ?? process.env;
763
- const workspaceId = env["PRISMA_WORKSPACE_ID"];
764
- if (workspaceId === void 0 || workspaceId.length === 0) throw new CliError("environment variable PRISMA_WORKSPACE_ID is required.");
765
- if (deps?.client === void 0 && (env["PRISMA_SERVICE_TOKEN"] ?? "").length === 0) throw new CliError("environment variable PRISMA_SERVICE_TOKEN is required.");
766
- if (input.stage !== void 0) validateStageName(input.stage);
767
- const program = resolveContainer({
768
- workspaceId,
769
- appName: input.appName,
770
- ...input.stage !== void 0 ? { stage: input.stage } : {},
771
- ensure: input.command === "deploy"
772
- }).pipe(Effect.map((c) => ({
773
- ok: true,
774
- container: c
775
- })), Effect.catchTag("ContainerNotFoundError", (e) => Effect.succeed({
776
- ok: false,
777
- message: `Nothing deployed for ${e.appName}${e.stage ? `/${e.stage}` : ""} — deploy it first.`
778
- })), Effect.catchTag("PrismaApiError", (e) => Effect.succeed({
779
- ok: false,
780
- message: `Prisma Management API error resolving containers: ${e.message}.`
781
- })));
782
- const provided = deps?.client !== void 0 ? program.pipe(Effect.provideService(ManagementClient, deps.client)) : program.pipe(Effect.provide(layer().pipe(Layer.provide(fromEnv()))));
783
- const outcome = await Effect.runPromise(provided);
784
- if (!outcome.ok) throw new CliError(outcome.message);
785
- return outcome.container;
786
- }
787
- /**
788
- * Soft-deletes a named stage's Branch after a successful `alchemy destroy`
789
- * has removed its members (spec §10) — the Management API refuses to delete
790
- * a Branch that still has live members.
791
- */
792
- async function deleteStageBranch(input, deps) {
793
- const env = input.env ?? process.env;
794
- if (deps?.client === void 0 && (env["PRISMA_SERVICE_TOKEN"] ?? "").length === 0) throw new CliError("environment variable PRISMA_SERVICE_TOKEN is required.");
795
- const program = deleteBranch(input.branchId).pipe(Effect.map(() => ({ ok: true })), Effect.catchTag("PrismaApiError", (e) => Effect.succeed({
796
- ok: false,
797
- message: `Failed to delete the stage Branch: ${e.message}.`
798
- })));
799
- const provided = deps?.client !== void 0 ? program.pipe(Effect.provideService(ManagementClient, deps.client)) : program.pipe(Effect.provide(layer().pipe(Layer.provide(fromEnv()))));
800
- const outcome = await Effect.runPromise(provided);
801
- if (!outcome.ok) throw new CliError(outcome.message);
802
- }
803
- /**
804
- * Best-effort cleanup after a successful `--production` destroy: removes
805
- * the app's Project so hand-run stacks don't accumulate as empty Projects
806
- * (they eventually hit the workspace's plan limit). Unlike `deleteStageBranch`,
807
- * this never throws: the destroy itself already succeeded, and the API's own
808
- * 400 ("still has dependencies") is the only check that matters — failing
809
- * the command over a cleanup step would be worse than leaving a Project shell.
810
- */
811
- async function deleteAppProject(input, deps) {
812
- const env = input.env ?? process.env;
813
- if (deps?.client === void 0 && (env["PRISMA_SERVICE_TOKEN"] ?? "").length === 0) {
814
- console.warn(`Skipped removing the Project (${input.projectId}): PRISMA_SERVICE_TOKEN is not set.`);
815
- return;
816
- }
817
- const program = deleteProject(input.projectId).pipe(Effect.map(() => ({ ok: true })), Effect.catchTag("PrismaApiError", (e) => Effect.succeed({
818
- ok: false,
819
- error: e
820
- })));
821
- const provided = deps?.client !== void 0 ? program.pipe(Effect.provideService(ManagementClient, deps.client)) : program.pipe(Effect.provide(layer().pipe(Layer.provide(fromEnv()))));
822
- const outcome = await Effect.runPromise(provided);
823
- if (outcome.ok) {
824
- console.log(`Removed the Project (${input.projectId}) — nothing was left in it.`);
825
- return;
826
- }
827
- if (outcome.error.status === 400) {
828
- console.log(`Kept the Project (${input.projectId}) — it still has another stage's resources.`);
829
- return;
830
- }
831
- console.warn(`Could not remove the Project (${input.projectId}) after destroy: ${outcome.error.message}.`);
832
- }
833
617
  /** Pipeline step 6: writes a regenerated-every-run, independently runnable stack module at `.prisma-composer/alchemy.run.ts`. */
834
618
  const GENERATED_DIR = ".prisma-composer";
835
619
  const GENERATED_FILE = "alchemy.run.ts";
@@ -932,7 +716,8 @@ function validateConfigShape(loaded, configPath) {
932
716
  if (seen.has(id)) throw new CliError(`${CONFIG_FILENAME}: extension "${id}" is listed more than once in \`extensions\`.`);
933
717
  seen.add(id);
934
718
  }
935
- if (typeof loaded["state"] !== "function") throw fieldError("state", "must be a function returning the deploy state layer (e.g. () => prismaState())");
719
+ const state = loaded["state"];
720
+ if (!isRecord(state) || typeof state["extension"] !== "string" || typeof state["create"] !== "function") throw fieldError("state", "must be a state descriptor (e.g. prismaState())");
936
721
  return blindCast(loaded);
937
722
  }
938
723
  /**
@@ -991,7 +776,7 @@ function resolveAlchemyBin(startDir) {
991
776
  dir = parent;
992
777
  }
993
778
  }
994
- /** Runs `alchemy deploy|destroy <stack file> --yes [--stage <stage>]`, inheriting stdio + env, plus the resolved Project/Branch ids. */
779
+ /** Runs `alchemy deploy|destroy <stack file> --yes [--stage <stage>]`, inheriting stdio + env, plus every extension's resolved container. */
995
780
  function runAlchemy(input) {
996
781
  const bin = resolveAlchemyBin(input.cwd);
997
782
  const args = [
@@ -1005,8 +790,7 @@ function runAlchemy(input) {
1005
790
  stdio: "inherit",
1006
791
  env: {
1007
792
  ...input.env ?? process.env,
1008
- PRISMA_PROJECT_ID: input.projectId,
1009
- ...input.branchId !== void 0 ? { PRISMA_BRANCH_ID: input.branchId } : {}
793
+ ...input.containerEnv
1010
794
  }
1011
795
  });
1012
796
  if (result.error !== void 0) throw result.error;
@@ -1032,6 +816,12 @@ function validateRegistryCoverage(graph, config) {
1032
816
  lookup(extensions, node.build.extension, node.build.type, "build", `service node "${id}"'s build descriptor`);
1033
817
  }
1034
818
  }
819
+ /** A stage name must be a valid git ref (deploy-cli.md) — checked via `git check-ref-format`, never silently normalized. Runs before anything platform-specific. */
820
+ function validateStageName(stage) {
821
+ const result = spawnSync("git", ["check-ref-format", `refs/heads/${stage}`], { stdio: "ignore" });
822
+ if (result.error) throw new CliError(`git is required to validate --stage "${stage}" (git check-ref-format): ${result.error.message}.`);
823
+ if (result.status !== 0) throw new CliError(`Invalid --stage "${stage}": must be a valid git ref name (git check-ref-format rejected "refs/heads/${stage}").`);
824
+ }
1035
825
  /**
1036
826
  * Argument parsing (clipanion — prisma-next's CLI idiom, see
1037
827
  * prisma-next/packages/1-framework/3-tooling/cli/src/migration-cli.ts) +
@@ -1127,6 +917,7 @@ async function run(argv, deps = {}) {
1127
917
  throw error;
1128
918
  }
1129
919
  const stage = effectiveStage(args);
920
+ if (stage !== void 0) validateStageName(stage);
1130
921
  const cwd = process.cwd();
1131
922
  if (args.command === "destroy") warnIfNoLocalDeployState(cwd);
1132
923
  const resolvedEntryPath = path.resolve(cwd, args.entry);
@@ -1146,18 +937,32 @@ async function run(argv, deps = {}) {
1146
937
  if (args.command === "destroy" && error instanceof Error) throw new CliError(`${error.message}\n\ndestroy evaluates the same stack program as deploy, which packages the built artifacts — so the app must be built first. Run the build, then retry the destroy.`);
1147
938
  throw error;
1148
939
  }
1149
- const { projectId, branchId } = await (deps.ensureContainers ?? ensureContainers)({
1150
- command: args.command,
1151
- appName: name,
1152
- stage
1153
- });
940
+ const containers = /* @__PURE__ */ new Map();
941
+ for (const extension of config.extensions) {
942
+ if (extension.container === void 0) continue;
943
+ try {
944
+ if (args.command === "deploy") containers.set(extension.id, await extension.container.ensure({
945
+ appName: name,
946
+ stage
947
+ }));
948
+ else {
949
+ const instance = await extension.container.locate({
950
+ appName: name,
951
+ stage
952
+ });
953
+ if (instance === void 0) throw new CliError(`Nothing deployed for ${name}${stage !== void 0 ? `/${stage}` : ""} — deploy it first.`);
954
+ containers.set(extension.id, instance);
955
+ }
956
+ } catch (error) {
957
+ throw error instanceof CliError ? error : new CliError(error instanceof Error ? error.message : String(error));
958
+ }
959
+ }
1154
960
  if (args.command === "deploy") for (const extension of config.extensions) {
1155
961
  if (extension.preflight === void 0) continue;
1156
962
  try {
1157
963
  await extension.preflight({
1158
964
  graph,
1159
- projectId,
1160
- branchId,
965
+ container: containers.get(extension.id),
1161
966
  stage
1162
967
  });
1163
968
  } catch (error) {
@@ -1177,28 +982,36 @@ async function run(argv, deps = {}) {
1177
982
  stackFileRelativePath: GENERATED_STACK_RELATIVE_PATH,
1178
983
  cwd,
1179
984
  stage,
1180
- projectId,
1181
- ...branchId !== void 0 ? { branchId } : {}
985
+ containerEnv: containerEnv(containers)
1182
986
  });
1183
987
  if (status !== 0) {
1184
988
  console.error(`\nGenerated stack file: ${stackPath}`);
1185
989
  console.error(`Run \`alchemy ${args.command} ${GENERATED_STACK_RELATIVE_PATH} --yes\` from ${cwd} to reproduce this directly.`);
1186
990
  return status;
1187
991
  }
1188
- if (args.command === "destroy") for (const extension of config.extensions) {
1189
- if (extension.teardown === void 0) continue;
1190
- try {
1191
- await extension.teardown({
1192
- projectId,
1193
- branchId,
1194
- stage
1195
- });
1196
- } catch (error) {
1197
- throw error instanceof CliError ? error : new CliError(error instanceof Error ? error.message : String(error));
992
+ if (args.command === "destroy") {
993
+ for (const extension of config.extensions) {
994
+ if (extension.teardown === void 0) continue;
995
+ try {
996
+ await extension.teardown({
997
+ container: containers.get(extension.id),
998
+ stage
999
+ });
1000
+ } catch (error) {
1001
+ throw error instanceof CliError ? error : new CliError(error instanceof Error ? error.message : String(error));
1002
+ }
1003
+ }
1004
+ for (const extension of config.extensions) {
1005
+ if (extension.container === void 0) continue;
1006
+ const instance = containers.get(extension.id);
1007
+ if (instance === void 0) continue;
1008
+ try {
1009
+ await extension.container.remove(instance);
1010
+ } catch (error) {
1011
+ throw error instanceof CliError ? error : new CliError(error instanceof Error ? error.message : String(error));
1012
+ }
1198
1013
  }
1199
1014
  }
1200
- if (args.command === "destroy" && branchId !== void 0) await (deps.deleteBranch ?? ((input) => deleteStageBranch(input)))({ branchId });
1201
- else if (args.command === "destroy") await (deps.deleteProject ?? ((input) => deleteAppProject(input)))({ projectId });
1202
1015
  return status;
1203
1016
  } catch (error) {
1204
1017
  console.error(`\nGenerated stack file: ${stackPath}`);