@kaupang/cli 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.
- package/README.md +83 -0
- package/dist/cli.js +668 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# @kaupang/cli
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@kaupang/cli)
|
|
4
|
+
[](https://github.com/kaupang-dev/kaupang/actions/workflows/ci.yml)
|
|
5
|
+
[](https://github.com/kaupang-dev/kaupang/blob/main/LICENSE)
|
|
6
|
+
|
|
7
|
+
> The **`kaupang`** CLI — spin up environments on Docker Compose, Docker Swarm, or
|
|
8
|
+
> Kubernetes from a single config, the same command on your laptop and in CI.
|
|
9
|
+
|
|
10
|
+
kaupang is an imperative, push-based deploy tool — an *environment maker*, not a
|
|
11
|
+
reconciler. You run it, it makes a target match your config, records exactly what it
|
|
12
|
+
did, and lets you replay or roll back. No control loop, no cluster agent.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install -g @kaupang/cli # installs the `kaupang` command
|
|
18
|
+
# or run without installing:
|
|
19
|
+
npx @kaupang/cli up api --target staging
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
Create a `kaupang.config.ts` at your repo root and an `environments/` folder:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
// kaupang.config.ts
|
|
28
|
+
import { defineConfig } from "@kaupang/core";
|
|
29
|
+
|
|
30
|
+
export default defineConfig({
|
|
31
|
+
environments: "./environments",
|
|
32
|
+
project: "shop",
|
|
33
|
+
dockerRepository: "ghcr.io/acme",
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// environments/api.ts
|
|
39
|
+
import { defineEnvironment } from "@kaupang/core";
|
|
40
|
+
|
|
41
|
+
export default defineEnvironment({
|
|
42
|
+
services: {
|
|
43
|
+
web: { image: "shop-api", ports: ["8080:3000"] },
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Then:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
kaupang up api # resolve images → pin digests → deploy → record
|
|
52
|
+
kaupang up api --dry-run # show the dependency graph + commands, run nothing
|
|
53
|
+
kaupang down api # tear it down
|
|
54
|
+
kaupang rollback api # re-apply the previous good deployment
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The authoring helpers (`defineConfig`, `defineEnvironment`, `secret`, `use`, …) come
|
|
58
|
+
from [`@kaupang/core`](https://www.npmjs.com/package/@kaupang/core), which this package
|
|
59
|
+
depends on.
|
|
60
|
+
|
|
61
|
+
## Commands
|
|
62
|
+
|
|
63
|
+
| Command | What it does |
|
|
64
|
+
| --- | --- |
|
|
65
|
+
| `kaupang up <env \| --solution \| --bundle>` | Deploy to a target |
|
|
66
|
+
| `kaupang down <env>` | Tear down (optionally `--with-deps`) |
|
|
67
|
+
| `kaupang build <env> [--push]` | Build (and push) images |
|
|
68
|
+
| `kaupang bundle <solution> [--push oci://…]` | Pack a portable, pinned bundle |
|
|
69
|
+
| `kaupang rollback <env> [--to <id>]` | Re-apply a recorded deployment |
|
|
70
|
+
| `kaupang run <pipeline>` | Run a pipeline (run / up / down / build / wait steps) |
|
|
71
|
+
|
|
72
|
+
Add `--dry-run` to preview without touching anything, and `-v` / `--verbose` to print the
|
|
73
|
+
underlying `docker`/`kubectl` commands.
|
|
74
|
+
|
|
75
|
+
## Docs
|
|
76
|
+
|
|
77
|
+
Full documentation — config, catalog presets, targets, solutions & bundles, pipelines,
|
|
78
|
+
secrets, backends, Azure DevOps — lives in the
|
|
79
|
+
[project README](https://github.com/kaupang-dev/kaupang#readme).
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
MIT © Andreas Quist Batista
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { defineCommand as defineCommand7, runMain } from "citty";
|
|
5
|
+
|
|
6
|
+
// src/commands/build.ts
|
|
7
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { dirname } from "node:path";
|
|
9
|
+
import { defineCommand } from "citty";
|
|
10
|
+
import { consola } from "consola";
|
|
11
|
+
import { backendNames, getBackend } from "@kaupang/core/internal";
|
|
12
|
+
import { loadConfig } from "@kaupang/core/internal";
|
|
13
|
+
import { makeContext } from "@kaupang/core/internal";
|
|
14
|
+
import { resolvePlan } from "@kaupang/core/internal";
|
|
15
|
+
import { run } from "@kaupang/core/internal";
|
|
16
|
+
|
|
17
|
+
// src/commands/shared.ts
|
|
18
|
+
import { setVerbose } from "@kaupang/core/internal";
|
|
19
|
+
var verboseArg = {
|
|
20
|
+
verbose: {
|
|
21
|
+
type: "boolean",
|
|
22
|
+
alias: "v",
|
|
23
|
+
description: "Print each underlying command ($ docker \u2026) as it runs."
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
function applyVerbose(args) {
|
|
27
|
+
setVerbose(Boolean(args.verbose));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// src/commands/build.ts
|
|
31
|
+
var buildCommand = defineCommand({
|
|
32
|
+
meta: {
|
|
33
|
+
name: "build",
|
|
34
|
+
description: "Build images for services that declare a build context."
|
|
35
|
+
},
|
|
36
|
+
args: {
|
|
37
|
+
environment: {
|
|
38
|
+
type: "positional",
|
|
39
|
+
description: "Environment whose services to build.",
|
|
40
|
+
required: true
|
|
41
|
+
},
|
|
42
|
+
backend: { type: "string", alias: "b", description: backendNames.join(" | ") },
|
|
43
|
+
push: { type: "boolean", description: "Push built images to the registry after building." },
|
|
44
|
+
"dry-run": { type: "boolean", description: "Print build commands without running them." },
|
|
45
|
+
...verboseArg,
|
|
46
|
+
cwd: { type: "string", description: "Directory to resolve the config from." }
|
|
47
|
+
},
|
|
48
|
+
async run({ args }) {
|
|
49
|
+
applyVerbose(args);
|
|
50
|
+
const loaded = await loadConfig(args.cwd);
|
|
51
|
+
const backendName = args.backend ?? loaded.config.defaultBackend ?? "compose";
|
|
52
|
+
if (backendName === "kubernetes") {
|
|
53
|
+
consola.warn(
|
|
54
|
+
"The kubernetes backend does not build images \u2014 build + push with your CI, then deploy by image."
|
|
55
|
+
);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const plan = resolvePlan(args.environment, loaded.environments, backendName);
|
|
59
|
+
const ctx = makeContext(loaded);
|
|
60
|
+
const backend = getBackend(backendName);
|
|
61
|
+
const env = plan.environments.find((e) => e.name === plan.target);
|
|
62
|
+
const hasBuild = Object.values(env.services).some((s) => s.build);
|
|
63
|
+
if (!hasBuild) {
|
|
64
|
+
consola.info(`No services in "${env.name}" declare a build context \u2014 nothing to build.`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const m = backend.materialize(env, ctx);
|
|
68
|
+
if (!args["dry-run"]) {
|
|
69
|
+
for (const f of m.files) {
|
|
70
|
+
mkdirSync(dirname(f.path), { recursive: true });
|
|
71
|
+
writeFileSync(f.path, f.content);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
consola.start(`\u2692\uFE0F forging ${env.name}`);
|
|
75
|
+
for (const action of m.build) {
|
|
76
|
+
await run(action.file, action.args, { cwd: ctx.rootDir, dryRun: args["dry-run"] });
|
|
77
|
+
}
|
|
78
|
+
if (args.push) {
|
|
79
|
+
const pushActions = m.push ?? [];
|
|
80
|
+
if (pushActions.length === 0) {
|
|
81
|
+
consola.warn(`The ${backendName} backend can't push \u2014 build + push with your CI instead.`);
|
|
82
|
+
} else {
|
|
83
|
+
consola.start(`\u26F5 shipping ${env.name}`);
|
|
84
|
+
for (const action of pushActions) {
|
|
85
|
+
await run(action.file, action.args, { cwd: ctx.rootDir, dryRun: args["dry-run"] });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
consola.success(`\u2692\uFE0F Forged "${env.name}"${args.push ? " and shipped" : ""}.`);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// src/commands/bundle.ts
|
|
94
|
+
import { basename, join, resolve } from "node:path";
|
|
95
|
+
import { mkdirSync as mkdirSync2 } from "node:fs";
|
|
96
|
+
import { defineCommand as defineCommand2 } from "citty";
|
|
97
|
+
import { consola as consola2 } from "consola";
|
|
98
|
+
import { getBackend as getBackend2 } from "@kaupang/core/internal";
|
|
99
|
+
import { loadConfig as loadConfig2 } from "@kaupang/core/internal";
|
|
100
|
+
import { makeContext as makeContext2 } from "@kaupang/core/internal";
|
|
101
|
+
import { resolveMultiPlan } from "@kaupang/core/internal";
|
|
102
|
+
import { resolveEnvironmentImages } from "@kaupang/core/internal";
|
|
103
|
+
import { applyPins, resolveSolution } from "@kaupang/core/internal";
|
|
104
|
+
import {
|
|
105
|
+
pushBundle,
|
|
106
|
+
rewriteActionPath,
|
|
107
|
+
writeBundle
|
|
108
|
+
} from "@kaupang/core/internal";
|
|
109
|
+
import { resolveTarget } from "@kaupang/core/internal";
|
|
110
|
+
import { stackName } from "@kaupang/core/internal";
|
|
111
|
+
import { mergeEnv } from "@kaupang/core/internal";
|
|
112
|
+
import { hasBinary, run as run2 } from "@kaupang/core/internal";
|
|
113
|
+
var bundleCommand = defineCommand2({
|
|
114
|
+
meta: {
|
|
115
|
+
name: "bundle",
|
|
116
|
+
description: "Materialize a solution into a portable, pinned bundle."
|
|
117
|
+
},
|
|
118
|
+
args: {
|
|
119
|
+
solution: { type: "positional", description: "Solution name.", required: true },
|
|
120
|
+
target: { type: "string", description: 'Target the bundle is built for (default "local").' },
|
|
121
|
+
output: { type: "string", alias: "o", description: "Output directory for the bundle." },
|
|
122
|
+
"with-images": {
|
|
123
|
+
type: "boolean",
|
|
124
|
+
description: "docker save each pinned image into the bundle (for airgapped transfer)."
|
|
125
|
+
},
|
|
126
|
+
push: {
|
|
127
|
+
type: "string",
|
|
128
|
+
description: "Also push the bundle to an OCI registry, e.g. oci://acmeregistry.azurecr.io/bundles/x:1."
|
|
129
|
+
},
|
|
130
|
+
resolve: {
|
|
131
|
+
type: "boolean",
|
|
132
|
+
default: true,
|
|
133
|
+
description: "Resolve images to digests (--no-resolve to keep tags as-is)."
|
|
134
|
+
},
|
|
135
|
+
cwd: { type: "string", description: "Directory to resolve the config from." }
|
|
136
|
+
},
|
|
137
|
+
async run({ args }) {
|
|
138
|
+
const loaded = await loadConfig2(args.cwd);
|
|
139
|
+
const sol = await resolveSolution(loaded.config, loaded.catalog, args.solution);
|
|
140
|
+
const targetName = args.target ?? sol.target ?? "local";
|
|
141
|
+
const rt = resolveTarget(loaded.config, targetName, loaded.rootDir);
|
|
142
|
+
const backendName = rt.backend ?? loaded.config.defaultBackend ?? "compose";
|
|
143
|
+
const plan = resolveMultiPlan(sol.environments, sol.name, loaded.environments, backendName);
|
|
144
|
+
applyPins(plan, sol.pins);
|
|
145
|
+
const ctx = makeContext2(loaded, { targetEnv: mergeEnv(rt.env, sol.env) });
|
|
146
|
+
const backend = getBackend2(backendName);
|
|
147
|
+
const outDir = resolve(loaded.rootDir, args.output ?? `${sol.name}-bundle`);
|
|
148
|
+
const tars = [];
|
|
149
|
+
const environments = [];
|
|
150
|
+
consola2.info(
|
|
151
|
+
`\u{1F4E6} Packing cargo "${sol.name}"${sol.version ? `@${sol.version}` : ""} for ${targetName} (${backendName})`
|
|
152
|
+
);
|
|
153
|
+
for (const env of plan.environments) {
|
|
154
|
+
let deployEnv = env;
|
|
155
|
+
let images = [];
|
|
156
|
+
if (args.resolve) {
|
|
157
|
+
const r = await resolveEnvironmentImages(env);
|
|
158
|
+
deployEnv = r.env;
|
|
159
|
+
images = r.images;
|
|
160
|
+
}
|
|
161
|
+
const m = backend.materialize(deployEnv, ctx);
|
|
162
|
+
const file = m.files[0];
|
|
163
|
+
const relPath = join("artifacts", stackName(loaded.project, env.name), basename(file.path));
|
|
164
|
+
environments.push({
|
|
165
|
+
name: env.name,
|
|
166
|
+
backend: backendName,
|
|
167
|
+
images,
|
|
168
|
+
artifact: { relPath, content: file.content },
|
|
169
|
+
up: m.up.map((a) => rewriteActionPath(a, file.path, relPath)),
|
|
170
|
+
down: m.down.map((a) => rewriteActionPath(a, file.path, relPath))
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (args["with-images"]) {
|
|
174
|
+
if (!await hasBinary("docker")) {
|
|
175
|
+
consola2.warn("docker not found \u2014 skipping image export (bundle has no tars).");
|
|
176
|
+
} else {
|
|
177
|
+
mkdirSync2(join(outDir, "images"), { recursive: true });
|
|
178
|
+
const unique = [...new Set(environments.flatMap((e) => e.images.map((i) => i.pinned)))];
|
|
179
|
+
for (const ref of unique) {
|
|
180
|
+
const tar = join("images", `${ref.replace(/[^a-zA-Z0-9]+/g, "_")}.tar`);
|
|
181
|
+
consola2.start(`\u{1F4E6} stowing image ${ref}`);
|
|
182
|
+
await run2("docker", ["save", "-o", join(outDir, tar), ref], { cwd: loaded.rootDir });
|
|
183
|
+
tars.push(tar);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const manifest = {
|
|
188
|
+
kaupang: "0.1.0",
|
|
189
|
+
solution: sol.name,
|
|
190
|
+
version: sol.version,
|
|
191
|
+
target: targetName,
|
|
192
|
+
project: loaded.project,
|
|
193
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
194
|
+
images: { included: tars.length > 0, tars },
|
|
195
|
+
environments
|
|
196
|
+
};
|
|
197
|
+
writeBundle(outDir, manifest);
|
|
198
|
+
consola2.success(
|
|
199
|
+
`\u{1F4E6} Cargo packed at ${outDir} (${environments.length} environment(s)${manifest.images.included ? `, ${tars.length} image tar(s)` : ", no images"}).`
|
|
200
|
+
);
|
|
201
|
+
if (args.push) {
|
|
202
|
+
consola2.start(`\u26F5 ferrying cargo \u2192 ${args.push}`);
|
|
203
|
+
await pushBundle(outDir, args.push);
|
|
204
|
+
consola2.success(`cargo delivered to ${args.push}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// src/commands/down.ts
|
|
210
|
+
import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "node:fs";
|
|
211
|
+
import { dirname as dirname2 } from "node:path";
|
|
212
|
+
import { defineCommand as defineCommand3 } from "citty";
|
|
213
|
+
import { consola as consola3 } from "consola";
|
|
214
|
+
import { backendNames as backendNames2, getBackend as getBackend3 } from "@kaupang/core/internal";
|
|
215
|
+
import { loadConfig as loadConfig3 } from "@kaupang/core/internal";
|
|
216
|
+
import { makeContext as makeContext3 } from "@kaupang/core/internal";
|
|
217
|
+
import { resolvePlan as resolvePlan2 } from "@kaupang/core/internal";
|
|
218
|
+
import { latestSuccessful } from "@kaupang/core/internal";
|
|
219
|
+
import { applyTarget, resolveTarget as resolveTarget2 } from "@kaupang/core/internal";
|
|
220
|
+
import { run as run3 } from "@kaupang/core/internal";
|
|
221
|
+
import { runHooks } from "@kaupang/core/internal";
|
|
222
|
+
var downCommand = defineCommand3({
|
|
223
|
+
meta: {
|
|
224
|
+
name: "down",
|
|
225
|
+
description: "Tear down an environment (optionally its dependencies too)."
|
|
226
|
+
},
|
|
227
|
+
args: {
|
|
228
|
+
environment: {
|
|
229
|
+
type: "positional",
|
|
230
|
+
description: "Environment to tear down.",
|
|
231
|
+
required: true
|
|
232
|
+
},
|
|
233
|
+
backend: {
|
|
234
|
+
type: "string",
|
|
235
|
+
alias: "b",
|
|
236
|
+
description: `Backend: ${backendNames2.join(" | ")}. Defaults to the cached run's backend.`
|
|
237
|
+
},
|
|
238
|
+
"with-deps": {
|
|
239
|
+
type: "boolean",
|
|
240
|
+
description: "Also tear down dependencies (reverse start order). Off by default."
|
|
241
|
+
},
|
|
242
|
+
target: { type: "string", description: 'Deployment target (default "local").' },
|
|
243
|
+
"dry-run": {
|
|
244
|
+
type: "boolean",
|
|
245
|
+
description: "Print the teardown commands without running them."
|
|
246
|
+
},
|
|
247
|
+
...verboseArg,
|
|
248
|
+
cwd: { type: "string", description: "Directory to resolve the config from." }
|
|
249
|
+
},
|
|
250
|
+
async run({ args }) {
|
|
251
|
+
applyVerbose(args);
|
|
252
|
+
const loaded = await loadConfig3(args.cwd);
|
|
253
|
+
const target = args.target ?? process.env.KAUPANG_TARGET ?? "local";
|
|
254
|
+
const rt = resolveTarget2(loaded.config, target, loaded.rootDir);
|
|
255
|
+
const cached = latestSuccessful(loaded.cacheDir, args.environment, target);
|
|
256
|
+
const backendName = args.backend ?? cached?.backend ?? rt.backend ?? loaded.config.defaultBackend ?? "compose";
|
|
257
|
+
const plan = resolvePlan2(args.environment, loaded.environments, backendName);
|
|
258
|
+
const ctx = makeContext3(loaded, { targetEnv: rt.env });
|
|
259
|
+
const backend = getBackend3(backendName);
|
|
260
|
+
const targets = args["with-deps"] ? [...plan.environments].reverse() : plan.environments.filter((e) => e.name === plan.target);
|
|
261
|
+
consola3.info(
|
|
262
|
+
`\u{1F525} Striking camp: ${targets.map((e) => e.name).join(", ")} via ${backendName}` + (args["dry-run"] ? " (dry-run)" : "")
|
|
263
|
+
);
|
|
264
|
+
for (const env of targets) {
|
|
265
|
+
const m = backend.materialize(env, ctx);
|
|
266
|
+
consola3.start(`\u{1F525} striking ${env.name}`);
|
|
267
|
+
await runHooks(env.hooks?.beforeDown, {
|
|
268
|
+
rootDir: ctx.rootDir,
|
|
269
|
+
dryRun: args["dry-run"]
|
|
270
|
+
});
|
|
271
|
+
if (!args["dry-run"]) {
|
|
272
|
+
for (const f of m.files) {
|
|
273
|
+
mkdirSync3(dirname2(f.path), { recursive: true });
|
|
274
|
+
writeFileSync2(f.path, f.content);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
for (const action of m.down) {
|
|
278
|
+
const a = applyTarget(action, rt);
|
|
279
|
+
await run3(a.file, a.args, {
|
|
280
|
+
cwd: ctx.rootDir,
|
|
281
|
+
dryRun: args["dry-run"],
|
|
282
|
+
input: a.input,
|
|
283
|
+
env: a.env
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
await runHooks(env.hooks?.afterDown, {
|
|
287
|
+
rootDir: ctx.rootDir,
|
|
288
|
+
dryRun: args["dry-run"]
|
|
289
|
+
});
|
|
290
|
+
consola3.success(env.name);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// src/commands/rollback.ts
|
|
296
|
+
import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync3 } from "node:fs";
|
|
297
|
+
import { dirname as dirname3 } from "node:path";
|
|
298
|
+
import { defineCommand as defineCommand4 } from "citty";
|
|
299
|
+
import { consola as consola4 } from "consola";
|
|
300
|
+
import { loadConfig as loadConfig4 } from "@kaupang/core/internal";
|
|
301
|
+
import {
|
|
302
|
+
appendDeployment,
|
|
303
|
+
history,
|
|
304
|
+
newDeploymentId,
|
|
305
|
+
rollbackTarget
|
|
306
|
+
} from "@kaupang/core/internal";
|
|
307
|
+
import { applyTarget as applyTarget2, resolveTarget as resolveTarget3 } from "@kaupang/core/internal";
|
|
308
|
+
import { run as run4 } from "@kaupang/core/internal";
|
|
309
|
+
var rollbackCommand = defineCommand4({
|
|
310
|
+
meta: {
|
|
311
|
+
name: "rollback",
|
|
312
|
+
description: "Re-apply a previously recorded deployment of an environment."
|
|
313
|
+
},
|
|
314
|
+
args: {
|
|
315
|
+
environment: {
|
|
316
|
+
type: "positional",
|
|
317
|
+
description: "Environment to roll back.",
|
|
318
|
+
required: true
|
|
319
|
+
},
|
|
320
|
+
to: {
|
|
321
|
+
type: "string",
|
|
322
|
+
description: "Deployment id to roll back to (see --list). Defaults to the previous one."
|
|
323
|
+
},
|
|
324
|
+
target: { type: "string", description: 'Deployment target (default "local").' },
|
|
325
|
+
list: { type: "boolean", description: "Show deployment history and exit." },
|
|
326
|
+
"dry-run": {
|
|
327
|
+
type: "boolean",
|
|
328
|
+
description: "Print the commands that would replay, without running them."
|
|
329
|
+
},
|
|
330
|
+
...verboseArg,
|
|
331
|
+
cwd: { type: "string", description: "Directory to resolve the config from." }
|
|
332
|
+
},
|
|
333
|
+
async run({ args }) {
|
|
334
|
+
applyVerbose(args);
|
|
335
|
+
const loaded = await loadConfig4(args.cwd);
|
|
336
|
+
const target = args.target ?? process.env.KAUPANG_TARGET ?? "local";
|
|
337
|
+
const rt = resolveTarget3(loaded.config, target, loaded.rootDir);
|
|
338
|
+
const records = history(loaded.cacheDir, args.environment, target);
|
|
339
|
+
if (records.length === 0) {
|
|
340
|
+
consola4.warn(`No deployment history for "${args.environment}@${target}".`);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (args.list) {
|
|
344
|
+
consola4.log(`
|
|
345
|
+
Deployments for ${args.environment}@${target} (newest first):
|
|
346
|
+
`);
|
|
347
|
+
for (const d of [...records].reverse()) {
|
|
348
|
+
const imgs = d.images.map((i) => `${i.service}=${i.digest.slice(0, 19)}\u2026`).join(" ");
|
|
349
|
+
const tag = d.rollbackOf ? ` (rollback of ${d.rollbackOf})` : "";
|
|
350
|
+
consola4.log(
|
|
351
|
+
` ${d.id} ${pad(d.status, 9)} ${d.backend} ${d.ranAt}${tag}
|
|
352
|
+
` + (imgs ? ` ${imgs}
|
|
353
|
+
` : "")
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const restore = rollbackTarget(loaded.cacheDir, args.environment, target, args.to);
|
|
359
|
+
if (!restore) {
|
|
360
|
+
consola4.error(
|
|
361
|
+
args.to ? `No successful deployment with id "${args.to}".` : "No previous successful deployment to roll back to (need at least two)."
|
|
362
|
+
);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
consola4.info(
|
|
366
|
+
`\u21A9\uFE0F Sailing back "${args.environment}@${target}" to ${restore.id} (${restore.ranAt})`
|
|
367
|
+
);
|
|
368
|
+
for (const i of restore.images) {
|
|
369
|
+
consola4.log(` ${i.service} \u2190 ${i.digest.slice(0, 19)}\u2026`);
|
|
370
|
+
}
|
|
371
|
+
if (!args["dry-run"]) {
|
|
372
|
+
for (const f of restore.files) {
|
|
373
|
+
mkdirSync4(dirname3(f.path), { recursive: true });
|
|
374
|
+
writeFileSync3(f.path, f.content);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
for (const action of restore.up) {
|
|
378
|
+
const a = applyTarget2(action, rt);
|
|
379
|
+
await run4(a.file, a.args, {
|
|
380
|
+
cwd: loaded.rootDir,
|
|
381
|
+
dryRun: args["dry-run"],
|
|
382
|
+
input: a.input,
|
|
383
|
+
env: a.env
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
if (!args["dry-run"]) {
|
|
387
|
+
const record = {
|
|
388
|
+
...restore,
|
|
389
|
+
id: newDeploymentId(),
|
|
390
|
+
ranAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
391
|
+
status: "succeeded",
|
|
392
|
+
rollbackOf: restore.id
|
|
393
|
+
};
|
|
394
|
+
appendDeployment(loaded.cacheDir, record);
|
|
395
|
+
}
|
|
396
|
+
consola4.success(`\u21A9\uFE0F Sailed back "${args.environment}" to ${restore.id}.`);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
function pad(s, n) {
|
|
400
|
+
return s.length >= n ? s : s + " ".repeat(n - s.length);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// src/commands/run.ts
|
|
404
|
+
import { defineCommand as defineCommand5 } from "citty";
|
|
405
|
+
import { loadConfig as loadConfig5 } from "@kaupang/core/internal";
|
|
406
|
+
import { runPipeline } from "@kaupang/core/internal";
|
|
407
|
+
var runCommand = defineCommand5({
|
|
408
|
+
meta: {
|
|
409
|
+
name: "run",
|
|
410
|
+
description: "Run a named pipeline (ordered run / up / down / build / wait steps)."
|
|
411
|
+
},
|
|
412
|
+
args: {
|
|
413
|
+
pipeline: { type: "positional", description: "Pipeline name.", required: true },
|
|
414
|
+
target: { type: "string", description: 'Default target for up/down/build steps (default "local").' },
|
|
415
|
+
"dry-run": { type: "boolean", description: "Print the step graph without running anything." },
|
|
416
|
+
...verboseArg,
|
|
417
|
+
cwd: { type: "string", description: "Directory to resolve the config from." }
|
|
418
|
+
},
|
|
419
|
+
async run({ args }) {
|
|
420
|
+
applyVerbose(args);
|
|
421
|
+
const loaded = await loadConfig5(args.cwd);
|
|
422
|
+
await runPipeline(loaded, args.pipeline, {
|
|
423
|
+
target: args.target,
|
|
424
|
+
dryRun: Boolean(args["dry-run"])
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
// src/commands/up.ts
|
|
430
|
+
import { join as join2, resolve as resolvePath } from "node:path";
|
|
431
|
+
import { defineCommand as defineCommand6 } from "citty";
|
|
432
|
+
import { consola as consola5 } from "consola";
|
|
433
|
+
import { backendNames as backendNames3 } from "@kaupang/core/internal";
|
|
434
|
+
import { loadConfig as loadConfig6 } from "@kaupang/core/internal";
|
|
435
|
+
import { makeContext as makeContext4 } from "@kaupang/core/internal";
|
|
436
|
+
import { resolveMultiPlan as resolveMultiPlan2, resolvePlan as resolvePlan3 } from "@kaupang/core/internal";
|
|
437
|
+
import { appendDeployment as appendDeployment2, newDeploymentId as newDeploymentId2 } from "@kaupang/core/internal";
|
|
438
|
+
import { renderPlan } from "@kaupang/core/internal";
|
|
439
|
+
import { deployPlan } from "@kaupang/core/internal";
|
|
440
|
+
import { applyPins as applyPins2, resolveSolution as resolveSolution2 } from "@kaupang/core/internal";
|
|
441
|
+
import { isOciRef, pullBundle, readBundle } from "@kaupang/core/internal";
|
|
442
|
+
import { applyTarget as applyTarget3, resolveTarget as resolveTarget4 } from "@kaupang/core/internal";
|
|
443
|
+
import { mergeEnv as mergeEnv2 } from "@kaupang/core/internal";
|
|
444
|
+
import { run as run5 } from "@kaupang/core/internal";
|
|
445
|
+
var PULL_POLICIES = ["always", "missing", "never"];
|
|
446
|
+
var upCommand = defineCommand6({
|
|
447
|
+
meta: {
|
|
448
|
+
name: "up",
|
|
449
|
+
description: "Deploy an environment, a solution, or a pre-built bundle to a target."
|
|
450
|
+
},
|
|
451
|
+
args: {
|
|
452
|
+
environment: { type: "positional", description: "Environment to bring up.", required: false },
|
|
453
|
+
solution: { type: "string", description: "Deploy a solution (composed environments) instead." },
|
|
454
|
+
bundle: { type: "string", description: "Deploy a pre-built bundle directory (offline)." },
|
|
455
|
+
target: { type: "string", description: 'Deployment target (default "local").' },
|
|
456
|
+
backend: { type: "string", alias: "b", description: `Backend: ${backendNames3.join(" | ")}.` },
|
|
457
|
+
pull: { type: "string", description: `Force pull policy: ${PULL_POLICIES.join(" | ")}.` },
|
|
458
|
+
resolve: {
|
|
459
|
+
type: "boolean",
|
|
460
|
+
default: true,
|
|
461
|
+
description: "Resolve image references to digests (pass --no-resolve to skip)."
|
|
462
|
+
},
|
|
463
|
+
"dry-run": { type: "boolean", description: "Print the plan and commands without running anything." },
|
|
464
|
+
output: { type: "string", description: 'Output format: "text" (default) or "json".' },
|
|
465
|
+
...verboseArg,
|
|
466
|
+
cwd: { type: "string", description: "Directory to resolve the config from." }
|
|
467
|
+
},
|
|
468
|
+
async run({ args }) {
|
|
469
|
+
applyVerbose(args);
|
|
470
|
+
const json = parseOutput(args.output);
|
|
471
|
+
if (json) consola5.level = -999;
|
|
472
|
+
const loaded = await loadConfig6(args.cwd);
|
|
473
|
+
if (args.bundle) {
|
|
474
|
+
await deployBundle(loaded, args.bundle, {
|
|
475
|
+
target: args.target,
|
|
476
|
+
dryRun: Boolean(args["dry-run"]),
|
|
477
|
+
json
|
|
478
|
+
});
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const targetName = args.target ?? process.env.KAUPANG_TARGET ?? "local";
|
|
482
|
+
const rt = resolveTarget4(loaded.config, targetName, loaded.rootDir);
|
|
483
|
+
let plan;
|
|
484
|
+
let targetEnv = rt.env;
|
|
485
|
+
if (args.solution) {
|
|
486
|
+
const sol = await resolveSolution2(loaded.config, loaded.catalog, args.solution);
|
|
487
|
+
const backendName2 = resolveBackend(args.backend, rt.backend, loaded.config.defaultBackend);
|
|
488
|
+
plan = resolveMultiPlan2(sol.environments, sol.name, loaded.environments, backendName2);
|
|
489
|
+
applyPins2(plan, sol.pins);
|
|
490
|
+
targetEnv = mergeEnv2(rt.env, sol.env);
|
|
491
|
+
} else if (args.environment) {
|
|
492
|
+
const backendName2 = resolveBackend(args.backend, rt.backend, loaded.config.defaultBackend);
|
|
493
|
+
plan = resolvePlan3(args.environment, loaded.environments, backendName2);
|
|
494
|
+
} else {
|
|
495
|
+
throw new Error("Specify an environment, --solution <name>, or --bundle <dir>.");
|
|
496
|
+
}
|
|
497
|
+
const backendName = plan.backend;
|
|
498
|
+
const pull = resolvePull(args.pull) ?? rt.pull ?? loaded.config.defaultPull;
|
|
499
|
+
const ctx = makeContext4(loaded, { pull, targetEnv });
|
|
500
|
+
if (args["dry-run"]) {
|
|
501
|
+
if (json) {
|
|
502
|
+
emitJson({
|
|
503
|
+
dryRun: true,
|
|
504
|
+
project: loaded.project,
|
|
505
|
+
target: targetName,
|
|
506
|
+
backend: backendName,
|
|
507
|
+
willResolve: Boolean(args.resolve),
|
|
508
|
+
environments: plan.environments.map((e) => ({
|
|
509
|
+
name: e.name,
|
|
510
|
+
services: Object.entries(e.services).map(([service, s]) => ({
|
|
511
|
+
service,
|
|
512
|
+
image: s.image ?? null
|
|
513
|
+
}))
|
|
514
|
+
}))
|
|
515
|
+
});
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
renderPlan(plan, ctx, rt);
|
|
519
|
+
if (args.resolve) {
|
|
520
|
+
consola5.info("Images will be resolved to digests at deploy time (--no-resolve to skip).");
|
|
521
|
+
}
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
const records = await deployPlan(loaded, plan, rt, {
|
|
525
|
+
targetName,
|
|
526
|
+
targetEnv,
|
|
527
|
+
pull,
|
|
528
|
+
resolve: Boolean(args.resolve)
|
|
529
|
+
});
|
|
530
|
+
if (json) {
|
|
531
|
+
emitJson({
|
|
532
|
+
project: loaded.project,
|
|
533
|
+
target: targetName,
|
|
534
|
+
backend: backendName,
|
|
535
|
+
deployments: records.map((r) => ({
|
|
536
|
+
environment: r.environment,
|
|
537
|
+
deploymentId: r.id,
|
|
538
|
+
ranAt: r.ranAt,
|
|
539
|
+
images: r.images.map((i) => ({
|
|
540
|
+
service: i.service,
|
|
541
|
+
ref: i.ref,
|
|
542
|
+
digest: i.digest ?? null,
|
|
543
|
+
pinned: i.pinned
|
|
544
|
+
}))
|
|
545
|
+
}))
|
|
546
|
+
});
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
function parseOutput(value) {
|
|
552
|
+
if (value === void 0 || value === "text") return false;
|
|
553
|
+
if (value === "json") return true;
|
|
554
|
+
throw new Error(`Unknown --output "${value}" (use "text" or "json").`);
|
|
555
|
+
}
|
|
556
|
+
function emitJson(value) {
|
|
557
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}
|
|
558
|
+
`);
|
|
559
|
+
}
|
|
560
|
+
async function deployBundle(loaded, bundleArg, opts) {
|
|
561
|
+
let dir;
|
|
562
|
+
if (isOciRef(bundleArg)) {
|
|
563
|
+
consola5.start(`\u26F5 ferrying cargo \u2190 ${bundleArg}`);
|
|
564
|
+
dir = await pullBundle(bundleArg, join2(loaded.cacheDir, "pulled"));
|
|
565
|
+
consola5.success(`cargo ashore at ${dir}`);
|
|
566
|
+
} else {
|
|
567
|
+
dir = resolvePath(loaded.rootDir, bundleArg);
|
|
568
|
+
}
|
|
569
|
+
const manifest = readBundle(dir);
|
|
570
|
+
const targetName = opts.target ?? manifest.target ?? "local";
|
|
571
|
+
const rt = resolveTarget4(loaded.config, targetName, loaded.rootDir);
|
|
572
|
+
consola5.info(
|
|
573
|
+
`\u{1F4E6} Unpacking cargo "${manifest.solution}"${manifest.version ? `@${manifest.version}` : ""} \u2192 ${targetName} (${manifest.environments.map((e) => e.name).join(" \u2192 ")})`
|
|
574
|
+
);
|
|
575
|
+
if (manifest.images.included && manifest.images.tars.length) {
|
|
576
|
+
for (const tar of manifest.images.tars) {
|
|
577
|
+
await run5("docker", ["load", "-i", join2(dir, tar)], { cwd: dir, dryRun: opts.dryRun });
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
const deployed = [];
|
|
581
|
+
for (const env of manifest.environments) {
|
|
582
|
+
consola5.start(`\u{1FA93} raising ${env.name}`);
|
|
583
|
+
const absArtifact = join2(dir, env.artifact.relPath);
|
|
584
|
+
for (const action of env.up) {
|
|
585
|
+
const abs = {
|
|
586
|
+
...action,
|
|
587
|
+
args: action.args.map(
|
|
588
|
+
(x, i, arr) => x === env.artifact.relPath ? absArtifact : arr[i - 1] === "--project-directory" ? dir : x
|
|
589
|
+
)
|
|
590
|
+
};
|
|
591
|
+
const a = applyTarget3(abs, rt);
|
|
592
|
+
await run5(a.file, a.args, { cwd: dir, dryRun: opts.dryRun, input: a.input, env: a.env });
|
|
593
|
+
}
|
|
594
|
+
if (!opts.dryRun) {
|
|
595
|
+
const id = newDeploymentId2();
|
|
596
|
+
const ranAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
597
|
+
appendDeployment2(loaded.cacheDir, {
|
|
598
|
+
id,
|
|
599
|
+
environment: env.name,
|
|
600
|
+
target: targetName,
|
|
601
|
+
backend: env.backend,
|
|
602
|
+
project: manifest.project,
|
|
603
|
+
ranAt,
|
|
604
|
+
status: "succeeded",
|
|
605
|
+
images: env.images,
|
|
606
|
+
files: [{ path: absArtifact, content: env.artifact.content }],
|
|
607
|
+
up: env.up,
|
|
608
|
+
down: env.down
|
|
609
|
+
});
|
|
610
|
+
deployed.push({ environment: env.name, deploymentId: id, ranAt, images: env.images });
|
|
611
|
+
}
|
|
612
|
+
consola5.success(env.name);
|
|
613
|
+
}
|
|
614
|
+
if (opts.json) {
|
|
615
|
+
emitJson({
|
|
616
|
+
project: manifest.project,
|
|
617
|
+
solution: manifest.solution,
|
|
618
|
+
version: manifest.version ?? null,
|
|
619
|
+
target: targetName,
|
|
620
|
+
bundle: dir,
|
|
621
|
+
deployments: deployed.map((d) => ({
|
|
622
|
+
environment: d.environment,
|
|
623
|
+
deploymentId: d.deploymentId,
|
|
624
|
+
ranAt: d.ranAt,
|
|
625
|
+
images: d.images.map((i) => ({
|
|
626
|
+
service: i.service,
|
|
627
|
+
ref: i.ref,
|
|
628
|
+
digest: i.digest ?? null,
|
|
629
|
+
pinned: i.pinned
|
|
630
|
+
}))
|
|
631
|
+
}))
|
|
632
|
+
});
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
consola5.success(`\u{1F6D6} Cargo "${manifest.solution}" landed on ${targetName}.`);
|
|
636
|
+
}
|
|
637
|
+
function resolveBackend(flag, fromTarget, fallback) {
|
|
638
|
+
const name = flag ?? fromTarget ?? fallback ?? "compose";
|
|
639
|
+
if (!backendNames3.includes(name)) {
|
|
640
|
+
throw new Error(`Unknown backend "${name}". Use one of: ${backendNames3.join(", ")}.`);
|
|
641
|
+
}
|
|
642
|
+
return name;
|
|
643
|
+
}
|
|
644
|
+
function resolvePull(flag) {
|
|
645
|
+
if (!flag) return void 0;
|
|
646
|
+
if (!PULL_POLICIES.includes(flag)) {
|
|
647
|
+
throw new Error(`Unknown pull policy "${flag}". Use one of: ${PULL_POLICIES.join(", ")}.`);
|
|
648
|
+
}
|
|
649
|
+
return flag;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// src/cli.ts
|
|
653
|
+
var main = defineCommand7({
|
|
654
|
+
meta: {
|
|
655
|
+
name: "kaupang",
|
|
656
|
+
version: "0.1.0",
|
|
657
|
+
description: "The trading hub for your deploys \u2014 gather your services, pin them into portable cargo, and ship them to any target (Compose, Swarm, or Kubernetes) from one config."
|
|
658
|
+
},
|
|
659
|
+
subCommands: {
|
|
660
|
+
up: upCommand,
|
|
661
|
+
down: downCommand,
|
|
662
|
+
build: buildCommand,
|
|
663
|
+
bundle: bundleCommand,
|
|
664
|
+
rollback: rollbackCommand,
|
|
665
|
+
run: runCommand
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
runMain(main);
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kaupang/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The kaupang CLI — the `kaupang` binary. Deploy the same definitions to Docker Compose, Swarm, or Kubernetes from one config.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Andreas Quist Batista",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/kaupang-dev/kaupang.git",
|
|
10
|
+
"directory": "packages/cli"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/kaupang-dev/kaupang/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/kaupang-dev/kaupang#readme",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"bin": {
|
|
21
|
+
"kaupang": "./dist/cli.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsup",
|
|
28
|
+
"typecheck": "tsc --noEmit",
|
|
29
|
+
"prepublishOnly": "npm run build"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@kaupang/core": "^0.1.0",
|
|
33
|
+
"citty": "0.1.6",
|
|
34
|
+
"consola": "3.4.0"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
}
|
|
39
|
+
}
|