@prisma/composer 0.1.0-dev.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/dist/app-config-CpWN1ZfP-CZ1c6Eqk.d.mts +325 -0
- package/dist/assertions.d.mts +31 -0
- package/dist/assertions.mjs +35 -0
- package/dist/assertions.mjs.map +1 -0
- package/dist/bin.mjs +1229 -0
- package/dist/bin.mjs.map +1 -0
- package/dist/casts-Ci5rYYaR.mjs +82 -0
- package/dist/casts-Ci5rYYaR.mjs.map +1 -0
- package/dist/casts.d.mts +78 -0
- package/dist/casts.mjs +2 -0
- package/dist/config-D-h0FACe.d.mts +1 -0
- package/dist/config-ad92ubCB-D0-dZUgA.d.mts +568 -0
- package/dist/config.d.mts +3 -0
- package/dist/config.mjs +9 -0
- package/dist/config.mjs.map +1 -0
- package/dist/deploy-D-h0FACe.d.mts +1 -0
- package/dist/deploy.d.mts +3 -0
- package/dist/deploy.mjs +305 -0
- package/dist/deploy.mjs.map +1 -0
- package/dist/dist-B0axxnBf.mjs +193 -0
- package/dist/dist-B0axxnBf.mjs.map +1 -0
- package/dist/graph-BmrUEdo9-6Oq1hSmS.mjs +688 -0
- package/dist/graph-BmrUEdo9-6Oq1hSmS.mjs.map +1 -0
- package/dist/graph-DXuL5tN6-BeAHOb0u.d.mts +18 -0
- package/dist/index-C1f1Aot7.d.mts +16 -0
- package/dist/index-Cy4C23u1.d.mts +32 -0
- package/dist/index.d.mts +4 -0
- package/dist/index.mjs +3 -0
- package/dist/nextjs-control.d.mts +14 -0
- package/dist/nextjs-control.mjs +105 -0
- package/dist/nextjs-control.mjs.map +1 -0
- package/dist/nextjs.d.mts +2 -0
- package/dist/nextjs.mjs +12 -0
- package/dist/nextjs.mjs.map +1 -0
- package/dist/node-control.d.mts +11 -0
- package/dist/node-control.mjs +142 -0
- package/dist/node-control.mjs.map +1 -0
- package/dist/node.d.mts +23 -0
- package/dist/node.mjs +12 -0
- package/dist/node.mjs.map +1 -0
- package/dist/report.d.mts +17 -0
- package/dist/report.mjs +79 -0
- package/dist/report.mjs.map +1 -0
- package/dist/rpc.d.mts +53 -0
- package/dist/rpc.mjs +184 -0
- package/dist/rpc.mjs.map +1 -0
- package/dist/testing.d.mts +23 -0
- package/dist/testing.mjs +45 -0
- package/dist/testing.mjs.map +1 -0
- package/package.json +69 -0
package/dist/bin.mjs
ADDED
|
@@ -0,0 +1,1229 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Cli, Command, Option, UsageError } from "clipanion";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import * as Layer from "effect/Layer";
|
|
7
|
+
import { createManagementApiClient } from "@prisma/management-api-sdk";
|
|
8
|
+
import * as Context from "effect/Context";
|
|
9
|
+
import * as Effect from "effect/Effect";
|
|
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
|
+
import * as c12 from "c12";
|
|
17
|
+
import { pathToFileURL } from "node:url";
|
|
18
|
+
//#region ../../0-framework/3-tooling/assemble/dist/index.mjs
|
|
19
|
+
/**
|
|
20
|
+
* A user-facing assembly failure with a message that already names the fix
|
|
21
|
+
* (mirrors @internal/cli's CliError contract). This package must not import
|
|
22
|
+
* CliError — its second consumer is the future programmatic deploy API, not
|
|
23
|
+
* just the CLI — so it throws its own typed error; the CLI maps it (or lets
|
|
24
|
+
* it propagate, since bin.ts already treats every Error uniformly: print the
|
|
25
|
+
* message, exit nonzero).
|
|
26
|
+
*/
|
|
27
|
+
var AssembleError = class extends Error {
|
|
28
|
+
constructor(message) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = "AssembleError";
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* The registry route for one service's build: extension by
|
|
35
|
+
* `build.extension`, node descriptor by `build.type`, kind must be "build".
|
|
36
|
+
* The CLI's coverage validation reports the same misses earlier with the
|
|
37
|
+
* config fix; these errors are the backstop for programmatic callers.
|
|
38
|
+
*/
|
|
39
|
+
function buildDescriptorAssemble(config, node, address, cwd) {
|
|
40
|
+
const { extension, type } = node.build;
|
|
41
|
+
const extensionDescriptor = config.extensions.find((candidate) => candidate.id === extension);
|
|
42
|
+
if (extensionDescriptor === void 0) throw new AssembleError(`No extension "${extension}" is configured (needed by service "${node.name}"'s build) — add it to prisma-composer.config.ts's \`extensions\`.`);
|
|
43
|
+
const nodeDescriptor = extensionDescriptor.nodes[type];
|
|
44
|
+
if (nodeDescriptor === void 0) throw new AssembleError(`Extension "${extension}" has no descriptor for build type "${type}" (known: ${Object.keys(extensionDescriptor.nodes).join(", ")}).`);
|
|
45
|
+
if (nodeDescriptor.kind !== "build") throw new AssembleError(`Extension "${extension}"'s descriptor for type "${type}" is a "${nodeDescriptor.kind}" descriptor — assembling a service build needs a "build" descriptor.`);
|
|
46
|
+
return nodeDescriptor.assemble({
|
|
47
|
+
build: node.build,
|
|
48
|
+
address,
|
|
49
|
+
cwd
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
async function assembleServices(graph, config, cwd, run) {
|
|
53
|
+
const runAssembler = run ?? ((node, address, nodeCwd) => buildDescriptorAssemble(config, node, address, nodeCwd));
|
|
54
|
+
const serviceNodes = graph.nodes.filter((n) => n.node.kind === "service");
|
|
55
|
+
if (serviceNodes.length === 0) throw new AssembleError("The loaded graph has no service to assemble.");
|
|
56
|
+
const bundles = {};
|
|
57
|
+
for (const { id, node } of serviceNodes) bundles[id] = await runAssembler(node, id, cwd);
|
|
58
|
+
return { bundles };
|
|
59
|
+
}
|
|
60
|
+
//#endregion
|
|
61
|
+
//#region ../../0-framework/0-foundation/foundation/dist/casts.mjs
|
|
62
|
+
/**
|
|
63
|
+
* **Last-resort escape hatch for unsafe type assertions. Not a sanctioned tool to reach for.**
|
|
64
|
+
*
|
|
65
|
+
* Before reaching for `blindCast`, **rewrite the surrounding code so the cast becomes
|
|
66
|
+
* unnecessary**: tighten an input type, add a runtime check that narrows via a type
|
|
67
|
+
* predicate, restructure a generic so the compiler can see the relationship you're
|
|
68
|
+
* asserting, or use {@link castAs} when the value already satisfies the target type.
|
|
69
|
+
* Only when no rewrite is feasible does `blindCast` become the right answer — and at
|
|
70
|
+
* that point, the `Reason` literal you supply must articulate the compromise in
|
|
71
|
+
* language a reviewer can evaluate.
|
|
72
|
+
*
|
|
73
|
+
* The reviewer **will** validate the `Reason`. If it doesn't hold up under scrutiny,
|
|
74
|
+
* that is not a signal to soften the reason; it is a signal to go back and solve the
|
|
75
|
+
* underlying type-system problem properly. An unconvincing justification is rework,
|
|
76
|
+
* not a free pass.
|
|
77
|
+
*
|
|
78
|
+
* `blindCast` is the auditable form of `as Foo` / `as unknown as Foo`: it bypasses
|
|
79
|
+
* the compiler's checks (the input type is `unknown`, the output type is whatever the
|
|
80
|
+
* caller asks for), but it forces the unsafety to be named at the call site instead of
|
|
81
|
+
* smuggled in via a bare `as`. The `Reason` type parameter exists only at compile
|
|
82
|
+
* time — it is not present in the emitted JavaScript — but it is grep-able and
|
|
83
|
+
* visible to future readers.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```typescript
|
|
87
|
+
* const stringValue = blindCast<
|
|
88
|
+
* string,
|
|
89
|
+
* "JSON.parse returns `unknown`; this field is documented to be a string in the API contract"
|
|
90
|
+
* >(parsed[key]);
|
|
91
|
+
* ```
|
|
92
|
+
*
|
|
93
|
+
* @typeParam TargetType - The type the caller is asserting the input has.
|
|
94
|
+
* @typeParam _Reason - A string literal describing why bypassing the type system is necessary here.
|
|
95
|
+
* Only meaningful at compile time. The reviewer evaluates whether it justifies the unsafety.
|
|
96
|
+
*/
|
|
97
|
+
function blindCast(input) {
|
|
98
|
+
return input;
|
|
99
|
+
}
|
|
100
|
+
//#endregion
|
|
101
|
+
//#region ../../0-framework/1-core/core/dist/graph-BmrUEdo9.mjs
|
|
102
|
+
/** Thrown by Load when the graph is malformed. */
|
|
103
|
+
var LoadError = class extends Error {
|
|
104
|
+
constructor(message) {
|
|
105
|
+
super(message);
|
|
106
|
+
this.name = "LoadError";
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Core model: node types and the factories that construct them, plain frozen
|
|
111
|
+
* data objects. A node's `extension` + `type` form its deploy-time registry key (ADR-0017).
|
|
112
|
+
*/
|
|
113
|
+
const NODE = Symbol.for("prisma:node");
|
|
114
|
+
const SECRET_SOURCE = blindCast(Symbol.for("prisma:secret-source"));
|
|
115
|
+
/** True if `value` is a secret source (an `envSecret` result or a forwarded ctx.secrets ref). */
|
|
116
|
+
function isSecretSource(value) {
|
|
117
|
+
return typeof value === "object" && value !== null && blindCast(value)[SECRET_SOURCE] === true;
|
|
118
|
+
}
|
|
119
|
+
const PARAM_SOURCE = blindCast(Symbol.for("prisma:param-source"));
|
|
120
|
+
/** True if `value` is a param source (an `envParam` result or a forwarded ctx.params ref). */
|
|
121
|
+
function isParamSource(value) {
|
|
122
|
+
return typeof value === "object" && value !== null && blindCast(value)[PARAM_SOURCE] === true;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* True if `value` was constructed by this module's factories. Checks the
|
|
126
|
+
* brand only, never a prototype — a graph may mix nodes from a different
|
|
127
|
+
* installed copy of core (dual-package hazard).
|
|
128
|
+
*/
|
|
129
|
+
function isNode(value) {
|
|
130
|
+
return typeof value === "object" && value !== null && value[NODE] === true;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Stable topological sort: every edge's `from` precedes its `to` in the
|
|
134
|
+
* result. Ties (nodes with no ordering constraint between them) keep their
|
|
135
|
+
* relative order from `nodes` — so a graph already authored producer-first
|
|
136
|
+
* comes out byte-identical to its pre-sort layout; only a graph that
|
|
137
|
+
* genuinely needs reordering (e.g. a module wired via a forged ref pointing at a
|
|
138
|
+
* not-yet-provisioned producer) actually moves. A Kahn's-algorithm variant
|
|
139
|
+
* that always picks the ready node with the smallest original index. Edges
|
|
140
|
+
* whose endpoint falls outside `nodes` (e.g. a service-root's input edges
|
|
141
|
+
* targeting the root, which is appended separately) are ignored. Cycles
|
|
142
|
+
* cannot reach here: `assertDependencyDag` already rejects them for
|
|
143
|
+
* dependency edges, and input edges never cycle.
|
|
144
|
+
*/
|
|
145
|
+
function topoSort(nodes, edges) {
|
|
146
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
147
|
+
const indexOf = new Map(nodes.map((n, i) => [n.id, i]));
|
|
148
|
+
const indegree = new Map(nodes.map((n) => [n.id, 0]));
|
|
149
|
+
const successors = /* @__PURE__ */ new Map();
|
|
150
|
+
for (const edge of edges) {
|
|
151
|
+
if (!byId.has(edge.from) || !byId.has(edge.to)) continue;
|
|
152
|
+
const targets = successors.get(edge.from) ?? [];
|
|
153
|
+
targets.push(edge.to);
|
|
154
|
+
successors.set(edge.from, targets);
|
|
155
|
+
indegree.set(edge.to, (indegree.get(edge.to) ?? 0) + 1);
|
|
156
|
+
}
|
|
157
|
+
const ready = new Set(nodes.filter((n) => indegree.get(n.id) === 0).map((n) => n.id));
|
|
158
|
+
const order = [];
|
|
159
|
+
while (ready.size > 0) {
|
|
160
|
+
let next;
|
|
161
|
+
let bestIndex = Number.POSITIVE_INFINITY;
|
|
162
|
+
for (const id of ready) {
|
|
163
|
+
const index = indexOf.get(id) ?? Number.POSITIVE_INFINITY;
|
|
164
|
+
if (index < bestIndex) {
|
|
165
|
+
bestIndex = index;
|
|
166
|
+
next = id;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (next === void 0) break;
|
|
170
|
+
ready.delete(next);
|
|
171
|
+
order.push(next);
|
|
172
|
+
for (const target of successors.get(next) ?? []) {
|
|
173
|
+
const remaining = (indegree.get(target) ?? 0) - 1;
|
|
174
|
+
indegree.set(target, remaining);
|
|
175
|
+
if (remaining === 0) ready.add(target);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (order.length !== nodes.length) throw new LoadError(`topological sort processed ${order.length} of ${nodes.length} nodes — the graph contains a cycle that slipped past the DAG validation.`);
|
|
179
|
+
return order.map((id) => byId.get(id)).filter((n) => n !== void 0);
|
|
180
|
+
}
|
|
181
|
+
function serviceInputs(service, serviceId) {
|
|
182
|
+
if (typeof service.inputs !== "object" || service.inputs === null) throw new LoadError(`Service "${serviceId}" has no inputs map.`);
|
|
183
|
+
const nodes = [];
|
|
184
|
+
const edges = [];
|
|
185
|
+
for (const [input, value] of Object.entries(service.inputs)) {
|
|
186
|
+
const kind = isNode(value) ? value.kind : void 0;
|
|
187
|
+
if (kind === "resource") throw new LoadError(`Input "${input}" of "${serviceId}" is a resource node — a resource is provisioned by the composing module, never created for a service that mentions it. Declare the input as a dependency (the pack's dependency factory) and wire the module-provisioned resource's ref into it.`);
|
|
188
|
+
if (kind !== "dependency") throw new LoadError(`Input "${input}" of "${serviceId}" is not a branded dependency end (construct it with the dependency() factory).`);
|
|
189
|
+
if (value.type.length === 0) throw new LoadError(`Input "${input}" of "${serviceId}" has an empty node type.`);
|
|
190
|
+
const id = `${serviceId}.${input}`;
|
|
191
|
+
nodes.push({
|
|
192
|
+
id,
|
|
193
|
+
node: value
|
|
194
|
+
});
|
|
195
|
+
edges.push({
|
|
196
|
+
from: id,
|
|
197
|
+
to: serviceId,
|
|
198
|
+
input,
|
|
199
|
+
kind: "input"
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
nodes,
|
|
204
|
+
edges
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
function loadService(root, rootId) {
|
|
208
|
+
for (const [input, value] of Object.entries(root.inputs)) if (isNode(value) && value.kind === "dependency") throw new LoadError(`Service "${rootId}" has an unwired dependency input "${input}" — this service is composed by a module; deploy the module instead of loading "${rootId}" directly.`);
|
|
209
|
+
const secretSlots = Object.keys(root.secretSlots);
|
|
210
|
+
if (secretSlots.length > 0) {
|
|
211
|
+
const names = secretSlots.map((k) => `"${k}"`).join(", ");
|
|
212
|
+
throw new LoadError(`Service "${rootId}" declares secret slot${secretSlots.length > 1 ? "s" : ""} ${names} but is being loaded directly — a lone service has no enclosing scope to bind them. Compose it inside a module that binds each with envSecret('NAME').`);
|
|
213
|
+
}
|
|
214
|
+
const rootGraphNode = {
|
|
215
|
+
id: rootId,
|
|
216
|
+
node: root
|
|
217
|
+
};
|
|
218
|
+
const { nodes, edges } = serviceInputs(root, rootId);
|
|
219
|
+
return {
|
|
220
|
+
root: rootGraphNode,
|
|
221
|
+
nodes: [...topoSort(nodes, edges), rootGraphNode],
|
|
222
|
+
edges,
|
|
223
|
+
secrets: [],
|
|
224
|
+
params: []
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Builds the ref a provision() call hands back: the id (so a producer with no
|
|
229
|
+
* exposed ports — or an untyped slot — can still be wired wholesale) plus one
|
|
230
|
+
* ref-port per exposed contract, each the contract's own runtime value (so
|
|
231
|
+
* its `satisfies()` still works) tagged with the provider's id.
|
|
232
|
+
*/
|
|
233
|
+
function refFor(id, service) {
|
|
234
|
+
const ports = {};
|
|
235
|
+
for (const [port, contract] of Object.entries(service.expose ?? {})) ports[port] = {
|
|
236
|
+
...contract,
|
|
237
|
+
__providerId: id
|
|
238
|
+
};
|
|
239
|
+
return blindCast({
|
|
240
|
+
id,
|
|
241
|
+
...ports
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* The resource variant of refFor: a resource has exactly one port — the
|
|
246
|
+
* contract it provides — flattened onto the ref itself, tagged with the
|
|
247
|
+
* provider id. `id` is written last so a hostile contract value cannot
|
|
248
|
+
* clobber it.
|
|
249
|
+
*/
|
|
250
|
+
function refForResource(id, resource) {
|
|
251
|
+
return blindCast({
|
|
252
|
+
...resource.provides,
|
|
253
|
+
__providerId: id,
|
|
254
|
+
id
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
/** A wired value's producer id: a ref-port's `__providerId`, or a bare ref's `id`. */
|
|
258
|
+
function producerIdOf(ref) {
|
|
259
|
+
if (typeof ref !== "object" || ref === null) return void 0;
|
|
260
|
+
if ("__providerId" in ref && typeof ref.__providerId === "string") return ref.__providerId;
|
|
261
|
+
if ("id" in ref && typeof ref.id === "string") return ref.id;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Brands each `ctx.inputs` entry with the input key it stands for (see
|
|
265
|
+
* flatten): diagnostic only — usage attribution relies on the per-key object
|
|
266
|
+
* identity the branding copy creates, never on reading this back.
|
|
267
|
+
*/
|
|
268
|
+
const MODULE_INPUT_KEY = Symbol("prisma:module-input-key");
|
|
269
|
+
/** Same per-key identity trick as MODULE_INPUT_KEY, for the parallel `ctx.secrets` forwarding channel. */
|
|
270
|
+
const MODULE_SECRET_KEY = Symbol("prisma:module-secret-key");
|
|
271
|
+
/** Same per-key identity trick as MODULE_INPUT_KEY, for the parallel `ctx.params` forwarding channel. */
|
|
272
|
+
const MODULE_PARAM_KEY = Symbol("prisma:module-param-key");
|
|
273
|
+
/** Whether `ref` carries a callable `satisfies` that accepts `required` truthily. */
|
|
274
|
+
function satisfiesRequired(ref, required) {
|
|
275
|
+
return typeof ref === "object" && ref !== null && "satisfies" in ref && typeof ref.satisfies === "function" && ref.satisfies(required);
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Checks one recorded `wiring` object against the Deps it was wired against:
|
|
279
|
+
* every named input exists and is a dependency slot, every referenced
|
|
280
|
+
* producer is a real (by-now-provisioned) address, and a wired ref whose
|
|
281
|
+
* slot declares a required contract must satisfy() it — no producer-kind
|
|
282
|
+
* branching, the contract alone determines validity, whether the producer is
|
|
283
|
+
* a service port or a resource. Shared by both provisioned kinds so a
|
|
284
|
+
* module-as-child gets exactly the checks a service gets, and run once per
|
|
285
|
+
* entry against the one `byId` shared by the whole recursive flatten, so a
|
|
286
|
+
* forwarded ref resolves through to its real producer address regardless of
|
|
287
|
+
* which ancestor scope provisioned it.
|
|
288
|
+
*/
|
|
289
|
+
function validateWiring(pending, byId) {
|
|
290
|
+
const { deps, wiring, targetId, targetKind, enclosingModuleName } = pending;
|
|
291
|
+
for (const [input, ref] of Object.entries(wiring)) {
|
|
292
|
+
const declared = blindCast(deps[input]);
|
|
293
|
+
if (declared === void 0 || !isNode(declared) || declared.kind !== "dependency") throw new LoadError(`The deps for "${targetId}" name "${input}", which is not a dependency slot of that ${targetKind}.`);
|
|
294
|
+
const producerId = producerIdOf(ref);
|
|
295
|
+
const producer = producerId !== void 0 ? byId.get(producerId) : void 0;
|
|
296
|
+
if (producerId === void 0 || producer === void 0) throw new LoadError(`The deps for "${targetId}.${input}" reference "${String(producerId)}", which is not provisioned in module "${enclosingModuleName}".`);
|
|
297
|
+
const required = declared.required;
|
|
298
|
+
if (required !== void 0 && !satisfiesRequired(ref, required)) throw new LoadError(`The deps for "${targetId}.${input}" do not satisfy the slot's required contract.`);
|
|
299
|
+
}
|
|
300
|
+
for (const [input, rawValue] of Object.entries(deps)) {
|
|
301
|
+
const value = blindCast(rawValue);
|
|
302
|
+
if (!isNode(value) || wiring[input] !== void 0) continue;
|
|
303
|
+
if (value.kind === "dependency") throw new LoadError(`Dependency input "${input}" of provisioned ${targetKind} "${targetId}" is not wired to a producer (module "${enclosingModuleName}").`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/** The (unvalidated) edges a `wiring` object implies — one dependency edge per entry; `validateWiring` does the real checking. */
|
|
307
|
+
function wiringEdges(wiring, targetId) {
|
|
308
|
+
return Object.entries(wiring).map(([input, ref]) => ({
|
|
309
|
+
from: producerIdOf(ref) ?? "",
|
|
310
|
+
to: targetId,
|
|
311
|
+
input,
|
|
312
|
+
kind: "dependency"
|
|
313
|
+
}));
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Checks the secrets wired into one provisioned child: every declared slot is
|
|
317
|
+
* bound to a real secret source (an `envSecret` or a forwarded ctx.secrets
|
|
318
|
+
* ref), and no wired key names a slot the child doesn't declare — the secret
|
|
319
|
+
* analog of `validateWiring`'s per-input checks, but resolved inline (a source
|
|
320
|
+
* carries its own name, so no whole-graph pass is needed).
|
|
321
|
+
*/
|
|
322
|
+
function validateSecretBinding(child, id, secretWiring, enclosingModuleName) {
|
|
323
|
+
const { kind } = child;
|
|
324
|
+
for (const slot of Object.keys(child.secretSlots)) {
|
|
325
|
+
const bound = secretWiring[slot];
|
|
326
|
+
if (bound === void 0) throw new LoadError(`Secret slot "${slot}" of provisioned ${kind} "${id}" is not bound (module "${enclosingModuleName}") — bind it with envSecret('NAME') or forward ctx.secrets.`);
|
|
327
|
+
if (!isSecretSource(bound)) throw new LoadError(`Secret slot "${slot}" of "${id}" (module "${enclosingModuleName}") was wired with a non-secret value — use envSecret('NAME') or a forwarded ctx.secrets ref.`);
|
|
328
|
+
}
|
|
329
|
+
for (const slot of Object.keys(secretWiring)) if (!Object.hasOwn(child.secretSlots, slot)) throw new LoadError(`The secrets for "${id}" name "${slot}", which is not a secret slot of that ${kind} (module "${enclosingModuleName}").`);
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Checks the params wired into a provisioned SERVICE: unlike a secret slot, a
|
|
333
|
+
* declared param is never required to be bound here — it may fall back to its
|
|
334
|
+
* own `default` (checked later, at `buildConfig`) — so this only rejects a
|
|
335
|
+
* wired key that names something other than a real declared param.
|
|
336
|
+
*/
|
|
337
|
+
function validateServiceParamBinding(child, id, paramWiring, enclosingModuleName) {
|
|
338
|
+
for (const slot of Object.keys(paramWiring)) if (!Object.hasOwn(child.params, slot)) throw new LoadError(`The params for "${id}" name "${slot}", which is not a param of that service (module "${enclosingModuleName}").`);
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Checks the params wired into a provisioned MODULE: every declared
|
|
342
|
+
* param-forwarding slot must be bound to a real `ParamSource` — a slot has no
|
|
343
|
+
* schema of its own, so (unlike a service param) it cannot fall back to a
|
|
344
|
+
* literal or a default; the param analog of `validateSecretBinding`.
|
|
345
|
+
*/
|
|
346
|
+
function validateParamNeedBinding(child, id, paramWiring, enclosingModuleName) {
|
|
347
|
+
for (const slot of Object.keys(child.paramSlots)) {
|
|
348
|
+
const bound = paramWiring[slot];
|
|
349
|
+
if (bound === void 0) throw new LoadError(`Param slot "${slot}" of provisioned module "${id}" is not bound (module "${enclosingModuleName}") — bind it with a param source (e.g. envParam('NAME')) or forward ctx.params.`);
|
|
350
|
+
if (!isParamSource(bound)) throw new LoadError(`Param slot "${slot}" of "${id}" (module "${enclosingModuleName}") was wired with a non-source value — a module param-forwarding slot carries no schema to validate a literal against; use envParam('NAME') or a forwarded ctx.params ref.`);
|
|
351
|
+
}
|
|
352
|
+
for (const slot of Object.keys(paramWiring)) if (!Object.hasOwn(child.paramSlots, slot)) throw new LoadError(`The params for "${id}" name "${slot}", which is not a param slot of that module (module "${enclosingModuleName}").`);
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Recursively flattens one module's body into the shared graph state and
|
|
356
|
+
* returns its resolved ModuleOutputs (one ref-port per expose key) for the
|
|
357
|
+
* caller (the enclosing provision() call, or Load itself for the root) to
|
|
358
|
+
* use. `address` is this module's OWN full address, or `undefined` for the root
|
|
359
|
+
* scope — its direct children then get bare (unprefixed) addresses, keeping
|
|
360
|
+
* a single-level module identical to before nesting existed. `wiring` supplies a
|
|
361
|
+
* resolved producer ref-port for each of this module's OWN declared deps (empty
|
|
362
|
+
* for the root, which may not declare any — see the root non-empty-deps
|
|
363
|
+
* check in loadModule). `nodes`, `edges`, `pending`, and `byId` are shared
|
|
364
|
+
* across the ENTIRE recursive flatten, not per scope — a nested module may
|
|
365
|
+
* forward in a producer provisioned by an ancestor scope, and it is the
|
|
366
|
+
* shared `byId` (keyed by full address) that lets that resolve.
|
|
367
|
+
*/
|
|
368
|
+
function flatten(moduleNode, address, wiring, secretWiring, paramWiring, nodes, edges, pending, secretBindings, paramBindings, byId) {
|
|
369
|
+
const localIds = /* @__PURE__ */ new Set();
|
|
370
|
+
const used = /* @__PURE__ */ new Set();
|
|
371
|
+
const usedSecrets = /* @__PURE__ */ new Set();
|
|
372
|
+
const usedParams = /* @__PURE__ */ new Set();
|
|
373
|
+
const ctxInputs = {};
|
|
374
|
+
for (const key of Object.keys(moduleNode.deps)) {
|
|
375
|
+
const wired = wiring[key];
|
|
376
|
+
ctxInputs[key] = typeof wired === "object" && wired !== null ? {
|
|
377
|
+
...wired,
|
|
378
|
+
[MODULE_INPUT_KEY]: key
|
|
379
|
+
} : wired;
|
|
380
|
+
}
|
|
381
|
+
const markUsed = (values) => {
|
|
382
|
+
for (const value of Object.values(values)) for (const key of Object.keys(ctxInputs)) if (value === ctxInputs[key]) used.add(key);
|
|
383
|
+
};
|
|
384
|
+
const ctxSecrets = {};
|
|
385
|
+
for (const key of Object.keys(moduleNode.secretSlots)) {
|
|
386
|
+
const bound = secretWiring[key];
|
|
387
|
+
ctxSecrets[key] = typeof bound === "object" && bound !== null ? {
|
|
388
|
+
...bound,
|
|
389
|
+
[MODULE_SECRET_KEY]: key
|
|
390
|
+
} : bound;
|
|
391
|
+
}
|
|
392
|
+
const markSecretsUsed = (values) => {
|
|
393
|
+
for (const value of Object.values(values)) for (const key of Object.keys(ctxSecrets)) if (value === ctxSecrets[key]) usedSecrets.add(key);
|
|
394
|
+
};
|
|
395
|
+
const ctxParams = {};
|
|
396
|
+
for (const key of Object.keys(moduleNode.paramSlots)) {
|
|
397
|
+
const bound = paramWiring[key];
|
|
398
|
+
ctxParams[key] = typeof bound === "object" && bound !== null ? {
|
|
399
|
+
...bound,
|
|
400
|
+
[MODULE_PARAM_KEY]: key
|
|
401
|
+
} : bound;
|
|
402
|
+
}
|
|
403
|
+
const markParamsUsed = (values) => {
|
|
404
|
+
for (const value of Object.values(values)) for (const key of Object.keys(ctxParams)) if (value === ctxParams[key]) usedParams.add(key);
|
|
405
|
+
};
|
|
406
|
+
const provision = (child, opts) => {
|
|
407
|
+
const id = opts?.id ?? child.name;
|
|
408
|
+
const provisionWiring = opts?.deps;
|
|
409
|
+
const provisionSecrets = opts?.secrets;
|
|
410
|
+
const provisionParams = opts?.params;
|
|
411
|
+
if (typeof id !== "string" || id.length === 0) throw new LoadError(`provision() requires a non-empty id (module "${moduleNode.name}").`);
|
|
412
|
+
if (id.includes("_") || id.includes(".")) throw new LoadError(`provision() id "${id}" (module "${moduleNode.name}") may not contain "_" or "." — "_" is the config-key separator and "." the node-id path separator; either inside an id collides with the joined form of other names.`);
|
|
413
|
+
if (localIds.has(id)) throw new LoadError(`Duplicate provision id "${id}" in module "${moduleNode.name}".`);
|
|
414
|
+
const untrusted = child;
|
|
415
|
+
if (!isNode(untrusted) || untrusted.kind !== "service" && untrusted.kind !== "resource" && untrusted.kind !== "module") throw new LoadError(`provision("${id}") expects a branded service, resource, or module node (construct it with the service()/resource()/module() factories or a pack's own).`);
|
|
416
|
+
localIds.add(id);
|
|
417
|
+
const fullAddress = address === void 0 ? id : `${address}.${id}`;
|
|
418
|
+
if (child.kind === "resource") {
|
|
419
|
+
if (provisionWiring !== void 0) throw new LoadError(`provision("${id}") received deps for a resource — a resource has no dependency slots to satisfy.`);
|
|
420
|
+
if (provisionSecrets !== void 0) throw new LoadError(`provision("${id}") received secrets for a resource — a resource has no secret slots to satisfy.`);
|
|
421
|
+
if (provisionParams !== void 0) throw new LoadError(`provision("${id}") received params for a resource — a resource has no params to satisfy.`);
|
|
422
|
+
if (child.type.length === 0) throw new LoadError(`provision("${id}") received a resource with an empty node type.`);
|
|
423
|
+
byId.set(fullAddress, child);
|
|
424
|
+
nodes.push({
|
|
425
|
+
id: fullAddress,
|
|
426
|
+
node: child
|
|
427
|
+
});
|
|
428
|
+
return refForResource(fullAddress, child);
|
|
429
|
+
}
|
|
430
|
+
const localWiring = { ...provisionWiring ?? {} };
|
|
431
|
+
markUsed(localWiring);
|
|
432
|
+
const localSecrets = { ...provisionSecrets ?? {} };
|
|
433
|
+
markSecretsUsed(localSecrets);
|
|
434
|
+
validateSecretBinding(child, id, localSecrets, moduleNode.name);
|
|
435
|
+
const localParams = { ...provisionParams ?? {} };
|
|
436
|
+
markParamsUsed(localParams);
|
|
437
|
+
if (child.kind === "service") {
|
|
438
|
+
for (const slot of Object.keys(child.secretSlots)) {
|
|
439
|
+
const bound = localSecrets[slot];
|
|
440
|
+
if (isSecretSource(bound)) secretBindings.push({
|
|
441
|
+
serviceAddress: fullAddress,
|
|
442
|
+
slot,
|
|
443
|
+
source: bound
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
validateServiceParamBinding(child, id, localParams, moduleNode.name);
|
|
447
|
+
for (const [slot, binding] of Object.entries(localParams)) paramBindings.push({
|
|
448
|
+
serviceAddress: fullAddress,
|
|
449
|
+
slot,
|
|
450
|
+
binding
|
|
451
|
+
});
|
|
452
|
+
const inputs = serviceInputs(child, fullAddress);
|
|
453
|
+
nodes.push(...inputs.nodes, {
|
|
454
|
+
id: fullAddress,
|
|
455
|
+
node: child
|
|
456
|
+
});
|
|
457
|
+
edges.push(...inputs.edges, ...wiringEdges(localWiring, fullAddress));
|
|
458
|
+
pending.push({
|
|
459
|
+
deps: child.inputs,
|
|
460
|
+
wiring: localWiring,
|
|
461
|
+
targetId: fullAddress,
|
|
462
|
+
targetKind: "service",
|
|
463
|
+
enclosingModuleName: moduleNode.name
|
|
464
|
+
});
|
|
465
|
+
byId.set(fullAddress, child);
|
|
466
|
+
return refFor(fullAddress, child);
|
|
467
|
+
}
|
|
468
|
+
validateParamNeedBinding(child, id, localParams, moduleNode.name);
|
|
469
|
+
edges.push(...wiringEdges(localWiring, fullAddress));
|
|
470
|
+
pending.push({
|
|
471
|
+
deps: child.deps,
|
|
472
|
+
wiring: localWiring,
|
|
473
|
+
targetId: fullAddress,
|
|
474
|
+
targetKind: "module",
|
|
475
|
+
enclosingModuleName: moduleNode.name
|
|
476
|
+
});
|
|
477
|
+
const childOutputs = flatten(child, fullAddress, localWiring, localSecrets, localParams, nodes, edges, pending, secretBindings, paramBindings, byId);
|
|
478
|
+
nodes.push({
|
|
479
|
+
id: fullAddress,
|
|
480
|
+
node: child
|
|
481
|
+
});
|
|
482
|
+
byId.set(fullAddress, child);
|
|
483
|
+
return blindCast({
|
|
484
|
+
id: fullAddress,
|
|
485
|
+
...childOutputs
|
|
486
|
+
});
|
|
487
|
+
};
|
|
488
|
+
const ctx = blindCast({
|
|
489
|
+
inputs: ctxInputs,
|
|
490
|
+
secrets: ctxSecrets,
|
|
491
|
+
params: ctxParams,
|
|
492
|
+
provision: blindCast(provision)
|
|
493
|
+
});
|
|
494
|
+
const outputs = blindCast(moduleNode.body(ctx) ?? {});
|
|
495
|
+
markUsed(outputs);
|
|
496
|
+
for (const key of Object.keys(moduleNode.deps)) if (!used.has(key)) throw new LoadError(`Module "${moduleNode.name}" declares input "${key}" but never forwards it into a provision nor returns it as an output.`);
|
|
497
|
+
for (const key of Object.keys(moduleNode.secretSlots)) if (!usedSecrets.has(key)) throw new LoadError(`Module "${moduleNode.name}" declares secret "${key}" but never forwards it into a provision.`);
|
|
498
|
+
for (const key of Object.keys(moduleNode.paramSlots)) if (!usedParams.has(key)) throw new LoadError(`Module "${moduleNode.name}" declares param "${key}" but never forwards it into a provision.`);
|
|
499
|
+
for (const [key, contract] of Object.entries(moduleNode.expose)) {
|
|
500
|
+
const port = outputs[key];
|
|
501
|
+
if (port === void 0) throw new LoadError(`Module "${moduleNode.name}" declares expose "${key}" but its body did not return a port for it.`);
|
|
502
|
+
if (!satisfiesRequired(port, contract)) throw new LoadError(`Module "${moduleNode.name}"'s returned port for expose "${key}" does not satisfy its declared contract.`);
|
|
503
|
+
}
|
|
504
|
+
return outputs;
|
|
505
|
+
}
|
|
506
|
+
function loadModule(root, opts) {
|
|
507
|
+
const rootId = opts?.id ?? root.name;
|
|
508
|
+
const rootDepKeys = Object.keys(root.deps);
|
|
509
|
+
if (rootDepKeys.length > 0) {
|
|
510
|
+
const names = rootDepKeys.map((k) => `"${k}"`).join(", ");
|
|
511
|
+
throw new LoadError(`Module "${root.name}" declares input${rootDepKeys.length > 1 ? "s" : ""} ${names} but is being deployed as the root — a root has no enclosing scope to wire them; compose "${root.name}" from another module that provisions and wires it instead.`);
|
|
512
|
+
}
|
|
513
|
+
const rootSecretKeys = Object.keys(root.secretSlots);
|
|
514
|
+
if (rootSecretKeys.length > 0) {
|
|
515
|
+
const names = rootSecretKeys.map((k) => `"${k}"`).join(", ");
|
|
516
|
+
throw new LoadError(`Module "${root.name}" declares secret${rootSecretKeys.length > 1 ? "s" : ""} ${names} but is being deployed as the root — a root has no enclosing scope to bind them; the root binds secrets with envSecret('NAME'), it does not declare secret slots of its own.`);
|
|
517
|
+
}
|
|
518
|
+
const rootParamKeys = Object.keys(root.paramSlots);
|
|
519
|
+
if (rootParamKeys.length > 0) {
|
|
520
|
+
const names = rootParamKeys.map((k) => `"${k}"`).join(", ");
|
|
521
|
+
throw new LoadError(`Module "${root.name}" declares param${rootParamKeys.length > 1 ? "s" : ""} ${names} but is being deployed as the root — a root has no enclosing scope to bind them; the root binds params directly on each provision() call, it does not declare param-forwarding slots of its own.`);
|
|
522
|
+
}
|
|
523
|
+
const nodes = [];
|
|
524
|
+
const edges = [];
|
|
525
|
+
const pending = [];
|
|
526
|
+
const secretBindings = [];
|
|
527
|
+
const paramBindings = [];
|
|
528
|
+
const byId = /* @__PURE__ */ new Map();
|
|
529
|
+
flatten(root, void 0, {}, {}, {}, nodes, edges, pending, secretBindings, paramBindings, byId);
|
|
530
|
+
for (const entry of pending) validateWiring(entry, byId);
|
|
531
|
+
assertDependencyDag(edges);
|
|
532
|
+
const rootGraphNode = {
|
|
533
|
+
id: rootId,
|
|
534
|
+
node: root
|
|
535
|
+
};
|
|
536
|
+
return {
|
|
537
|
+
root: rootGraphNode,
|
|
538
|
+
nodes: [...topoSort(nodes, edges), rootGraphNode],
|
|
539
|
+
edges,
|
|
540
|
+
secrets: secretBindings,
|
|
541
|
+
params: paramBindings
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* The dependency edges must form a DAG — a cycle means neither producer can
|
|
546
|
+
* deploy first. Resources take no wiring, so only service/module-to-service/module
|
|
547
|
+
* edges can ever participate in a cycle; no special-casing needed.
|
|
548
|
+
*/
|
|
549
|
+
function assertDependencyDag(edges) {
|
|
550
|
+
const adjacency = /* @__PURE__ */ new Map();
|
|
551
|
+
for (const edge of edges) {
|
|
552
|
+
if (edge.kind !== "dependency") continue;
|
|
553
|
+
const targets = adjacency.get(edge.from) ?? [];
|
|
554
|
+
targets.push(edge.to);
|
|
555
|
+
adjacency.set(edge.from, targets);
|
|
556
|
+
}
|
|
557
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
558
|
+
const done = /* @__PURE__ */ new Set();
|
|
559
|
+
const stack = [];
|
|
560
|
+
const visit = (id) => {
|
|
561
|
+
if (done.has(id)) return;
|
|
562
|
+
if (visiting.has(id)) throw new LoadError(`Dependency cycle: ${[...stack.slice(stack.indexOf(id)), id].join(" → ")} — no deploy order exists.`);
|
|
563
|
+
visiting.add(id);
|
|
564
|
+
stack.push(id);
|
|
565
|
+
for (const next of adjacency.get(id) ?? []) visit(next);
|
|
566
|
+
stack.pop();
|
|
567
|
+
visiting.delete(id);
|
|
568
|
+
done.add(id);
|
|
569
|
+
};
|
|
570
|
+
for (const id of adjacency.keys()) visit(id);
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Builds the in-memory graph from a root node. A service root walks its own
|
|
574
|
+
* `inputs`; a module root executes its body (wiring, not user code — the
|
|
575
|
+
* designed exception to imports-run-nothing) and recursively flattens every
|
|
576
|
+
* module it provisions into one graph of hierarchical addresses. A malformed
|
|
577
|
+
* graph is a `LoadError` that names its fix; the individual validation rules
|
|
578
|
+
* live with `loadService` / `loadModule` and are covered by name in the Load
|
|
579
|
+
* tests. Executes nothing of the user's own code beyond module bodies.
|
|
580
|
+
*/
|
|
581
|
+
function Load(root, opts) {
|
|
582
|
+
if (!isNode(root)) throw new LoadError("Load expects a branded service or module node (construct it with the service()/module() factories).");
|
|
583
|
+
if (root.kind === "module") return loadModule(root, opts);
|
|
584
|
+
if (root.kind === "service") return loadService(root, opts?.id ?? "root");
|
|
585
|
+
throw new LoadError("Load expects a service or module root (received another node kind).");
|
|
586
|
+
}
|
|
587
|
+
//#endregion
|
|
588
|
+
//#region ../../1-prisma-cloud/0-lowering/lowering/dist/http-CxGdfSAP.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
|
+
Schedule.both(Schedule.exponential("2 seconds", 2), Schedule.during("5 minutes"));
|
|
626
|
+
Resource("Prisma.ComputeService");
|
|
627
|
+
Resource("Prisma.Deployment");
|
|
628
|
+
Resource("Prisma.EnvironmentVariable");
|
|
629
|
+
Resource("PrismaCloud.ServiceKey");
|
|
630
|
+
Resource("Prisma.Connection");
|
|
631
|
+
Resource("Prisma.Database");
|
|
632
|
+
Resource("Prisma.Project");
|
|
633
|
+
//#endregion
|
|
634
|
+
//#region ../../1-prisma-cloud/0-lowering/lowering/dist/index.mjs
|
|
635
|
+
/** Raised with `ensure: false` when the app's Project (or a named stage's Branch) doesn't exist. */
|
|
636
|
+
var ContainerNotFoundError = class extends Data.TaggedError("ContainerNotFoundError") {};
|
|
637
|
+
const listAllProjects = (client) => Effect.gen(function* () {
|
|
638
|
+
const projects = [];
|
|
639
|
+
let cursor;
|
|
640
|
+
for (;;) {
|
|
641
|
+
const query = cursor === void 0 ? {} : { cursor };
|
|
642
|
+
const page = yield* call(() => client.GET("/v1/projects", { params: { query } }));
|
|
643
|
+
projects.push(...page.data);
|
|
644
|
+
if (!page.pagination.hasMore || page.pagination.nextCursor === null) break;
|
|
645
|
+
cursor = page.pagination.nextCursor;
|
|
646
|
+
}
|
|
647
|
+
return projects;
|
|
648
|
+
});
|
|
649
|
+
/**
|
|
650
|
+
* Workspace ids circulate in two shapes: `wksp_`-prefixed and bare. Compare
|
|
651
|
+
* bare-to-bare so a `wksp_`-prefixed API id still matches a bare configured
|
|
652
|
+
* one (the same normalization `state/bootstrap.ts` applies to the same
|
|
653
|
+
* `/v1/projects` listing).
|
|
654
|
+
*/
|
|
655
|
+
const bareWorkspaceId = (id) => id.startsWith("wksp_") ? id.slice(5) : id;
|
|
656
|
+
/**
|
|
657
|
+
* Finds the app's Project by name in the workspace — PDP allows duplicate
|
|
658
|
+
* project names, so more than one can match; the oldest wins. Creates one
|
|
659
|
+
* if none match, unless `ensure` is `false` (find-only — `destroy`), in
|
|
660
|
+
* which case an absent Project fails with `ContainerNotFoundError`. No
|
|
661
|
+
* ownership marker and no `--project` override (both deferred — see
|
|
662
|
+
* ADR-0019).
|
|
663
|
+
*/
|
|
664
|
+
const resolveProject = (client, workspaceId, appName, ensure) => Effect.gen(function* () {
|
|
665
|
+
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];
|
|
666
|
+
if (oldest !== void 0) return oldest.id;
|
|
667
|
+
if (!ensure) return yield* Effect.fail(new ContainerNotFoundError({ appName }));
|
|
668
|
+
return (yield* call(() => client.POST("/v1/projects", { body: {
|
|
669
|
+
name: appName,
|
|
670
|
+
workspaceId
|
|
671
|
+
} }))).data.id;
|
|
672
|
+
});
|
|
673
|
+
const findBranchId = (client, projectId, gitName) => call(() => client.GET("/v1/projects/{projectId}/branches", { params: {
|
|
674
|
+
path: { projectId },
|
|
675
|
+
query: { gitName }
|
|
676
|
+
} })).pipe(Effect.map((page) => page.data[0]?.id));
|
|
677
|
+
/**
|
|
678
|
+
* Finds the stage's Branch by its exact `gitName`, creating it if absent
|
|
679
|
+
* unless `ensure` is `false` (find-only — `destroy`), in which case an
|
|
680
|
+
* absent Branch fails with `ContainerNotFoundError`. The Management API has
|
|
681
|
+
* no server-side "create-or-return" idempotency (`POST
|
|
682
|
+
* /v1/projects/:id/branches` 409s on a duplicate `gitName`, with no request
|
|
683
|
+
* field to make that a no-op), so idempotency is client-side: observe
|
|
684
|
+
* first, and on a racing 409 from create, re-observe rather than fail.
|
|
685
|
+
*/
|
|
686
|
+
const resolveBranch = (client, projectId, gitName, appName, ensure) => Effect.gen(function* () {
|
|
687
|
+
const existing = yield* findBranchId(client, projectId, gitName);
|
|
688
|
+
if (existing !== void 0) return existing;
|
|
689
|
+
if (!ensure) return yield* Effect.fail(new ContainerNotFoundError({
|
|
690
|
+
appName,
|
|
691
|
+
stage: gitName
|
|
692
|
+
}));
|
|
693
|
+
return yield* call(() => client.POST("/v1/projects/{projectId}/branches", {
|
|
694
|
+
params: { path: { projectId } },
|
|
695
|
+
body: { gitName }
|
|
696
|
+
})).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)));
|
|
697
|
+
});
|
|
698
|
+
/**
|
|
699
|
+
* Resolves the two containers a stage's deploy runs into (ADR-0019): the
|
|
700
|
+
* app's **Project**, found-or-created by name, and — for a named stage
|
|
701
|
+
* only — its **Branch**, found-or-created by `gitName`. The default stage
|
|
702
|
+
* (no `stage`) creates no Branch; `branchId` is omitted. With `ensure:
|
|
703
|
+
* false` (`destroy`), nothing is created — an absent Project or Branch
|
|
704
|
+
* fails with `ContainerNotFoundError` instead.
|
|
705
|
+
*/
|
|
706
|
+
const resolveContainer = (opts) => Effect.gen(function* () {
|
|
707
|
+
const client = yield* ManagementClient;
|
|
708
|
+
const ensure = opts.ensure ?? true;
|
|
709
|
+
const projectId = yield* resolveProject(client, opts.workspaceId, opts.appName, ensure);
|
|
710
|
+
if (opts.stage === void 0) return { projectId };
|
|
711
|
+
return {
|
|
712
|
+
projectId,
|
|
713
|
+
branchId: yield* resolveBranch(client, projectId, opts.stage, opts.appName, ensure)
|
|
714
|
+
};
|
|
715
|
+
});
|
|
716
|
+
/**
|
|
717
|
+
* Soft-deletes a Branch. Tolerates a 404 (already gone). The API refuses if
|
|
718
|
+
* the Branch still has live members or is the production/default Branch —
|
|
719
|
+
* that surfaces as a `PrismaApiError`.
|
|
720
|
+
*/
|
|
721
|
+
const deleteBranch = (branchId) => Effect.gen(function* () {
|
|
722
|
+
const client = yield* ManagementClient;
|
|
723
|
+
yield* callVoid(() => client.DELETE("/v1/branches/{branchId}", { params: { path: { branchId } } }));
|
|
724
|
+
});
|
|
725
|
+
/**
|
|
726
|
+
* Deletes a Project. Tolerates a 404 (already gone). The API refuses with a
|
|
727
|
+
* 400 if the Project still has live dependencies (e.g. another stage's
|
|
728
|
+
* Branch/resources) — that surfaces as a `PrismaApiError`.
|
|
729
|
+
*/
|
|
730
|
+
const deleteProject = (projectId) => Effect.gen(function* () {
|
|
731
|
+
const client = yield* ManagementClient;
|
|
732
|
+
yield* callVoid(() => client.DELETE("/v1/projects/{id}", { params: { path: { id: projectId } } }));
|
|
733
|
+
});
|
|
734
|
+
Provider.ProviderCollection()("Prisma");
|
|
735
|
+
//#endregion
|
|
736
|
+
//#region ../../0-framework/3-tooling/cli/dist/cli-BeT-XTbX.mjs
|
|
737
|
+
/**
|
|
738
|
+
* A user-facing failure with a message that already names the fix (deploy-cli.md
|
|
739
|
+
* § Error surface). `bin.ts` catches this — and any other Error, including
|
|
740
|
+
* core's LoadError/LowerError — uniformly: print the message, exit nonzero.
|
|
741
|
+
*/
|
|
742
|
+
var CliError = class extends Error {
|
|
743
|
+
constructor(message) {
|
|
744
|
+
super(message);
|
|
745
|
+
this.name = "CliError";
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
/**
|
|
749
|
+
* Pipeline pre-stack step: resolves the app's Project + (named stage) Branch
|
|
750
|
+
* via `@internal/lowering`'s `resolveContainer`, before the generated stack file
|
|
751
|
+
* runs — `deploy` creates-if-absent, `destroy` finds only.
|
|
752
|
+
*/
|
|
753
|
+
/** Validates `stage` as a git ref name via `git check-ref-format` — no silent normalization. */
|
|
754
|
+
function validateStageName(stage) {
|
|
755
|
+
const result = spawnSync("git", ["check-ref-format", `refs/heads/${stage}`], { stdio: "ignore" });
|
|
756
|
+
if (result.error) throw new CliError(`git is required to validate --stage "${stage}" (git check-ref-format): ${result.error.message}.`);
|
|
757
|
+
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}").`);
|
|
758
|
+
}
|
|
759
|
+
async function ensureContainers(input, deps) {
|
|
760
|
+
const env = input.env ?? process.env;
|
|
761
|
+
const workspaceId = env["PRISMA_WORKSPACE_ID"];
|
|
762
|
+
if (workspaceId === void 0 || workspaceId.length === 0) throw new CliError("environment variable PRISMA_WORKSPACE_ID is required.");
|
|
763
|
+
if (deps?.client === void 0 && (env["PRISMA_SERVICE_TOKEN"] ?? "").length === 0) throw new CliError("environment variable PRISMA_SERVICE_TOKEN is required.");
|
|
764
|
+
if (input.stage !== void 0) validateStageName(input.stage);
|
|
765
|
+
const program = resolveContainer({
|
|
766
|
+
workspaceId,
|
|
767
|
+
appName: input.appName,
|
|
768
|
+
...input.stage !== void 0 ? { stage: input.stage } : {},
|
|
769
|
+
ensure: input.command === "deploy"
|
|
770
|
+
}).pipe(Effect.map((c) => ({
|
|
771
|
+
ok: true,
|
|
772
|
+
container: c
|
|
773
|
+
})), Effect.catchTag("ContainerNotFoundError", (e) => Effect.succeed({
|
|
774
|
+
ok: false,
|
|
775
|
+
message: `Nothing deployed for ${e.appName}${e.stage ? `/${e.stage}` : ""} — deploy it first.`
|
|
776
|
+
})), Effect.catchTag("PrismaApiError", (e) => Effect.succeed({
|
|
777
|
+
ok: false,
|
|
778
|
+
message: `Prisma Management API error resolving containers: ${e.message}.`
|
|
779
|
+
})));
|
|
780
|
+
const provided = deps?.client !== void 0 ? program.pipe(Effect.provideService(ManagementClient, deps.client)) : program.pipe(Effect.provide(layer().pipe(Layer.provide(fromEnv()))));
|
|
781
|
+
const outcome = await Effect.runPromise(provided);
|
|
782
|
+
if (!outcome.ok) throw new CliError(outcome.message);
|
|
783
|
+
return outcome.container;
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Soft-deletes a named stage's Branch after a successful `alchemy destroy`
|
|
787
|
+
* has removed its members (spec §10) — the Management API refuses to delete
|
|
788
|
+
* a Branch that still has live members.
|
|
789
|
+
*/
|
|
790
|
+
async function deleteStageBranch(input, deps) {
|
|
791
|
+
const env = input.env ?? process.env;
|
|
792
|
+
if (deps?.client === void 0 && (env["PRISMA_SERVICE_TOKEN"] ?? "").length === 0) throw new CliError("environment variable PRISMA_SERVICE_TOKEN is required.");
|
|
793
|
+
const program = deleteBranch(input.branchId).pipe(Effect.map(() => ({ ok: true })), Effect.catchTag("PrismaApiError", (e) => Effect.succeed({
|
|
794
|
+
ok: false,
|
|
795
|
+
message: `Failed to delete the stage Branch: ${e.message}.`
|
|
796
|
+
})));
|
|
797
|
+
const provided = deps?.client !== void 0 ? program.pipe(Effect.provideService(ManagementClient, deps.client)) : program.pipe(Effect.provide(layer().pipe(Layer.provide(fromEnv()))));
|
|
798
|
+
const outcome = await Effect.runPromise(provided);
|
|
799
|
+
if (!outcome.ok) throw new CliError(outcome.message);
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Best-effort cleanup after a successful `--production` destroy: removes
|
|
803
|
+
* the app's Project so hand-run stacks don't accumulate as empty Projects
|
|
804
|
+
* (they eventually hit the workspace's plan limit). Unlike `deleteStageBranch`,
|
|
805
|
+
* this never throws: the destroy itself already succeeded, and the API's own
|
|
806
|
+
* 400 ("still has dependencies") is the only check that matters — failing
|
|
807
|
+
* the command over a cleanup step would be worse than leaving a Project shell.
|
|
808
|
+
*/
|
|
809
|
+
async function deleteAppProject(input, deps) {
|
|
810
|
+
const env = input.env ?? process.env;
|
|
811
|
+
if (deps?.client === void 0 && (env["PRISMA_SERVICE_TOKEN"] ?? "").length === 0) {
|
|
812
|
+
console.warn(`Skipped removing the Project (${input.projectId}): PRISMA_SERVICE_TOKEN is not set.`);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
const program = deleteProject(input.projectId).pipe(Effect.map(() => ({ ok: true })), Effect.catchTag("PrismaApiError", (e) => Effect.succeed({
|
|
816
|
+
ok: false,
|
|
817
|
+
error: e
|
|
818
|
+
})));
|
|
819
|
+
const provided = deps?.client !== void 0 ? program.pipe(Effect.provideService(ManagementClient, deps.client)) : program.pipe(Effect.provide(layer().pipe(Layer.provide(fromEnv()))));
|
|
820
|
+
const outcome = await Effect.runPromise(provided);
|
|
821
|
+
if (outcome.ok) {
|
|
822
|
+
console.log(`Removed the Project (${input.projectId}) — nothing was left in it.`);
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
if (outcome.error.status === 400) {
|
|
826
|
+
console.log(`Kept the Project (${input.projectId}) — it still has another stage's resources.`);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
console.warn(`Could not remove the Project (${input.projectId}) after destroy: ${outcome.error.message}.`);
|
|
830
|
+
}
|
|
831
|
+
/** Pipeline step 6: writes a regenerated-every-run, independently runnable stack module at `.prisma-composer/alchemy.run.ts`. */
|
|
832
|
+
const GENERATED_DIR = ".prisma-composer";
|
|
833
|
+
const GENERATED_FILE = "alchemy.run.ts";
|
|
834
|
+
/** A relative import specifier from `.prisma-composer/alchemy.run.ts` to `target` (posix separators). */
|
|
835
|
+
function relativeImportSpecifier(generatedDir, target) {
|
|
836
|
+
const rel = path.relative(generatedDir, target).split(path.sep).join("/");
|
|
837
|
+
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
838
|
+
}
|
|
839
|
+
function quote(value) {
|
|
840
|
+
return JSON.stringify(value);
|
|
841
|
+
}
|
|
842
|
+
function renderBundle(bundle) {
|
|
843
|
+
return `{ dir: ${quote(bundle.dir)}, entry: ${quote(bundle.entry)} }`;
|
|
844
|
+
}
|
|
845
|
+
function renderOptions(input) {
|
|
846
|
+
const lines = [];
|
|
847
|
+
lines.push(` name: ${quote(input.name)},`);
|
|
848
|
+
lines.push(" bundles: {");
|
|
849
|
+
for (const [id, bundle] of Object.entries(input.assembled.bundles)) lines.push(` ${quote(id)}: ${renderBundle(bundle)},`);
|
|
850
|
+
lines.push(" },");
|
|
851
|
+
lines.push(" report: deploymentReport,");
|
|
852
|
+
return lines.join("\n");
|
|
853
|
+
}
|
|
854
|
+
/** Renders the stack module's source (tests assert on it without touching disk) — uses `//` headers, not a block comment, since a cwd path with a star-slash could close one early. */
|
|
855
|
+
function renderStackFile(input) {
|
|
856
|
+
const generatedDir = path.join(input.cwd, GENERATED_DIR);
|
|
857
|
+
const appImport = relativeImportSpecifier(generatedDir, input.entryPath);
|
|
858
|
+
const configImport = relativeImportSpecifier(generatedDir, input.configPath);
|
|
859
|
+
return `// Generated by \`prisma-composer deploy\`/\`prisma-composer destroy\` — overwritten on every
|
|
860
|
+
// run; do not edit by hand. Independently runnable from ${quote(input.cwd)}:
|
|
861
|
+
//
|
|
862
|
+
// alchemy deploy ${GENERATED_DIR}/${GENERATED_FILE}
|
|
863
|
+
//
|
|
864
|
+
// bisects a CLI bug from an Alchemy bug (deploy-cli.md § Implementation decisions).
|
|
865
|
+
import { lower } from '@prisma/composer/deploy';
|
|
866
|
+
import { deploymentReport } from '@prisma/composer/report';
|
|
867
|
+
import config from ${quote(configImport)};
|
|
868
|
+
import app from ${quote(appImport)};
|
|
869
|
+
|
|
870
|
+
export default lower(app, config, {
|
|
871
|
+
${renderOptions(input)}
|
|
872
|
+
});
|
|
873
|
+
`;
|
|
874
|
+
}
|
|
875
|
+
/** Writes the stack file, returning its absolute path. */
|
|
876
|
+
function writeStackFile(input) {
|
|
877
|
+
const generatedDir = path.join(input.cwd, GENERATED_DIR);
|
|
878
|
+
fs.mkdirSync(generatedDir, { recursive: true });
|
|
879
|
+
const filePath = path.join(generatedDir, GENERATED_FILE);
|
|
880
|
+
fs.writeFileSync(filePath, renderStackFile(input));
|
|
881
|
+
return filePath;
|
|
882
|
+
}
|
|
883
|
+
const GENERATED_STACK_RELATIVE_PATH = path.join(GENERATED_DIR, GENERATED_FILE);
|
|
884
|
+
/**
|
|
885
|
+
* Pipeline step: find and load `prisma-composer.config.ts` (ADR-0017) — the ONE
|
|
886
|
+
* file that imports control-plane code. Discovery is the standard walk-up
|
|
887
|
+
* from the deploy entry's directory (mirrors prisma-next's config-loader);
|
|
888
|
+
* loading is c12 with that explicit path (rc/global/package.json lookups
|
|
889
|
+
* disabled), so the config file's own static imports resolve from the app
|
|
890
|
+
* root by whatever package manager runs — no specifier construction, no
|
|
891
|
+
* anchoring. The loaded shape is validated field-by-field with CliErrors
|
|
892
|
+
* naming the field.
|
|
893
|
+
*/
|
|
894
|
+
const CONFIG_FILENAME = "prisma-composer.config.ts";
|
|
895
|
+
/** Walks UP from the entry file's directory looking for the literal CONFIG_FILENAME; undefined when the walk hits the filesystem root. */
|
|
896
|
+
function findConfigPathForEntry(entryPath) {
|
|
897
|
+
let current = path.dirname(path.resolve(entryPath));
|
|
898
|
+
while (true) {
|
|
899
|
+
const candidate = path.join(current, CONFIG_FILENAME);
|
|
900
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
901
|
+
const parent = path.dirname(current);
|
|
902
|
+
if (parent === current) return void 0;
|
|
903
|
+
current = parent;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
function missingConfigError(entryPath) {
|
|
907
|
+
return new CliError(`No ${CONFIG_FILENAME} found walking up from "${path.dirname(path.resolve(entryPath))}" — the deploy needs the app's config file. Create one next to (or above) the entry, default-exporting defineConfig({ extensions: [...], state: ... }) from '@prisma/composer/config'.`);
|
|
908
|
+
}
|
|
909
|
+
function fieldError(field, requirement) {
|
|
910
|
+
return new CliError(`${CONFIG_FILENAME}: \`${field}\` ${requirement} — see defineConfig() in '@prisma/composer/config'.`);
|
|
911
|
+
}
|
|
912
|
+
function isRecord(value) {
|
|
913
|
+
return typeof value === "object" && value !== null;
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Field-by-field validation of the loaded default export — deliberately no
|
|
917
|
+
* schema library: each check is a CliError naming the offending field.
|
|
918
|
+
* Returns the same object, typed.
|
|
919
|
+
*/
|
|
920
|
+
function validateConfigShape(loaded, configPath) {
|
|
921
|
+
if (!isRecord(loaded) || Object.keys(loaded).length === 0) throw new CliError(`"${configPath}" exported no config — it must default-export defineConfig({ extensions: [...], state: ... }) from '@prisma/composer/config'.`);
|
|
922
|
+
const extensions = loaded["extensions"];
|
|
923
|
+
if (!Array.isArray(extensions)) throw fieldError("extensions", "must be an array");
|
|
924
|
+
const seen = /* @__PURE__ */ new Set();
|
|
925
|
+
for (const [index, entry] of extensions.entries()) {
|
|
926
|
+
if (!isRecord(entry)) throw fieldError(`extensions[${index}]`, "must be an extension descriptor object");
|
|
927
|
+
const id = entry["id"];
|
|
928
|
+
if (typeof id !== "string" || id.length === 0) throw fieldError(`extensions[${index}].id`, "must be a non-empty string (the extension package name)");
|
|
929
|
+
if (!isRecord(entry["nodes"])) throw fieldError(`extensions[${index}].nodes`, "must be an object (the node-ID → control registry)");
|
|
930
|
+
if (seen.has(id)) throw new CliError(`${CONFIG_FILENAME}: extension "${id}" is listed more than once in \`extensions\`.`);
|
|
931
|
+
seen.add(id);
|
|
932
|
+
}
|
|
933
|
+
if (typeof loaded["state"] !== "function") throw fieldError("state", "must be a function returning the deploy state layer (e.g. () => prismaState())");
|
|
934
|
+
return blindCast(loaded);
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Loads + validates the config at `configPath` via c12 (explicit file; rc /
|
|
938
|
+
* global-rc / package.json lookups disabled — discovery already happened in
|
|
939
|
+
* findConfigPathForEntry).
|
|
940
|
+
*/
|
|
941
|
+
async function loadAppConfig(configPath) {
|
|
942
|
+
const result = await c12.loadConfig({
|
|
943
|
+
name: "prisma-composer",
|
|
944
|
+
configFile: configPath,
|
|
945
|
+
cwd: path.dirname(configPath),
|
|
946
|
+
rcFile: false,
|
|
947
|
+
globalRc: false,
|
|
948
|
+
packageJson: false
|
|
949
|
+
});
|
|
950
|
+
const loadedFile = result.configFile;
|
|
951
|
+
if (typeof loadedFile !== "string" || fs.realpathSync(loadedFile) !== fs.realpathSync(configPath)) throw new CliError(`Config loading resolved "${String(loadedFile)}" instead of the discovered "${configPath}" — refusing to deploy against a different file.`);
|
|
952
|
+
return {
|
|
953
|
+
path: configPath,
|
|
954
|
+
config: validateConfigShape(result.config, configPath)
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Pipeline step 1 (deploy-cli.md § The pipeline): import the entry module
|
|
959
|
+
* (resolved against cwd) and require its default export to be a node — a
|
|
960
|
+
* service or module, branded by core's factories. Whatever this module exports
|
|
961
|
+
* IS the application; nothing else marks a root (ADR-0003).
|
|
962
|
+
*/
|
|
963
|
+
async function loadEntry(entryArg, cwd) {
|
|
964
|
+
const resolvedPath = path.resolve(cwd, entryArg);
|
|
965
|
+
const root = (await import(pathToFileURL(resolvedPath).href)).default;
|
|
966
|
+
if (!isNode(root) || root.kind === "dependency" || root.kind === "resource") throw new CliError(`Entry module "${resolvedPath}" must default-export a node (a service or a module) — construct it with service() or module() from @prisma/composer.`);
|
|
967
|
+
return {
|
|
968
|
+
path: resolvedPath,
|
|
969
|
+
root: blindCast(root)
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Pipeline step 7 (deploy-cli.md § The pipeline; design-notes.md's "Driving
|
|
974
|
+
* Alchemy" call): shell out to the generated stack file. Resolves the
|
|
975
|
+
* workspace's own installed `alchemy` bin (walking up `node_modules/.bin`
|
|
976
|
+
* from the generated file's package dir) rather than going through
|
|
977
|
+
* `bunx`/`npx`, so this works the same under node and bun — the resolved
|
|
978
|
+
* bin's own launcher (`alchemy/bin/cli.js`) does its own node/bun dispatch
|
|
979
|
+
* from there, driven by the env it inherits.
|
|
980
|
+
*/
|
|
981
|
+
/** Walks up from `startDir` looking for `node_modules/.bin/alchemy`. */
|
|
982
|
+
function resolveAlchemyBin(startDir) {
|
|
983
|
+
let dir = startDir;
|
|
984
|
+
while (true) {
|
|
985
|
+
const candidate = path.join(dir, "node_modules", ".bin", "alchemy");
|
|
986
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
987
|
+
const parent = path.dirname(dir);
|
|
988
|
+
if (parent === dir) throw new CliError(`Could not find an installed \`alchemy\` bin above "${startDir}" — add "alchemy" as a dependency of your app.`);
|
|
989
|
+
dir = parent;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
/** Runs `alchemy deploy|destroy <stack file> --yes [--stage <stage>]`, inheriting stdio + env, plus the resolved Project/Branch ids. */
|
|
993
|
+
function runAlchemy(input) {
|
|
994
|
+
const bin = resolveAlchemyBin(input.cwd);
|
|
995
|
+
const args = [
|
|
996
|
+
input.command,
|
|
997
|
+
input.stackFileRelativePath,
|
|
998
|
+
"--yes"
|
|
999
|
+
];
|
|
1000
|
+
if (input.stage !== void 0) args.push("--stage", input.stage);
|
|
1001
|
+
const result = spawnSync(bin, args, {
|
|
1002
|
+
cwd: input.cwd,
|
|
1003
|
+
stdio: "inherit",
|
|
1004
|
+
env: {
|
|
1005
|
+
...input.env ?? process.env,
|
|
1006
|
+
PRISMA_PROJECT_ID: input.projectId,
|
|
1007
|
+
...input.branchId !== void 0 ? { PRISMA_BRANCH_ID: input.branchId } : {}
|
|
1008
|
+
}
|
|
1009
|
+
});
|
|
1010
|
+
if (result.error !== void 0) throw result.error;
|
|
1011
|
+
return result.status ?? 1;
|
|
1012
|
+
}
|
|
1013
|
+
function lookup(extensions, extension, type, expectedKind, what) {
|
|
1014
|
+
const ext = extensions.get(extension);
|
|
1015
|
+
if (ext === void 0) throw new CliError(`No extension "${extension}" is configured (needed by ${what}) — add it to ${CONFIG_FILENAME}'s \`extensions\` (import its /control entry and list its descriptor).`);
|
|
1016
|
+
const descriptor = ext.nodes[type];
|
|
1017
|
+
if (descriptor === void 0) throw new CliError(`Extension "${extension}" has no descriptor for node type "${type}" (needed by ${what}; known: ${Object.keys(ext.nodes).join(", ")}).`);
|
|
1018
|
+
if (descriptor.kind !== expectedKind) throw new CliError(`Extension "${extension}"'s descriptor for node type "${type}" is a "${descriptor.kind}" descriptor — ${what} needs a "${expectedKind}" descriptor.`);
|
|
1019
|
+
}
|
|
1020
|
+
/** Throws a CliError on the first uncovered `(extension, type)`; silent when the config covers the whole graph. */
|
|
1021
|
+
function validateRegistryCoverage(graph, config) {
|
|
1022
|
+
const extensions = new Map(config.extensions.map((descriptor) => [descriptor.id, descriptor]));
|
|
1023
|
+
for (const { id, node } of graph.nodes) {
|
|
1024
|
+
if (node.kind === "resource") {
|
|
1025
|
+
lookup(extensions, node.extension, node.type, "resource", `resource node "${id}"`);
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
if (node.kind !== "service") continue;
|
|
1029
|
+
lookup(extensions, node.extension, node.type, "service", `service node "${id}"`);
|
|
1030
|
+
lookup(extensions, node.build.extension, node.build.type, "build", `service node "${id}"'s build descriptor`);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Argument parsing (clipanion — prisma-next's CLI idiom, see
|
|
1035
|
+
* prisma-next/packages/1-framework/3-tooling/cli/src/migration-cli.ts) +
|
|
1036
|
+
* orchestration of deploy-cli.md § The pipeline.
|
|
1037
|
+
*/
|
|
1038
|
+
const BINARY_NAME = "prisma-composer";
|
|
1039
|
+
/** The <entry>/--name/--stage surface shared by deploy and destroy; execute() is unused — run() drives the pipeline directly so error handling stays under this module's control. */
|
|
1040
|
+
var DeployCliCommand = class extends Command {
|
|
1041
|
+
entry = Option.String({ name: "entry" });
|
|
1042
|
+
name = Option.String("--name", { description: "Override the root node's name — the deploy's application name." });
|
|
1043
|
+
stage = Option.String("--stage", { description: "Alchemy stage to target." });
|
|
1044
|
+
production = Option.Boolean("--production", false, { description: "destroy: tear down the project-level production environment (required to destroy production)." });
|
|
1045
|
+
async execute() {
|
|
1046
|
+
return 0;
|
|
1047
|
+
}
|
|
1048
|
+
};
|
|
1049
|
+
var DeployCommand = class extends DeployCliCommand {
|
|
1050
|
+
static paths = [["deploy"]];
|
|
1051
|
+
static usage = Command.Usage({
|
|
1052
|
+
description: "Deploy the application whose root node is <entry>'s default export.",
|
|
1053
|
+
examples: [["Deploy an app", "$0 deploy src/service.ts"]]
|
|
1054
|
+
});
|
|
1055
|
+
action = "deploy";
|
|
1056
|
+
};
|
|
1057
|
+
var DestroyCommand = class extends DeployCliCommand {
|
|
1058
|
+
static paths = [["destroy"]];
|
|
1059
|
+
static usage = Command.Usage({
|
|
1060
|
+
description: "Tear down the application whose root node is <entry>'s default export — same derivation as deploy, Alchemy destroy.",
|
|
1061
|
+
examples: [["Destroy an app", "$0 destroy src/service.ts"]]
|
|
1062
|
+
});
|
|
1063
|
+
action = "destroy";
|
|
1064
|
+
};
|
|
1065
|
+
function buildCli() {
|
|
1066
|
+
return Cli.from([DeployCommand, DestroyCommand], {
|
|
1067
|
+
binaryName: BINARY_NAME,
|
|
1068
|
+
binaryLabel: "The prisma-composer deploy CLI"
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
/** Thrown internally when the user explicitly asked for `--help`/`-h` — run() prints it to stdout and exits 0; not a usage error. */
|
|
1072
|
+
var HelpRequested = class extends Error {};
|
|
1073
|
+
/** Duck-typed: clipanion's UnknownSyntaxError isn't re-exported, so match its name + clipanion.type discriminator (mirrors prisma-next's migration-cli.ts). */
|
|
1074
|
+
function isUnknownSyntaxError(error) {
|
|
1075
|
+
if (!(error instanceof Error) || error.name !== "UnknownSyntaxError") return false;
|
|
1076
|
+
const meta = error.clipanion;
|
|
1077
|
+
return typeof meta === "object" && meta !== null && meta.type === "none";
|
|
1078
|
+
}
|
|
1079
|
+
/** Exported for direct testing (main.test.ts) — not part of the package's public barrel (see index.ts). */
|
|
1080
|
+
function parseArgs(argv) {
|
|
1081
|
+
const cli = buildCli();
|
|
1082
|
+
let command;
|
|
1083
|
+
try {
|
|
1084
|
+
command = cli.process([...argv]);
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
if (isUnknownSyntaxError(error) || error instanceof UsageError) throw new UsageError(cli.usage(null, { detailed: true }));
|
|
1087
|
+
throw error;
|
|
1088
|
+
}
|
|
1089
|
+
if (command instanceof DeployCommand || command instanceof DestroyCommand) return {
|
|
1090
|
+
command: command.action,
|
|
1091
|
+
entry: command.entry,
|
|
1092
|
+
name: command.name,
|
|
1093
|
+
stage: command.stage,
|
|
1094
|
+
production: command.production
|
|
1095
|
+
};
|
|
1096
|
+
if (argv.includes("--help") || argv.includes("-h")) throw new HelpRequested(cli.usage(null, { detailed: true }));
|
|
1097
|
+
throw new UsageError(cli.usage(null, { detailed: true }));
|
|
1098
|
+
}
|
|
1099
|
+
/** Destroy must name its target explicitly — no silent default to production (spec §10). */
|
|
1100
|
+
function effectiveStage(args) {
|
|
1101
|
+
if (args.command === "deploy") {
|
|
1102
|
+
if (args.production) throw new CliError("--production is only valid with `destroy`; `deploy` targets production by default (omit --stage).");
|
|
1103
|
+
return args.stage;
|
|
1104
|
+
}
|
|
1105
|
+
if (args.stage !== void 0 && args.production) throw new CliError("Pass either --stage <name> or --production to `destroy`, not both.");
|
|
1106
|
+
if (args.stage === void 0 && !args.production) throw new CliError("`destroy` requires an explicit target: --stage <name> to tear down a branch environment, or --production to tear down the production environment.");
|
|
1107
|
+
return args.production ? void 0 : args.stage;
|
|
1108
|
+
}
|
|
1109
|
+
const ALCHEMY_STATE_DIR = ".alchemy";
|
|
1110
|
+
/** Warns (doesn't fail) when destroy finds no local deploy state under cwd — likely wrong directory or nothing deployed yet. */
|
|
1111
|
+
function warnIfNoLocalDeployState(cwd) {
|
|
1112
|
+
const stateDir = path.join(cwd, ALCHEMY_STATE_DIR);
|
|
1113
|
+
if (!(fs.existsSync(stateDir) && fs.readdirSync(stateDir).length > 0)) console.warn(`\nNo prior deploy state under ${cwd} — if you deployed from a different directory, run destroy from there; otherwise this is a no-op.`);
|
|
1114
|
+
}
|
|
1115
|
+
/** Runs the full pipeline; returns the process exit code. */
|
|
1116
|
+
async function run(argv, deps = {}) {
|
|
1117
|
+
let args;
|
|
1118
|
+
try {
|
|
1119
|
+
args = parseArgs(argv);
|
|
1120
|
+
} catch (error) {
|
|
1121
|
+
if (error instanceof HelpRequested) {
|
|
1122
|
+
console.log(error.message);
|
|
1123
|
+
return 0;
|
|
1124
|
+
}
|
|
1125
|
+
throw error;
|
|
1126
|
+
}
|
|
1127
|
+
const stage = effectiveStage(args);
|
|
1128
|
+
const cwd = process.cwd();
|
|
1129
|
+
if (args.command === "destroy") warnIfNoLocalDeployState(cwd);
|
|
1130
|
+
const resolvedEntryPath = path.resolve(cwd, args.entry);
|
|
1131
|
+
const configPath = findConfigPathForEntry(resolvedEntryPath);
|
|
1132
|
+
if (configPath === void 0) throw missingConfigError(resolvedEntryPath);
|
|
1133
|
+
const config = deps.config ?? (await loadAppConfig(configPath)).config;
|
|
1134
|
+
const entryModule = await loadEntry(args.entry, cwd);
|
|
1135
|
+
const graph = Load(entryModule.root);
|
|
1136
|
+
if (graph.root.node.kind !== "module") throw new CliError("The deploy root must be a module — wrap your service, e.g. export default module('name', ({ provision }) => { provision(service); }).");
|
|
1137
|
+
validateRegistryCoverage(graph, config);
|
|
1138
|
+
const name = args.name ?? entryModule.root.name;
|
|
1139
|
+
if (name.length === 0) throw new CliError("The root node has no name — name it at authoring, or pass --name.");
|
|
1140
|
+
let assembled;
|
|
1141
|
+
try {
|
|
1142
|
+
assembled = await assembleServices(graph, config, cwd, deps.runAssembler);
|
|
1143
|
+
} catch (error) {
|
|
1144
|
+
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.`);
|
|
1145
|
+
throw error;
|
|
1146
|
+
}
|
|
1147
|
+
const { projectId, branchId } = await (deps.ensureContainers ?? ensureContainers)({
|
|
1148
|
+
command: args.command,
|
|
1149
|
+
appName: name,
|
|
1150
|
+
stage
|
|
1151
|
+
});
|
|
1152
|
+
if (args.command === "deploy") for (const extension of config.extensions) {
|
|
1153
|
+
if (extension.preflight === void 0) continue;
|
|
1154
|
+
try {
|
|
1155
|
+
await extension.preflight({
|
|
1156
|
+
graph,
|
|
1157
|
+
projectId,
|
|
1158
|
+
branchId,
|
|
1159
|
+
stage
|
|
1160
|
+
});
|
|
1161
|
+
} catch (error) {
|
|
1162
|
+
throw error instanceof CliError ? error : new CliError(error instanceof Error ? error.message : String(error));
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
const stackPath = writeStackFile({
|
|
1166
|
+
entryPath: entryModule.path,
|
|
1167
|
+
cwd,
|
|
1168
|
+
configPath,
|
|
1169
|
+
name,
|
|
1170
|
+
assembled
|
|
1171
|
+
});
|
|
1172
|
+
try {
|
|
1173
|
+
const status = (deps.alchemy ?? runAlchemy)({
|
|
1174
|
+
command: args.command,
|
|
1175
|
+
stackFileRelativePath: GENERATED_STACK_RELATIVE_PATH,
|
|
1176
|
+
cwd,
|
|
1177
|
+
stage,
|
|
1178
|
+
projectId,
|
|
1179
|
+
...branchId !== void 0 ? { branchId } : {}
|
|
1180
|
+
});
|
|
1181
|
+
if (status !== 0) {
|
|
1182
|
+
console.error(`\nGenerated stack file: ${stackPath}`);
|
|
1183
|
+
console.error(`Run \`alchemy ${args.command} ${GENERATED_STACK_RELATIVE_PATH} --yes\` from ${cwd} to reproduce this directly.`);
|
|
1184
|
+
return status;
|
|
1185
|
+
}
|
|
1186
|
+
if (args.command === "destroy") for (const extension of config.extensions) {
|
|
1187
|
+
if (extension.teardown === void 0) continue;
|
|
1188
|
+
try {
|
|
1189
|
+
await extension.teardown({
|
|
1190
|
+
projectId,
|
|
1191
|
+
branchId,
|
|
1192
|
+
stage
|
|
1193
|
+
});
|
|
1194
|
+
} catch (error) {
|
|
1195
|
+
throw error instanceof CliError ? error : new CliError(error instanceof Error ? error.message : String(error));
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
if (args.command === "destroy" && branchId !== void 0) await (deps.deleteBranch ?? ((input) => deleteStageBranch(input)))({ branchId });
|
|
1199
|
+
else if (args.command === "destroy") await (deps.deleteProject ?? ((input) => deleteAppProject(input)))({ projectId });
|
|
1200
|
+
return status;
|
|
1201
|
+
} catch (error) {
|
|
1202
|
+
console.error(`\nGenerated stack file: ${stackPath}`);
|
|
1203
|
+
throw error;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Run the `prisma-composer` CLI end to end: dispatch `argv`, map errors to exit
|
|
1208
|
+
* codes. Shared by this package's `bin` and the unscoped `prisma-composer` launcher.
|
|
1209
|
+
*/
|
|
1210
|
+
async function cli(argv = process.argv.slice(2)) {
|
|
1211
|
+
try {
|
|
1212
|
+
process.exitCode = await run(argv);
|
|
1213
|
+
} catch (error) {
|
|
1214
|
+
if (error instanceof UsageError) {
|
|
1215
|
+
console.error(error.message);
|
|
1216
|
+
process.exitCode = 1;
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
1220
|
+
process.exitCode = 1;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
//#endregion
|
|
1224
|
+
//#region ../../0-framework/3-tooling/cli/dist/bin.mjs
|
|
1225
|
+
cli();
|
|
1226
|
+
//#endregion
|
|
1227
|
+
export {};
|
|
1228
|
+
|
|
1229
|
+
//# sourceMappingURL=bin.mjs.map
|