@kaupang/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1535 @@
1
+ // src/config/loader.ts
2
+ import { existsSync as existsSync2, readFileSync as readFileSync2, readdirSync } from "node:fs";
3
+ import { basename, dirname, extname, join as join2, resolve as resolve2 } from "node:path";
4
+ import { createJiti } from "jiti";
5
+
6
+ // src/catalog/source.ts
7
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join, resolve } from "node:path";
10
+ import { execa } from "execa";
11
+
12
+ // src/util/registry.ts
13
+ function registryHost(ref) {
14
+ return ref.replace(/^oci:\/\//, "").split("/")[0] ?? "";
15
+ }
16
+ function isLoopback(host) {
17
+ return /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(host);
18
+ }
19
+ function orasRegistryArgs(ref) {
20
+ if (isLoopback(registryHost(ref)) || process.env.KAUPANG_ORAS_PLAIN_HTTP) {
21
+ return ["--plain-http"];
22
+ }
23
+ return [];
24
+ }
25
+
26
+ // src/catalog/source.ts
27
+ var FileCatalogSource = class {
28
+ constructor(path) {
29
+ this.path = path;
30
+ this.describe = `file:${path}`;
31
+ }
32
+ describe;
33
+ async load() {
34
+ if (!existsSync(this.path)) {
35
+ throw new Error(`Catalog file not found: ${this.path}`);
36
+ }
37
+ return JSON.parse(readFileSync(this.path, "utf8"));
38
+ }
39
+ };
40
+ var HttpCatalogSource = class {
41
+ constructor(url, headers, kind = "http") {
42
+ this.url = url;
43
+ this.headers = headers;
44
+ this.describe = `${kind}:${url}`;
45
+ }
46
+ describe;
47
+ async load() {
48
+ const res = await fetch(this.url, { headers: this.headers });
49
+ if (!res.ok) {
50
+ throw new Error(`Catalog ${res.status} from ${this.url}`);
51
+ }
52
+ return await res.json();
53
+ }
54
+ };
55
+ var OciCatalogSource = class {
56
+ constructor(ref, file = "catalog.json") {
57
+ this.ref = ref;
58
+ this.file = file;
59
+ this.describe = `oci:${ref}`;
60
+ }
61
+ describe;
62
+ async load() {
63
+ const dir = mkdtempSync(join(tmpdir(), "kaupang-oci-"));
64
+ try {
65
+ await execa("oras", ["pull", ...orasRegistryArgs(this.ref), this.ref, "-o", dir]);
66
+ } catch (err) {
67
+ const e = err;
68
+ if (e.code === "ENOENT") {
69
+ throw new Error(
70
+ `Catalog "oci" source needs the \`oras\` CLI on PATH (https://oras.land). Install it, or use a file / http / service source instead.`
71
+ );
72
+ }
73
+ throw new Error(`oras pull ${this.ref} failed: ${e.message ?? String(err)}`);
74
+ }
75
+ const file = join(dir, this.file);
76
+ try {
77
+ if (!existsSync(file)) {
78
+ throw new Error(
79
+ `Pulled ${this.ref} but it has no "${this.file}". Push it as that filename: oras push ${this.ref} ${this.file}`
80
+ );
81
+ }
82
+ return JSON.parse(readFileSync(file, "utf8"));
83
+ } finally {
84
+ rmSync(dir, { recursive: true, force: true });
85
+ }
86
+ }
87
+ };
88
+ function createSource(cfg, rootDir) {
89
+ switch (cfg.type) {
90
+ case "file":
91
+ if (!cfg.path) throw new Error('Catalog "file" source needs a `path`.');
92
+ return new FileCatalogSource(resolve(rootDir, cfg.path));
93
+ case "http":
94
+ if (!cfg.url) throw new Error('Catalog "http" source needs a `url`.');
95
+ return new HttpCatalogSource(cfg.url, cfg.headers, "http");
96
+ case "service":
97
+ if (!cfg.url) throw new Error('Catalog "service" source needs a `url`.');
98
+ return new HttpCatalogSource(cfg.url, cfg.headers, "service");
99
+ case "oci":
100
+ if (!cfg.ref) throw new Error('Catalog "oci" source needs a `ref`.');
101
+ return new OciCatalogSource(cfg.ref, cfg.path);
102
+ default:
103
+ throw new Error(`Unknown catalog source type "${cfg.type}".`);
104
+ }
105
+ }
106
+ function createCatalogResolver(config, rootDir) {
107
+ const sources = (config?.sources ?? []).map((c2) => createSource(c2, rootDir));
108
+ let merged = null;
109
+ const loadAll = () => {
110
+ if (!merged) {
111
+ merged = (async () => {
112
+ const out = { services: {}, solutions: {} };
113
+ for (const source of [...sources].reverse()) {
114
+ const manifest = await source.load();
115
+ Object.assign(out.services, manifest.services ?? {});
116
+ Object.assign(out.solutions, manifest.solutions ?? {});
117
+ }
118
+ return out;
119
+ })();
120
+ }
121
+ return merged;
122
+ };
123
+ const requireSources = (what, name) => {
124
+ if (sources.length === 0) {
125
+ throw new Error(
126
+ `${what} "${name}" was requested but no catalog sources are configured. Add a \`catalog: { sources: [...] }\` to kaupang.config.ts.`
127
+ );
128
+ }
129
+ };
130
+ return {
131
+ async resolve(name) {
132
+ requireSources("Catalog preset", name);
133
+ const { services } = await loadAll();
134
+ const spec = services[name];
135
+ if (!spec) {
136
+ const available = Object.keys(services).sort().join(", ") || "(none)";
137
+ throw new Error(`Catalog preset "${name}" not found. Available: ${available}.`);
138
+ }
139
+ return structuredClone(spec);
140
+ },
141
+ async list() {
142
+ return Object.keys((await loadAll()).services).sort();
143
+ },
144
+ async resolveSolution(name) {
145
+ requireSources("Solution", name);
146
+ const { solutions } = await loadAll();
147
+ const recipe = solutions[name];
148
+ if (!recipe) {
149
+ const available = Object.keys(solutions).sort().join(", ") || "(none)";
150
+ throw new Error(`Solution "${name}" not found in catalog. Available: ${available}.`);
151
+ }
152
+ return structuredClone(recipe);
153
+ },
154
+ async listSolutions() {
155
+ return Object.keys((await loadAll()).solutions).sort();
156
+ }
157
+ };
158
+ }
159
+
160
+ // src/config/normalize.ts
161
+ function toArray(value) {
162
+ if (!value) return [];
163
+ return Array.isArray(value) ? value : [value];
164
+ }
165
+ function resolveImage(image, repo) {
166
+ if (!image || !repo) return image;
167
+ if (image.includes("/")) return image;
168
+ return `${repo.replace(/\/+$/, "")}/${image}`;
169
+ }
170
+ function isCatalogRef(input) {
171
+ return typeof input === "object" && input !== null && "$catalog" in input;
172
+ }
173
+ function mergeSpec(base, overrides) {
174
+ if (!overrides) return base;
175
+ return {
176
+ ...base,
177
+ ...overrides,
178
+ env: { ...base.env, ...overrides.env },
179
+ labels: { ...base.labels, ...overrides.labels }
180
+ };
181
+ }
182
+ function finalize(spec, ctx, fromCatalog) {
183
+ const { dependsOn, image, pull, ...rest } = spec;
184
+ return {
185
+ ...rest,
186
+ // Catalog presets carry their own (public) images — don't re-prefix them.
187
+ image: fromCatalog ? image : resolveImage(image, ctx.dockerRepository),
188
+ dependsOn: toArray(dependsOn),
189
+ pull: pull ?? ctx.defaultPull
190
+ };
191
+ }
192
+ async function normalizeService(input, ctx) {
193
+ if (typeof input === "string") {
194
+ return finalize({ image: input }, ctx, false);
195
+ }
196
+ if (isCatalogRef(input)) {
197
+ const base = await ctx.catalog.resolve(input.$catalog);
198
+ return finalize(mergeSpec(base, input.overrides), ctx, true);
199
+ }
200
+ return finalize(input, ctx, false);
201
+ }
202
+ async function normalizeEnvironment(def, name, file, ctx) {
203
+ const localCtx = {
204
+ catalog: ctx.catalog,
205
+ dockerRepository: def.dockerRepository ?? ctx.dockerRepository,
206
+ defaultPull: def.pull ?? ctx.defaultPull
207
+ };
208
+ const services = {};
209
+ for (const [key2, input] of Object.entries(def.services)) {
210
+ services[key2] = await normalizeService(input, localCtx);
211
+ }
212
+ return {
213
+ name,
214
+ file,
215
+ services,
216
+ dependsOn: toArray(def.dependsOn),
217
+ env: def.env ?? {},
218
+ hooks: def.hooks,
219
+ networks: def.networks,
220
+ volumes: def.volumes
221
+ };
222
+ }
223
+
224
+ // src/config/loader.ts
225
+ var CONFIG_NAMES = [
226
+ "kaupang.config.ts",
227
+ "kaupang.config.mjs",
228
+ "kaupang.config.js",
229
+ "kaupang.config.json"
230
+ ];
231
+ var ENV_INDEX_NAMES = ["index.ts", "index.mjs", "index.js", "index.json"];
232
+ var ENV_FILE_RE = /\.(ts|mts|mjs|js|json)$/;
233
+ function findConfig(cwd = process.cwd()) {
234
+ let dir = resolve2(cwd);
235
+ while (true) {
236
+ for (const name of CONFIG_NAMES) {
237
+ const candidate = join2(dir, name);
238
+ if (existsSync2(candidate)) return candidate;
239
+ }
240
+ const parent = dirname(dir);
241
+ if (parent === dir) return null;
242
+ dir = parent;
243
+ }
244
+ }
245
+ function sanitizeProject(name) {
246
+ return name.toLowerCase().replace(/[^a-z0-9_-]+/g, "_").replace(/^[_-]+|[_-]+$/g, "") || "kaupang";
247
+ }
248
+ async function loadConfig(cwd = process.cwd()) {
249
+ const configPath = findConfig(cwd);
250
+ if (!configPath) {
251
+ throw new Error(
252
+ `No kaupang config found. Create a "kaupang.config.ts" (or .mjs / .js / .json) in ${resolve2(cwd)} or a parent directory.`
253
+ );
254
+ }
255
+ const rootDir = dirname(configPath);
256
+ const jiti = createJiti(rootDir, { moduleCache: false });
257
+ const config = await importDefault(jiti, configPath);
258
+ if (!config || typeof config.environments !== "string") {
259
+ throw new Error(
260
+ `${configPath} must \`export default defineConfig({ environments: "<folder>" })\`.`
261
+ );
262
+ }
263
+ const envDir = resolve2(rootDir, config.environments);
264
+ if (!existsSync2(envDir)) {
265
+ throw new Error(
266
+ `Environments folder not found: ${envDir} (config.environments = "${config.environments}").`
267
+ );
268
+ }
269
+ const cacheDir = resolve2(rootDir, config.cacheDir ?? ".kaupang");
270
+ const project = sanitizeProject(config.project ?? basename(rootDir));
271
+ const catalog = createCatalogResolver(config.catalog, rootDir);
272
+ const environments = await loadEnvironments(jiti, envDir, config, catalog);
273
+ return { config, configPath, rootDir, cacheDir, project, catalog, environments };
274
+ }
275
+ async function loadEnvironments(jiti, envDir, config, catalog) {
276
+ const map = /* @__PURE__ */ new Map();
277
+ for (const entry of readdirSync(envDir, { withFileTypes: true })) {
278
+ let file = null;
279
+ let fallbackName = entry.name;
280
+ if (entry.isFile() && ENV_FILE_RE.test(entry.name) && !entry.name.endsWith(".d.ts")) {
281
+ file = join2(envDir, entry.name);
282
+ fallbackName = basename(entry.name, extname(entry.name));
283
+ } else if (entry.isDirectory()) {
284
+ for (const idx of ENV_INDEX_NAMES) {
285
+ const candidate = join2(envDir, entry.name, idx);
286
+ if (existsSync2(candidate)) {
287
+ file = candidate;
288
+ break;
289
+ }
290
+ }
291
+ }
292
+ if (!file) continue;
293
+ const def = await importDefault(jiti, file);
294
+ if (!def || typeof def.services !== "object") {
295
+ throw new Error(
296
+ `${file} must \`export default defineEnvironment({ services: { ... } })\`.`
297
+ );
298
+ }
299
+ const name = def.name ?? fallbackName;
300
+ if (map.has(name)) {
301
+ throw new Error(
302
+ `Duplicate environment name "${name}" (in ${file} and ${map.get(name).file}).`
303
+ );
304
+ }
305
+ const normalized = await normalizeEnvironment(def, name, file, {
306
+ dockerRepository: config.dockerRepository,
307
+ defaultPull: config.defaultPull,
308
+ catalog
309
+ });
310
+ map.set(name, normalized);
311
+ }
312
+ return map;
313
+ }
314
+ async function importDefault(jiti, path) {
315
+ if (path.endsWith(".json")) {
316
+ try {
317
+ return JSON.parse(readFileSync2(path, "utf8"));
318
+ } catch (err) {
319
+ throw new Error(`Failed to parse ${path}: ${err.message}`);
320
+ }
321
+ }
322
+ const mod = await jiti.import(path);
323
+ return mod.default ?? mod;
324
+ }
325
+
326
+ // src/context.ts
327
+ function makeContext(loaded, opts = {}) {
328
+ return {
329
+ rootDir: loaded.rootDir,
330
+ cacheDir: loaded.cacheDir,
331
+ project: loaded.project,
332
+ baseEnv: loaded.config.globalEnv ?? {},
333
+ targetEnv: opts.targetEnv ?? {},
334
+ pull: opts.pull
335
+ };
336
+ }
337
+
338
+ // src/graph/resolver.ts
339
+ function topoSort(nodes, deps, label) {
340
+ const indegree = /* @__PURE__ */ new Map();
341
+ const dependents = /* @__PURE__ */ new Map();
342
+ for (const n of nodes) {
343
+ indegree.set(n, 0);
344
+ dependents.set(n, []);
345
+ }
346
+ for (const n of nodes) {
347
+ for (const d of deps.get(n) ?? []) {
348
+ if (!indegree.has(d)) {
349
+ throw new Error(`${label} "${n}" depends on unknown "${d}".`);
350
+ }
351
+ indegree.set(n, indegree.get(n) + 1);
352
+ dependents.get(d).push(n);
353
+ }
354
+ }
355
+ const order = [];
356
+ const waves = [];
357
+ let frontier = nodes.filter((n) => indegree.get(n) === 0).sort();
358
+ while (frontier.length > 0) {
359
+ waves.push([...frontier]);
360
+ const next = [];
361
+ for (const n of frontier) {
362
+ order.push(n);
363
+ for (const dep of dependents.get(n)) {
364
+ indegree.set(dep, indegree.get(dep) - 1);
365
+ if (indegree.get(dep) === 0) next.push(dep);
366
+ }
367
+ }
368
+ frontier = next.sort();
369
+ }
370
+ if (order.length !== nodes.length) {
371
+ const cyclic = nodes.filter((n) => !order.includes(n));
372
+ throw new Error(
373
+ `Dependency cycle detected among ${label}s: ${cyclic.join(", ")}.`
374
+ );
375
+ }
376
+ return { order, waves };
377
+ }
378
+ function resolvePlan(target, environments, backend) {
379
+ if (!environments.has(target)) {
380
+ const available = [...environments.keys()].sort().join(", ") || "(none)";
381
+ throw new Error(`Unknown environment "${target}". Available: ${available}.`);
382
+ }
383
+ return buildPlan([target], target, environments, backend);
384
+ }
385
+ function resolveMultiPlan(roots, label, environments, backend) {
386
+ return buildPlan(roots, label, environments, backend);
387
+ }
388
+ function buildPlan(roots, label, environments, backend) {
389
+ const involved = /* @__PURE__ */ new Set();
390
+ const visit = (name, stack) => {
391
+ if (stack.includes(name)) {
392
+ throw new Error(
393
+ `Environment dependency cycle: ${[...stack, name].join(" -> ")}.`
394
+ );
395
+ }
396
+ if (involved.has(name)) return;
397
+ const env = environments.get(name);
398
+ if (!env) {
399
+ const from = stack.at(-1) ?? "(root)";
400
+ throw new Error(
401
+ `Environment "${name}" (required by "${from}") was not found.`
402
+ );
403
+ }
404
+ involved.add(name);
405
+ for (const dep of env.dependsOn ?? []) visit(dep, [...stack, name]);
406
+ };
407
+ for (const root of roots) visit(root, []);
408
+ const envDeps = /* @__PURE__ */ new Map();
409
+ for (const name of involved) {
410
+ const env = environments.get(name);
411
+ envDeps.set(name, (env.dependsOn ?? []).filter((d) => involved.has(d)));
412
+ }
413
+ const { order: envOrder } = topoSort([...involved], envDeps, "environment");
414
+ const plans = envOrder.map((name) => {
415
+ const env = environments.get(name);
416
+ const serviceNames = Object.keys(env.services);
417
+ const svcDeps = /* @__PURE__ */ new Map();
418
+ for (const svc of serviceNames) {
419
+ const declared = env.services[svc].dependsOn ?? [];
420
+ for (const d of declared) {
421
+ if (!env.services[d]) {
422
+ throw new Error(
423
+ `Service "${svc}" in environment "${name}" depends on unknown service "${d}".`
424
+ );
425
+ }
426
+ }
427
+ svcDeps.set(svc, declared);
428
+ }
429
+ const { order, waves } = topoSort(serviceNames, svcDeps, "service");
430
+ return {
431
+ name,
432
+ file: env.file,
433
+ env: env.env ?? {},
434
+ dependsOn: env.dependsOn ?? [],
435
+ order,
436
+ waves,
437
+ services: env.services,
438
+ hooks: env.hooks
439
+ };
440
+ });
441
+ return { target: label, backend, environments: plans };
442
+ }
443
+
444
+ // src/backends/compose.ts
445
+ import { join as join3 } from "node:path";
446
+
447
+ // src/backends/compose-spec.ts
448
+ import { stringify } from "yaml";
449
+
450
+ // src/util/env.ts
451
+ function isSecret(value) {
452
+ return typeof value === "object" && value !== null && "$secret" in value;
453
+ }
454
+ function mergeEnv(...maps) {
455
+ const out = {};
456
+ for (const m of maps) if (m) Object.assign(out, m);
457
+ return out;
458
+ }
459
+ function renderComposeEnv(env) {
460
+ const out = {};
461
+ for (const [key2, value] of Object.entries(env)) {
462
+ out[key2] = isSecret(value) ? `\${${value.$secret}}` : value;
463
+ }
464
+ return out;
465
+ }
466
+ function requiredSecretVars(...maps) {
467
+ const set = /* @__PURE__ */ new Set();
468
+ for (const m of maps) {
469
+ if (!m) continue;
470
+ for (const value of Object.values(m)) {
471
+ if (isSecret(value)) set.add(value.$secret);
472
+ }
473
+ }
474
+ return [...set];
475
+ }
476
+
477
+ // src/backends/compose-spec.ts
478
+ function renderComposeFile(env, opts) {
479
+ const completing = new Set(
480
+ Object.entries(env.services).filter(([, def]) => def.runOnce).map(([name]) => name)
481
+ );
482
+ const services = {};
483
+ for (const [name, def] of Object.entries(env.services)) {
484
+ services[name] = toComposeService(def, opts, env.env, completing);
485
+ }
486
+ const spec = { services };
487
+ const networks = collectNetworks(env);
488
+ if (networks.length) {
489
+ spec.networks = Object.fromEntries(networks.map((n) => [n, {}]));
490
+ }
491
+ const volumes = collectVolumes(env);
492
+ if (volumes.length) {
493
+ spec.volumes = Object.fromEntries(volumes.map((v) => [v, {}]));
494
+ }
495
+ return `# Generated by kaupang \u2014 do not edit by hand.
496
+ ${stringify(spec)}`;
497
+ }
498
+ function toComposeService(def, opts, envVars, completing) {
499
+ const svc = {};
500
+ if (def.image) svc.image = def.image;
501
+ if (def.build) svc.build = def.build;
502
+ if (def.command) svc.command = def.command;
503
+ if (def.ports?.length) svc.ports = def.ports;
504
+ if (def.volumes?.length) svc.volumes = def.volumes;
505
+ if (def.networks?.length) svc.networks = def.networks;
506
+ if (def.labels) svc.labels = def.labels;
507
+ if (def.pull && !opts.swarm) svc.pull_policy = def.pull;
508
+ const merged = mergeEnv(opts.baseEnv, envVars, opts.targetEnv, def.env);
509
+ const environment = renderComposeEnv(merged);
510
+ if (Object.keys(environment).length) svc.environment = environment;
511
+ if (def.healthcheck) {
512
+ svc.healthcheck = {
513
+ test: def.healthcheck.test,
514
+ interval: def.healthcheck.interval,
515
+ timeout: def.healthcheck.timeout,
516
+ retries: def.healthcheck.retries,
517
+ start_period: def.healthcheck.startPeriod
518
+ };
519
+ }
520
+ const restart = def.runOnce ? "no" : def.restart;
521
+ if (opts.swarm) {
522
+ const deploy = {};
523
+ if (def.replicas != null) deploy.replicas = def.replicas;
524
+ if (restart) deploy.restart_policy = { condition: toSwarmCondition(restart) };
525
+ if (Object.keys(deploy).length) svc.deploy = deploy;
526
+ } else {
527
+ if (def.dependsOn?.length) svc.depends_on = renderDependsOn(def.dependsOn, completing);
528
+ if (restart) svc.restart = restart;
529
+ if (def.replicas != null) svc.deploy = { replicas: def.replicas };
530
+ }
531
+ return svc;
532
+ }
533
+ function renderDependsOn(deps, completing) {
534
+ if (!deps.some((d) => completing.has(d))) return deps;
535
+ return Object.fromEntries(
536
+ deps.map((d) => [
537
+ d,
538
+ { condition: completing.has(d) ? "service_completed_successfully" : "service_started" }
539
+ ])
540
+ );
541
+ }
542
+ function toSwarmCondition(restart) {
543
+ switch (restart) {
544
+ case "no":
545
+ return "none";
546
+ case "on-failure":
547
+ return "on-failure";
548
+ default:
549
+ return "any";
550
+ }
551
+ }
552
+ function collectNetworks(env) {
553
+ const set = /* @__PURE__ */ new Set();
554
+ for (const svc of Object.values(env.services)) {
555
+ for (const n of svc.networks ?? []) set.add(n);
556
+ }
557
+ return [...set];
558
+ }
559
+ function collectVolumes(env) {
560
+ const set = /* @__PURE__ */ new Set();
561
+ for (const svc of Object.values(env.services)) {
562
+ for (const v of svc.volumes ?? []) {
563
+ const source = v.split(":")[0];
564
+ if (source && !source.includes("/") && !source.includes(".")) set.add(source);
565
+ }
566
+ }
567
+ return [...set];
568
+ }
569
+
570
+ // src/util/names.ts
571
+ function sanitize(name) {
572
+ return name.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^[-_]+|[-_]+$/g, "") || "default";
573
+ }
574
+ function k8sName(name) {
575
+ return name.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^[-]+|[-]+$/g, "").slice(0, 63) || "default";
576
+ }
577
+ function stackName(project, env) {
578
+ return `${sanitize(project)}_${sanitize(env)}`;
579
+ }
580
+
581
+ // src/backends/compose.ts
582
+ function composeFilePath(env, ctx) {
583
+ return join3(ctx.cacheDir, stackName(ctx.project, env.name), "docker-compose.yml");
584
+ }
585
+ function resolveImageFlag(pull) {
586
+ if (pull === "missing") return "changed";
587
+ return pull;
588
+ }
589
+ var composeBackend = {
590
+ name: "compose",
591
+ materialize(env, ctx) {
592
+ const file = composeFilePath(env, ctx);
593
+ const project = stackName(ctx.project, env.name);
594
+ const content = renderComposeFile(env, { swarm: false, baseEnv: ctx.baseEnv, targetEnv: ctx.targetEnv });
595
+ const base = ["compose", "-p", project, "--project-directory", ctx.rootDir, "-f", file];
596
+ const upArgs = [...base, "up", "-d", "--wait"];
597
+ if (ctx.pull) upArgs.push("--pull", ctx.pull);
598
+ return {
599
+ files: [{ path: file, content }],
600
+ up: [{ description: `compose up ${env.name}`, file: "docker", args: upArgs }],
601
+ down: [
602
+ {
603
+ description: `compose down ${env.name}`,
604
+ file: "docker",
605
+ args: [...base, "down"]
606
+ }
607
+ ],
608
+ build: [
609
+ {
610
+ description: `compose build ${env.name}`,
611
+ file: "docker",
612
+ args: [...base, "build"]
613
+ }
614
+ ],
615
+ push: [
616
+ {
617
+ description: `compose push ${env.name}`,
618
+ file: "docker",
619
+ args: [...base, "push"]
620
+ }
621
+ ]
622
+ };
623
+ }
624
+ };
625
+ var swarmBackend = {
626
+ name: "swarm",
627
+ materialize(env, ctx) {
628
+ const file = composeFilePath(env, ctx);
629
+ const stack = stackName(ctx.project, env.name);
630
+ const content = renderComposeFile(env, { swarm: true, baseEnv: ctx.baseEnv, targetEnv: ctx.targetEnv });
631
+ const deployArgs = ["stack", "deploy", "--detach=false", "-c", file, stack];
632
+ if (ctx.pull) deployArgs.splice(2, 0, `--resolve-image=${resolveImageFlag(ctx.pull)}`);
633
+ return {
634
+ files: [{ path: file, content }],
635
+ up: [{ description: `stack deploy ${stack}`, file: "docker", args: deployArgs }],
636
+ down: [
637
+ {
638
+ description: `stack rm ${stack}`,
639
+ file: "docker",
640
+ args: ["stack", "rm", stack]
641
+ }
642
+ ],
643
+ // Swarm has no build step; build + push out of band (or via `kaupang build`
644
+ // on the compose backend, then deploy the pushed image).
645
+ build: []
646
+ };
647
+ }
648
+ };
649
+
650
+ // src/backends/kubernetes.ts
651
+ import { join as join4 } from "node:path";
652
+ import { stringify as stringify2 } from "yaml";
653
+ var kubernetesBackend = {
654
+ name: "kubernetes",
655
+ materialize(env, ctx) {
656
+ const namespace = k8sName(stackName(ctx.project, env.name));
657
+ const docs = [
658
+ {
659
+ apiVersion: "v1",
660
+ kind: "Namespace",
661
+ metadata: { name: namespace, labels: { "app.kubernetes.io/managed-by": "kaupang" } }
662
+ }
663
+ ];
664
+ for (const [name, def] of Object.entries(env.services)) {
665
+ docs.push(deployment(name, def, namespace, ctx, env.env));
666
+ if (def.ports?.length) docs.push(service(name, def, namespace));
667
+ }
668
+ const file = join4(ctx.cacheDir, namespace, "manifest.yaml");
669
+ const content = docs.map((d) => stringify2(d)).join("---\n");
670
+ return {
671
+ files: [{ path: file, content: `# Generated by kaupang
672
+ ${content}` }],
673
+ up: [
674
+ {
675
+ description: `kubectl apply (${namespace})`,
676
+ file: "kubectl",
677
+ args: ["apply", "-f", file]
678
+ }
679
+ ],
680
+ down: [
681
+ {
682
+ description: `kubectl delete namespace ${namespace}`,
683
+ file: "kubectl",
684
+ args: ["delete", "namespace", namespace, "--ignore-not-found"]
685
+ }
686
+ ],
687
+ build: []
688
+ };
689
+ }
690
+ };
691
+ function deployment(name, def, namespace, ctx, envVars) {
692
+ if (!def.image) {
693
+ throw new Error(
694
+ `Service "${name}" needs an "image" for the kubernetes backend (build contexts are not supported).`
695
+ );
696
+ }
697
+ const appName = k8sName(name);
698
+ const env = mergeEnv(ctx.baseEnv, envVars, ctx.targetEnv, def.env);
699
+ const secretName = `${k8sName(ctx.project)}-secrets`;
700
+ const container = {
701
+ name: appName,
702
+ image: def.image
703
+ };
704
+ if (def.command) {
705
+ container.command = Array.isArray(def.command) ? def.command : ["sh", "-c", def.command];
706
+ }
707
+ if (def.ports?.length) {
708
+ container.ports = def.ports.map((p) => ({ containerPort: containerPort(p) }));
709
+ }
710
+ if (Object.keys(env).length) {
711
+ container.env = Object.entries(env).map(
712
+ ([k, v]) => isSecret(v) ? { name: k, valueFrom: { secretKeyRef: { name: secretName, key: v.$secret } } } : { name: k, value: v }
713
+ );
714
+ }
715
+ return {
716
+ apiVersion: "apps/v1",
717
+ kind: "Deployment",
718
+ metadata: { name: appName, namespace, labels: { app: appName } },
719
+ spec: {
720
+ replicas: def.replicas ?? 1,
721
+ selector: { matchLabels: { app: appName } },
722
+ template: {
723
+ metadata: { labels: { app: appName } },
724
+ spec: { containers: [container] }
725
+ }
726
+ }
727
+ };
728
+ }
729
+ function service(name, def, namespace) {
730
+ const appName = k8sName(name);
731
+ return {
732
+ apiVersion: "v1",
733
+ kind: "Service",
734
+ metadata: { name: appName, namespace },
735
+ spec: {
736
+ selector: { app: appName },
737
+ ports: (def.ports ?? []).map((p) => {
738
+ const target = containerPort(p);
739
+ return { port: hostPort(p) ?? target, targetPort: target };
740
+ })
741
+ }
742
+ };
743
+ }
744
+ function containerPort(p) {
745
+ const parts = p.split(":");
746
+ return Number(parts.at(-1));
747
+ }
748
+ function hostPort(p) {
749
+ const parts = p.split(":");
750
+ return parts.length > 1 ? Number(parts[0]) : void 0;
751
+ }
752
+
753
+ // src/backends/index.ts
754
+ var backends = {
755
+ compose: composeBackend,
756
+ swarm: swarmBackend,
757
+ kubernetes: kubernetesBackend
758
+ };
759
+ function getBackend(name) {
760
+ const backend = backends[name];
761
+ if (!backend) {
762
+ throw new Error(
763
+ `Unknown backend "${name}". Use one of: ${Object.keys(backends).join(", ")}.`
764
+ );
765
+ }
766
+ return backend;
767
+ }
768
+ var backendNames = Object.keys(backends);
769
+
770
+ // src/image/resolve.ts
771
+ import { execa as execa2 } from "execa";
772
+ import { consola } from "consola";
773
+ var FLOATING_TAGS = /* @__PURE__ */ new Set([
774
+ "latest",
775
+ "edge",
776
+ "stable",
777
+ "main",
778
+ "master",
779
+ "dev",
780
+ "nightly"
781
+ ]);
782
+ function isPinned(ref) {
783
+ return ref.includes("@sha256:");
784
+ }
785
+ function repository(ref) {
786
+ const base = ref.includes("@") ? ref.slice(0, ref.indexOf("@")) : ref;
787
+ const slash = base.lastIndexOf("/");
788
+ const colon = base.lastIndexOf(":");
789
+ return colon > slash ? base.slice(0, colon) : base;
790
+ }
791
+ function tagOf(ref) {
792
+ const base = ref.includes("@") ? ref.slice(0, ref.indexOf("@")) : ref;
793
+ const slash = base.lastIndexOf("/");
794
+ const colon = base.lastIndexOf(":");
795
+ return colon > slash ? base.slice(colon + 1) : void 0;
796
+ }
797
+ async function resolveDigest(ref) {
798
+ if (isPinned(ref)) return ref.slice(ref.indexOf("@") + 1);
799
+ try {
800
+ const { stdout } = await execa2("docker", [
801
+ "buildx",
802
+ "imagetools",
803
+ "inspect",
804
+ ref,
805
+ "--format",
806
+ "{{.Manifest.Digest}}"
807
+ ]);
808
+ const digest = stdout.trim();
809
+ if (digest.startsWith("sha256:")) return digest;
810
+ throw new Error(`unexpected output "${digest}"`);
811
+ } catch (err) {
812
+ throw new Error(
813
+ `Could not resolve a digest for "${ref}". Ensure the image is pushed and you are authenticated, or pass --no-resolve to deploy the reference as-is. (${err.message})`
814
+ );
815
+ }
816
+ }
817
+ async function resolveEnvironmentImages(env) {
818
+ const services = {};
819
+ const images = [];
820
+ for (const [key2, def] of Object.entries(env.services)) {
821
+ if (def.build || !def.image) {
822
+ services[key2] = def;
823
+ continue;
824
+ }
825
+ const ref = def.image;
826
+ const digest = await resolveDigest(ref);
827
+ const pinned = `${repository(ref)}@${digest}`;
828
+ const tag = tagOf(ref);
829
+ const floating = !isPinned(ref) && (tag === void 0 || FLOATING_TAGS.has(tag));
830
+ images.push({ service: key2, ref, digest, pinned, floating });
831
+ services[key2] = { ...def, image: pinned };
832
+ consola.log(
833
+ ` ${ref} \u2192 ${digest.slice(0, 19)}\u2026${floating ? " (floating)" : ""}`
834
+ );
835
+ }
836
+ return { env: { ...env, services }, images };
837
+ }
838
+
839
+ // src/target/target.ts
840
+ import { resolve as resolve3 } from "node:path";
841
+ function emptyRuntime(name) {
842
+ return {
843
+ name,
844
+ env: {},
845
+ dockerContextArgs: [],
846
+ kubectlContextArgs: [],
847
+ processEnv: {}
848
+ };
849
+ }
850
+ function resolveTarget(config, name, rootDir) {
851
+ const cfg = config.targets?.[name];
852
+ if (!cfg) {
853
+ if (name === "local") return emptyRuntime("local");
854
+ const available = Object.keys(config.targets ?? {}).sort().join(", ") || "(none)";
855
+ throw new Error(`Unknown target "${name}". Configured targets: ${available}.`);
856
+ }
857
+ const processEnv = {};
858
+ if (cfg.dockerHost) processEnv.DOCKER_HOST = cfg.dockerHost;
859
+ if (cfg.kubeconfig) processEnv.KUBECONFIG = resolve3(rootDir, cfg.kubeconfig);
860
+ return {
861
+ name,
862
+ backend: cfg.backend,
863
+ pull: cfg.pull,
864
+ env: cfg.env ?? {},
865
+ dockerContextArgs: cfg.dockerContext ? ["--context", cfg.dockerContext] : [],
866
+ kubectlContextArgs: cfg.kubeContext ? ["--context", cfg.kubeContext] : [],
867
+ processEnv
868
+ };
869
+ }
870
+ function applyTarget(action, rt) {
871
+ let args = action.args;
872
+ if (action.file === "docker" && rt.dockerContextArgs.length) {
873
+ args = [...rt.dockerContextArgs, ...args];
874
+ } else if (action.file === "kubectl" && rt.kubectlContextArgs.length) {
875
+ args = [...rt.kubectlContextArgs, ...args];
876
+ }
877
+ return {
878
+ file: action.file,
879
+ args,
880
+ input: action.input,
881
+ env: Object.keys(rt.processEnv).length ? rt.processEnv : void 0
882
+ };
883
+ }
884
+
885
+ // src/ledger/ledger.ts
886
+ import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync3, writeFileSync } from "node:fs";
887
+ import { join as join5 } from "node:path";
888
+ var LEDGER_VERSION = 1;
889
+ var MAX_HISTORY = 25;
890
+ function ledgerPath(cacheDir) {
891
+ return join5(cacheDir, "ledger.json");
892
+ }
893
+ function key(environment, target) {
894
+ return `${environment}@${target}`;
895
+ }
896
+ function readLedger(cacheDir) {
897
+ const path = ledgerPath(cacheDir);
898
+ if (!existsSync3(path)) return { version: LEDGER_VERSION, deployments: {} };
899
+ try {
900
+ const parsed = JSON.parse(readFileSync3(path, "utf8"));
901
+ return { version: LEDGER_VERSION, deployments: parsed.deployments ?? {} };
902
+ } catch {
903
+ return { version: LEDGER_VERSION, deployments: {} };
904
+ }
905
+ }
906
+ function writeLedger(cacheDir, ledger) {
907
+ mkdirSync(cacheDir, { recursive: true });
908
+ writeFileSync(ledgerPath(cacheDir), JSON.stringify(ledger, null, 2) + "\n");
909
+ }
910
+ function newDeploymentId(date = /* @__PURE__ */ new Date()) {
911
+ const stamp = date.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
912
+ const rand = Math.random().toString(36).slice(2, 6);
913
+ return `${stamp}-${rand}`;
914
+ }
915
+ function appendDeployment(cacheDir, record) {
916
+ const ledger = readLedger(cacheDir);
917
+ const k = key(record.environment, record.target);
918
+ const list = ledger.deployments[k] ?? [];
919
+ list.push(record);
920
+ ledger.deployments[k] = list.slice(-MAX_HISTORY);
921
+ writeLedger(cacheDir, ledger);
922
+ }
923
+ function history(cacheDir, environment, target) {
924
+ return readLedger(cacheDir).deployments[key(environment, target)] ?? [];
925
+ }
926
+ function latestSuccessful(cacheDir, environment, target) {
927
+ const list = history(cacheDir, environment, target);
928
+ for (let i = list.length - 1; i >= 0; i--) {
929
+ if (list[i].status === "succeeded") return list[i];
930
+ }
931
+ return void 0;
932
+ }
933
+ function rollbackTarget(cacheDir, environment, target, toId) {
934
+ const successful = history(cacheDir, environment, target).filter(
935
+ (d) => d.status === "succeeded"
936
+ );
937
+ if (toId) return successful.find((d) => d.id === toId);
938
+ return successful.length >= 2 ? successful[successful.length - 2] : void 0;
939
+ }
940
+
941
+ // src/solution/solution.ts
942
+ async function resolveSolution(config, catalog, name) {
943
+ const recipe = config.solutions?.[name] ?? await tryCatalog(catalog, name);
944
+ if (!recipe) {
945
+ const inline = Object.keys(config.solutions ?? {}).sort().join(", ") || "(none)";
946
+ throw new Error(
947
+ `Unknown solution "${name}". Inline solutions: ${inline}. (It may also live in the catalog \u2014 check your catalog sources.)`
948
+ );
949
+ }
950
+ if (!Array.isArray(recipe.environments) || recipe.environments.length === 0) {
951
+ throw new Error(`Solution "${name}" must list at least one environment.`);
952
+ }
953
+ return {
954
+ name,
955
+ version: recipe.version,
956
+ environments: recipe.environments,
957
+ pins: recipe.pins ?? {},
958
+ env: recipe.env ?? {},
959
+ target: recipe.target
960
+ };
961
+ }
962
+ async function tryCatalog(catalog, name) {
963
+ try {
964
+ return await catalog.resolveSolution(name);
965
+ } catch {
966
+ return void 0;
967
+ }
968
+ }
969
+ function applyPins(plan, pins) {
970
+ for (const [key2, ref] of Object.entries(pins)) {
971
+ const dot = key2.indexOf(".");
972
+ const envName = dot === -1 ? key2 : key2.slice(0, dot);
973
+ const service2 = dot === -1 ? "" : key2.slice(dot + 1);
974
+ const env = plan.environments.find((e) => e.name === envName);
975
+ const svc = env && service2 ? env.services[service2] : void 0;
976
+ if (!svc) {
977
+ throw new Error(`Solution pin "${key2}" does not match any service (expected "env.service").`);
978
+ }
979
+ svc.image = ref;
980
+ }
981
+ }
982
+
983
+ // src/solution/bundle.ts
984
+ import { existsSync as existsSync4, mkdirSync as mkdirSync2, mkdtempSync as mkdtempSync2, readFileSync as readFileSync4, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs";
985
+ import { tmpdir as tmpdir2 } from "node:os";
986
+ import { dirname as dirname2, join as join6 } from "node:path";
987
+ import { execa as execa3 } from "execa";
988
+ import { create as tarCreate, extract as tarExtract } from "tar";
989
+ var BUNDLE_MANIFEST = "manifest.json";
990
+ var BUNDLE_BLOB = "bundle.tar.gz";
991
+ var BUNDLE_ARTIFACT_TYPE = "application/vnd.kaupang.bundle.v1";
992
+ var OCI_PREFIX = "oci://";
993
+ function isOciRef(ref) {
994
+ return ref.startsWith(OCI_PREFIX);
995
+ }
996
+ function rewriteActionPath(action, from, to) {
997
+ return { ...action, args: action.args.map((a) => a === from ? to : a) };
998
+ }
999
+ function writeBundle(dir, manifest) {
1000
+ mkdirSync2(dir, { recursive: true });
1001
+ for (const env of manifest.environments) {
1002
+ const abs = join6(dir, env.artifact.relPath);
1003
+ mkdirSync2(dirname2(abs), { recursive: true });
1004
+ writeFileSync2(abs, env.artifact.content);
1005
+ }
1006
+ writeFileSync2(
1007
+ join6(dir, BUNDLE_MANIFEST),
1008
+ JSON.stringify(manifest, null, 2) + "\n"
1009
+ );
1010
+ }
1011
+ function readBundle(dir) {
1012
+ const path = join6(dir, BUNDLE_MANIFEST);
1013
+ if (!existsSync4(path)) {
1014
+ throw new Error(`Not a kaupang bundle (no ${BUNDLE_MANIFEST}): ${dir}`);
1015
+ }
1016
+ return JSON.parse(readFileSync4(path, "utf8"));
1017
+ }
1018
+ async function pushBundle(dir, ociRef) {
1019
+ const ref = ociRef.replace(OCI_PREFIX, "");
1020
+ const tmp = mkdtempSync2(join6(tmpdir2(), "kaupang-push-"));
1021
+ try {
1022
+ await tarCreate({ file: join6(tmp, BUNDLE_BLOB), cwd: dir, gzip: true }, ["."]);
1023
+ await oras(
1024
+ ["push", ...orasRegistryArgs(ref), ref, "--artifact-type", BUNDLE_ARTIFACT_TYPE, BUNDLE_BLOB],
1025
+ tmp,
1026
+ `push ${ref}`
1027
+ );
1028
+ } finally {
1029
+ rmSync2(tmp, { recursive: true, force: true });
1030
+ }
1031
+ }
1032
+ async function pullBundle(ociRef, intoParent) {
1033
+ const ref = ociRef.replace(OCI_PREFIX, "");
1034
+ const tmp = mkdtempSync2(join6(tmpdir2(), "kaupang-pull-"));
1035
+ try {
1036
+ await oras(["pull", ...orasRegistryArgs(ref), ref, "-o", tmp], process.cwd(), `pull ${ref}`);
1037
+ const blob = join6(tmp, BUNDLE_BLOB);
1038
+ if (!existsSync4(blob)) {
1039
+ throw new Error(`OCI artifact ${ref} is not a kaupang bundle (no ${BUNDLE_BLOB}).`);
1040
+ }
1041
+ mkdirSync2(intoParent, { recursive: true });
1042
+ const dest = mkdtempSync2(join6(intoParent, "bundle-"));
1043
+ await tarExtract({ file: blob, cwd: dest });
1044
+ return dest;
1045
+ } finally {
1046
+ rmSync2(tmp, { recursive: true, force: true });
1047
+ }
1048
+ }
1049
+ async function oras(args, cwd, what) {
1050
+ try {
1051
+ await execa3("oras", args, { cwd, stdio: "inherit" });
1052
+ } catch (err) {
1053
+ const e = err;
1054
+ if (e.code === "ENOENT") {
1055
+ throw new Error(
1056
+ `Pushing/pulling OCI bundles needs the \`oras\` CLI on PATH (https://oras.land).`
1057
+ );
1058
+ }
1059
+ throw new Error(`oras ${what} failed: ${e.message ?? String(err)}`);
1060
+ }
1061
+ }
1062
+
1063
+ // src/render/plan.ts
1064
+ import { consola as consola2 } from "consola";
1065
+ var c = {
1066
+ bold: (s) => `\x1B[1m${s}\x1B[0m`,
1067
+ dim: (s) => `\x1B[2m${s}\x1B[0m`,
1068
+ cyan: (s) => `\x1B[36m${s}\x1B[0m`,
1069
+ green: (s) => `\x1B[32m${s}\x1B[0m`
1070
+ };
1071
+ function renderPlan(plan, ctx, rt) {
1072
+ consola2.log("");
1073
+ consola2.log(
1074
+ `${c.bold("Deployment plan")} for ${c.cyan(plan.target)} ${c.dim(`(backend: ${plan.backend}${rt ? `, target: ${rt.name}` : ""})`)}`
1075
+ );
1076
+ consola2.log("");
1077
+ consola2.log(c.bold("Environment start order:"));
1078
+ plan.environments.forEach((env, i) => {
1079
+ const deps = env.dependsOn.length ? c.dim(` \u2190 depends on ${env.dependsOn.join(", ")}`) : "";
1080
+ const marker = env.name === plan.target ? c.green("\u25C6") : c.cyan("\u25CF");
1081
+ consola2.log(` ${i + 1}. ${marker} ${env.name}${deps}`);
1082
+ });
1083
+ consola2.log("");
1084
+ consola2.log(c.bold("Service graph (per environment):"));
1085
+ for (const env of plan.environments) {
1086
+ renderEnvironmentTree(env);
1087
+ }
1088
+ consola2.log("");
1089
+ consola2.log(c.bold("Commands that would run:"));
1090
+ const backend = getBackend(plan.backend);
1091
+ for (const env of plan.environments) {
1092
+ const m = backend.materialize(env, ctx);
1093
+ for (const file of m.files) {
1094
+ consola2.log(` ${c.dim("write")} ${file.path}`);
1095
+ }
1096
+ for (const action of m.up) {
1097
+ const a = rt ? applyTarget(action, rt) : action;
1098
+ consola2.log(` ${c.dim("$")} ${a.file} ${a.args.join(" ")}`);
1099
+ }
1100
+ }
1101
+ consola2.log("");
1102
+ }
1103
+ function renderEnvironmentTree(env) {
1104
+ consola2.log("");
1105
+ consola2.log(` ${c.cyan(env.name)}`);
1106
+ if (env.order.length === 0) {
1107
+ consola2.log(` ${c.dim("\u2514\u2500 (no services)")}`);
1108
+ return;
1109
+ }
1110
+ env.waves.forEach((wave, i) => {
1111
+ const isLast = i === env.waves.length - 1;
1112
+ const branch = isLast ? "\u2514\u2500" : "\u251C\u2500";
1113
+ const label = wave.length > 1 ? `${c.dim("parallel")} ` : "";
1114
+ consola2.log(` ${branch} wave ${i + 1}: ${label}${wave.join(", ")}`);
1115
+ for (const svc of wave) {
1116
+ const deps = env.services[svc]?.dependsOn ?? [];
1117
+ if (deps.length) {
1118
+ const pad = isLast ? " " : "\u2502 ";
1119
+ consola2.log(` ${pad}${c.dim(`${svc} \u2192 ${deps.join(", ")}`)}`);
1120
+ }
1121
+ }
1122
+ });
1123
+ }
1124
+
1125
+ // src/run/deploy.ts
1126
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "node:fs";
1127
+ import { dirname as dirname3 } from "node:path";
1128
+ import { consola as consola4 } from "consola";
1129
+
1130
+ // src/util/exec.ts
1131
+ import { existsSync as existsSync5 } from "node:fs";
1132
+ import { delimiter, join as join7 } from "node:path";
1133
+ import { execa as execa4 } from "execa";
1134
+ import { consola as consola3 } from "consola";
1135
+ var verbose = false;
1136
+ function setVerbose(value) {
1137
+ verbose = value;
1138
+ }
1139
+ function echo(command, dryRun) {
1140
+ if (verbose || dryRun) consola3.log(` ${dim("$")} ${command}`);
1141
+ }
1142
+ async function run(file, args, opts) {
1143
+ echo(`${file} ${args.join(" ")}`, opts.dryRun);
1144
+ if (opts.dryRun) return;
1145
+ await execa4(file, args, {
1146
+ cwd: opts.cwd,
1147
+ stdio: opts.input ? ["pipe", "inherit", "inherit"] : "inherit",
1148
+ input: opts.input,
1149
+ env: opts.env ? { ...process.env, ...opts.env } : process.env
1150
+ });
1151
+ }
1152
+ async function hasBinary(file) {
1153
+ if (file.includes("/") || file.includes("\\")) {
1154
+ return existsSync5(file) || existsSync5(`${file}.exe`);
1155
+ }
1156
+ const dirs = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
1157
+ const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";") : [""];
1158
+ for (const dir of dirs) {
1159
+ for (const ext of exts) {
1160
+ if (existsSync5(join7(dir, `${file}${ext}`))) return true;
1161
+ }
1162
+ }
1163
+ return false;
1164
+ }
1165
+ async function runShell(command, opts) {
1166
+ echo(command, opts.dryRun);
1167
+ if (opts.dryRun) return;
1168
+ await execa4(command, {
1169
+ shell: true,
1170
+ cwd: opts.cwd,
1171
+ stdio: "inherit",
1172
+ env: opts.env ? { ...process.env, ...opts.env } : process.env
1173
+ });
1174
+ }
1175
+ function dim(s) {
1176
+ return `\x1B[2m${s}\x1B[0m`;
1177
+ }
1178
+ var defaultExecutor = { run, runShell, hasBinary };
1179
+ function parseDuration(value) {
1180
+ if (typeof value === "number") return value * 1e3;
1181
+ const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h)?$/.exec(value.trim());
1182
+ if (!m) throw new Error(`Invalid duration "${value}" (use e.g. "2s", "500ms", "5m").`);
1183
+ const n = Number(m[1]);
1184
+ switch (m[2]) {
1185
+ case "ms":
1186
+ return n;
1187
+ case "m":
1188
+ return n * 6e4;
1189
+ case "h":
1190
+ return n * 36e5;
1191
+ default:
1192
+ return n * 1e3;
1193
+ }
1194
+ }
1195
+
1196
+ // src/util/hooks.ts
1197
+ import { resolve as resolve4 } from "node:path";
1198
+ async function runHooks(commands, opts) {
1199
+ const executor = opts.executor ?? defaultExecutor;
1200
+ for (const cmd of commands ?? []) {
1201
+ const norm = typeof cmd === "string" ? { run: cmd } : cmd;
1202
+ await executor.runShell(norm.run, {
1203
+ cwd: norm.cwd ? resolve4(opts.rootDir, norm.cwd) : opts.rootDir,
1204
+ env: norm.env,
1205
+ dryRun: opts.dryRun
1206
+ });
1207
+ }
1208
+ }
1209
+
1210
+ // src/run/progress.ts
1211
+ var bold = (s) => `\x1B[1m${s}\x1B[0m`;
1212
+ var dim2 = (s) => `\x1B[2m${s}\x1B[0m`;
1213
+ var cyan = (s) => `\x1B[36m${s}\x1B[0m`;
1214
+ var green = (s) => `\x1B[32m${s}\x1B[0m`;
1215
+ var red = (s) => `\x1B[31m${s}\x1B[0m`;
1216
+ function servicesLabel(env) {
1217
+ if (env.order.length === 0) return "(no services)";
1218
+ return env.waves.map((wave) => wave.join(", ")).join(" \u2192 ");
1219
+ }
1220
+ function renderQueue(plan, target) {
1221
+ const n = plan.environments.length;
1222
+ const width = Math.max(...plan.environments.map((e) => e.name.length));
1223
+ const lines = [
1224
+ "",
1225
+ `${bold("\u26F5 Voyage")} to ${cyan(plan.target)} ${dim2(`\xB7 ${plan.backend} \u2192 ${target}`)}`,
1226
+ dim2(` queue \xB7 ${n} environment${n === 1 ? "" : "s"}`)
1227
+ ];
1228
+ plan.environments.forEach((env, i) => {
1229
+ const dep = env.dependsOn.length ? dim2(` \u2190 ${env.dependsOn.join(", ")}`) : "";
1230
+ lines.push(
1231
+ ` ${dim2("\u25CB")} ${dim2(`${i + 1}/${n}`)} ${cyan(env.name.padEnd(width))} ${dim2(servicesLabel(env))}${dep}`
1232
+ );
1233
+ });
1234
+ lines.push("");
1235
+ return lines.join("\n");
1236
+ }
1237
+ function renderStepStart(i, total, env) {
1238
+ return `${cyan("\u25B6")} ${dim2(`[${i}/${total}]`)} ${bold(env.name)} ${dim2("\u2026")}`;
1239
+ }
1240
+ function renderStepDone(i, total, env, ms) {
1241
+ return `${green("\u2714")} ${dim2(`[${i}/${total}]`)} ${env.name} ${dim2(`(${fmtMs(ms)})`)}`;
1242
+ }
1243
+ function renderStepFailed(i, total, env) {
1244
+ return `${red("\u2716")} ${dim2(`[${i}/${total}]`)} ${env.name} ${red("failed")}`;
1245
+ }
1246
+ function renderSummary(plan, target, ms) {
1247
+ const n = plan.environments.length;
1248
+ const names = plan.environments.map((e) => e.name).join(", ");
1249
+ return (
1250
+ // Two spaces: the 🛖 emoji is rendered double-width and swallows a single
1251
+ // trailing space in many terminals, gluing it to the name.
1252
+ `${green("\u{1F6D6}")} ${bold(plan.target)} ${dim2("stands on")} ${cyan(target)} ${dim2(`\xB7 ${n}/${n} up (${fmtMs(ms)})`)}
1253
+ ${dim2(names)}`
1254
+ );
1255
+ }
1256
+ function fmtMs(ms) {
1257
+ return ms < 1e3 ? `${ms}ms` : `${(ms / 1e3).toFixed(1)}s`;
1258
+ }
1259
+
1260
+ // src/run/deploy.ts
1261
+ async function deployPlan(loaded, plan, rt, opts, executor = defaultExecutor) {
1262
+ const backendName = plan.backend;
1263
+ const backend = getBackend(backendName);
1264
+ const ctx = makeContext(loaded, { pull: opts.pull, targetEnv: opts.targetEnv });
1265
+ const records = [];
1266
+ const binary = backendName === "kubernetes" ? "kubectl" : "docker";
1267
+ if (!await executor.hasBinary(binary)) {
1268
+ consola4.warn(`"${binary}" was not found on PATH \u2014 commands will likely fail.`);
1269
+ }
1270
+ consola4.log(renderQueue(plan, opts.targetName));
1271
+ if (backendName !== "kubernetes") {
1272
+ for (const env of plan.environments) {
1273
+ const serviceEnvs = Object.values(env.services).map((s) => s.env);
1274
+ const needed = requiredSecretVars(loaded.config.globalEnv, opts.targetEnv, env.env, ...serviceEnvs);
1275
+ const missing = needed.filter((v) => process.env[v] === void 0);
1276
+ if (missing.length) {
1277
+ throw new Error(
1278
+ `Missing secret env var(s) for "${env.name}": ${missing.join(", ")}. Set them in your shell or pipeline (Azure: map under the step's env:, e.g. ${missing[0]}: $(${missing[0]})).`
1279
+ );
1280
+ }
1281
+ }
1282
+ }
1283
+ const total = plan.environments.length;
1284
+ const startedAll = Date.now();
1285
+ let step = 0;
1286
+ for (const env of plan.environments) {
1287
+ step++;
1288
+ consola4.log(renderStepStart(step, total, env));
1289
+ const startedStep = Date.now();
1290
+ await runHooks(env.hooks?.beforeUp, { rootDir: ctx.rootDir, executor });
1291
+ let deployEnv = env;
1292
+ let images = [];
1293
+ if (opts.resolve) {
1294
+ const resolved = await resolveEnvironmentImages(env);
1295
+ deployEnv = resolved.env;
1296
+ images = resolved.images;
1297
+ }
1298
+ const m = backend.materialize(deployEnv, ctx);
1299
+ const record = (status) => ({
1300
+ id: newDeploymentId(),
1301
+ environment: env.name,
1302
+ target: opts.targetName,
1303
+ backend: backendName,
1304
+ project: loaded.project,
1305
+ ranAt: (/* @__PURE__ */ new Date()).toISOString(),
1306
+ status,
1307
+ images,
1308
+ files: m.files,
1309
+ up: m.up,
1310
+ down: m.down
1311
+ });
1312
+ try {
1313
+ for (const f of m.files) {
1314
+ mkdirSync3(dirname3(f.path), { recursive: true });
1315
+ writeFileSync3(f.path, f.content);
1316
+ }
1317
+ for (const action of m.up) {
1318
+ const a = applyTarget(action, rt);
1319
+ await executor.run(a.file, a.args, { cwd: ctx.rootDir, input: a.input, env: a.env });
1320
+ }
1321
+ } catch (err) {
1322
+ appendDeployment(loaded.cacheDir, record("failed"));
1323
+ consola4.log(renderStepFailed(step, total, env));
1324
+ throw err;
1325
+ }
1326
+ await runHooks(env.hooks?.afterUp, { rootDir: ctx.rootDir, executor });
1327
+ const ok = record("succeeded");
1328
+ appendDeployment(loaded.cacheDir, ok);
1329
+ records.push(ok);
1330
+ consola4.log(renderStepDone(step, total, env, Date.now() - startedStep));
1331
+ }
1332
+ consola4.log(renderSummary(plan, opts.targetName, Date.now() - startedAll));
1333
+ return records;
1334
+ }
1335
+
1336
+ // src/run/pipeline.ts
1337
+ import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "node:fs";
1338
+ import { dirname as dirname4 } from "node:path";
1339
+ import { consola as consola5 } from "consola";
1340
+ var ACTION_KEYS = ["run", "up", "down", "build", "wait"];
1341
+ async function runPipeline(loaded, name, opts, executor = defaultExecutor) {
1342
+ const pipeline = loaded.config.pipelines?.[name];
1343
+ if (!pipeline) {
1344
+ const available = Object.keys(loaded.config.pipelines ?? {}).sort().join(", ") || "(none)";
1345
+ throw new Error(`Unknown pipeline "${name}". Available: ${available}.`);
1346
+ }
1347
+ const stepNames = Object.keys(pipeline.steps);
1348
+ if (stepNames.length === 0) throw new Error(`Pipeline "${name}" has no steps.`);
1349
+ for (const s of stepNames) validateStep(s, pipeline.steps[s]);
1350
+ const deps = new Map(stepNames.map((s) => [s, toArray(pipeline.steps[s].needs)]));
1351
+ const { order, waves } = topoSort(stepNames, deps, "step");
1352
+ if (opts.dryRun) {
1353
+ renderPipeline(name, pipeline, waves, opts.target);
1354
+ return;
1355
+ }
1356
+ consola5.info(`\u{1F6F6} Setting out on voyage "${name}" (${order.join(" \u2192 ")})`);
1357
+ for (const step of order) {
1358
+ consola5.start(`\u2693 leg: ${step}`);
1359
+ await runStep(loaded, pipeline.steps[step], opts.target, executor);
1360
+ consola5.success(step);
1361
+ }
1362
+ consola5.success(`\u{1F37A} Voyage "${name}" complete \u2014 to the feast!`);
1363
+ }
1364
+ function validateStep(name, step) {
1365
+ const present = ACTION_KEYS.filter((k) => step[k] !== void 0);
1366
+ if (present.length !== 1) {
1367
+ throw new Error(
1368
+ `Pipeline step "${name}" must have exactly one of ${ACTION_KEYS.join(" / ")} (got ${present.length}).`
1369
+ );
1370
+ }
1371
+ }
1372
+ async function runStep(loaded, step, defaultTarget, executor) {
1373
+ if (step.run !== void 0) {
1374
+ await runHooks([step.run], { rootDir: loaded.rootDir, executor });
1375
+ return;
1376
+ }
1377
+ if (step.wait !== void 0) {
1378
+ await runWait(step.wait);
1379
+ return;
1380
+ }
1381
+ const targetName = step.target ?? defaultTarget ?? process.env.KAUPANG_TARGET ?? "local";
1382
+ const rt = resolveTarget(loaded.config, targetName, loaded.rootDir);
1383
+ const backendName = pickBackend(step.backend, rt.backend, loaded.config.defaultBackend);
1384
+ if (step.up !== void 0) {
1385
+ const plan = resolvePlan(step.up, loaded.environments, backendName);
1386
+ await deployPlan(
1387
+ loaded,
1388
+ plan,
1389
+ rt,
1390
+ {
1391
+ targetName,
1392
+ targetEnv: rt.env,
1393
+ pull: rt.pull ?? loaded.config.defaultPull,
1394
+ resolve: true
1395
+ },
1396
+ executor
1397
+ );
1398
+ return;
1399
+ }
1400
+ if (step.build !== void 0) {
1401
+ await runEnvActions(loaded, step.build, backendName, rt, "build", executor);
1402
+ return;
1403
+ }
1404
+ if (step.down !== void 0) {
1405
+ await runEnvActions(loaded, step.down, backendName, rt, "down", executor);
1406
+ return;
1407
+ }
1408
+ }
1409
+ async function runEnvActions(loaded, envName, backendName, rt, kind, executor) {
1410
+ const plan = resolvePlan(envName, loaded.environments, backendName);
1411
+ const env = plan.environments.find((e) => e.name === plan.target);
1412
+ const ctx = makeContext(loaded, { targetEnv: rt.env });
1413
+ const m = getBackend(backendName).materialize(env, ctx);
1414
+ const actions = kind === "build" ? m.build : m.down;
1415
+ if (kind === "build" && actions.length === 0) {
1416
+ consola5.info(` (no build context in "${envName}" \u2014 nothing to build)`);
1417
+ return;
1418
+ }
1419
+ if (kind === "down" && !env) return;
1420
+ if (kind === "build") {
1421
+ for (const f of m.files) {
1422
+ mkdirSync4(dirname4(f.path), { recursive: true });
1423
+ writeFileSync4(f.path, f.content);
1424
+ }
1425
+ }
1426
+ for (const action of actions) {
1427
+ const a = applyTarget(action, rt);
1428
+ await executor.run(a.file, a.args, { cwd: ctx.rootDir, input: a.input, env: a.env });
1429
+ }
1430
+ }
1431
+ async function runWait(spec) {
1432
+ if (spec.seconds !== void 0) {
1433
+ consola5.log(` \u23F3 waiting ${spec.seconds}s`);
1434
+ await sleep(spec.seconds * 1e3);
1435
+ return;
1436
+ }
1437
+ if (!spec.http) throw new Error("A `wait` step needs either `http` or `seconds`.");
1438
+ const want = spec.status ?? 200;
1439
+ const interval = parseDuration(spec.interval ?? "2s");
1440
+ const deadline = Date.now() + parseDuration(spec.timeout ?? "60s");
1441
+ consola5.log(` \u{1F52E} reading the omens at ${spec.http} \u2192 ${want}`);
1442
+ while (Date.now() < deadline) {
1443
+ try {
1444
+ const res = await fetch(spec.http);
1445
+ if (res.status === want) return;
1446
+ } catch {
1447
+ }
1448
+ await sleep(interval);
1449
+ }
1450
+ throw new Error(`Timed out waiting for ${spec.http} to return ${want}.`);
1451
+ }
1452
+ function pickBackend(step, fromTarget, fallback) {
1453
+ const name = step ?? fromTarget ?? fallback ?? "compose";
1454
+ if (!backendNames.includes(name)) {
1455
+ throw new Error(`Unknown backend "${name}" in pipeline step.`);
1456
+ }
1457
+ return name;
1458
+ }
1459
+ function renderPipeline(name, pipeline, waves, target) {
1460
+ consola5.log(`
1461
+ Pipeline "${name}"${target ? ` \u2192 ${target}` : ""}
1462
+ `);
1463
+ waves.forEach((wave, i) => {
1464
+ consola5.log(` wave ${i + 1}${wave.length > 1 ? " (parallelizable)" : ""}:`);
1465
+ for (const step of wave) {
1466
+ consola5.log(` - ${step}: ${describe(pipeline.steps[step])}`);
1467
+ }
1468
+ });
1469
+ consola5.log("");
1470
+ }
1471
+ function describe(step) {
1472
+ if (step.run !== void 0) {
1473
+ return `run ${typeof step.run === "string" ? step.run : step.run.run}`;
1474
+ }
1475
+ if (step.up !== void 0) return `up ${step.up}${step.target ? ` \u2192 ${step.target}` : ""}`;
1476
+ if (step.down !== void 0) return `down ${step.down}`;
1477
+ if (step.build !== void 0) return `build ${step.build}`;
1478
+ if (step.wait?.http) return `wait for ${step.wait.http}`;
1479
+ if (step.wait?.seconds !== void 0) return `wait ${step.wait.seconds}s`;
1480
+ return "(empty)";
1481
+ }
1482
+ function sleep(ms) {
1483
+ return new Promise((r) => setTimeout(r, ms));
1484
+ }
1485
+ export {
1486
+ BUNDLE_MANIFEST,
1487
+ appendDeployment,
1488
+ applyPins,
1489
+ applyTarget,
1490
+ backendNames,
1491
+ createCatalogResolver,
1492
+ defaultExecutor,
1493
+ deployPlan,
1494
+ findConfig,
1495
+ getBackend,
1496
+ hasBinary,
1497
+ history,
1498
+ isOciRef,
1499
+ isPinned,
1500
+ isSecret,
1501
+ k8sName,
1502
+ latestSuccessful,
1503
+ loadConfig,
1504
+ makeContext,
1505
+ mergeEnv,
1506
+ newDeploymentId,
1507
+ normalizeEnvironment,
1508
+ parseDuration,
1509
+ pullBundle,
1510
+ pushBundle,
1511
+ readBundle,
1512
+ readLedger,
1513
+ renderComposeEnv,
1514
+ renderPlan,
1515
+ requiredSecretVars,
1516
+ resolveDigest,
1517
+ resolveEnvironmentImages,
1518
+ resolveImage,
1519
+ resolveMultiPlan,
1520
+ resolvePlan,
1521
+ resolveSolution,
1522
+ resolveTarget,
1523
+ rewriteActionPath,
1524
+ rollbackTarget,
1525
+ run,
1526
+ runHooks,
1527
+ runPipeline,
1528
+ runShell,
1529
+ sanitize,
1530
+ setVerbose,
1531
+ stackName,
1532
+ toArray,
1533
+ topoSort,
1534
+ writeBundle
1535
+ };