@esuyo/coding-container 1.0.2

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/src/index.js ADDED
@@ -0,0 +1,281 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { createRequire } from "node:module";
6
+ import { loadConfig, imageRef, globalConfigPath, resolveDefaultConfigPath } from "./config.js";
7
+ import { setup } from "./commands/setup.js";
8
+ import { build } from "./commands/build.js";
9
+ import { push } from "./commands/push.js";
10
+ import { deploy } from "./commands/deploy.js";
11
+ import { create } from "./commands/create.js";
12
+ import { ssh } from "./commands/ssh.js";
13
+ import { gc } from "./commands/gc.js";
14
+ import { deleteProject } from "./commands/delete.js";
15
+ import { validate } from "./commands/validate.js";
16
+ import {
17
+ systemInit,
18
+ systemGet,
19
+ systemList,
20
+ systemCreate,
21
+ systemRollback,
22
+ systemGc,
23
+ } from "./commands/system.js";
24
+ import { ensureKeyPair } from "./utils/keys.js";
25
+ import { ensureProjectConfig } from "./utils/project.js";
26
+ import { resolveDeployTag } from "./utils/system-config.js";
27
+
28
+ const program = new Command();
29
+
30
+ const require = createRequire(import.meta.url);
31
+ const { version } = require("../package.json");
32
+
33
+ program
34
+ .name("coding-container")
35
+ .description("Build, publish and deploy SSH-accessible coding containers on k3s")
36
+ .version(version)
37
+ // Positional options: flags before the subcommand are global, flags after
38
+ // belong to the subcommand. Required so `system create --tag vX.Y.Z` is
39
+ // not swallowed by the global --tag.
40
+ .enablePositionalOptions()
41
+ .option("-c, --config <file>", "path to config yaml (default: ./config.yaml if present, else the repo's)")
42
+ .option("-p, --project <name>", "override the project name")
43
+ .option("--tag <tag>", "override the image tag");
44
+
45
+ function cfgFrom(ctx) {
46
+ const globalOpts = program.opts();
47
+ const overrides = {};
48
+ if (globalOpts.project) overrides.project = globalOpts.project;
49
+ if (globalOpts.tag) overrides.image = { tag: globalOpts.tag };
50
+ const cfg = loadConfig(resolveDefaultConfigPath(globalOpts.config), overrides);
51
+ // First-run nudge: shown once, on stderr, until the global config exists.
52
+ if (process.argv[2] !== "setup" && !fs.existsSync(globalConfigPath())) {
53
+ console.error(
54
+ `tip: no global config at ${globalConfigPath()} - using built-in defaults. Run "coding-container setup" to create one.`
55
+ );
56
+ }
57
+ return cfg;
58
+ }
59
+
60
+ program
61
+ .command("setup")
62
+ .description("create the global config, Dockerfile and SSH key pair (never overwrites existing files)")
63
+ .option("--registry <registry>", "container registry (e.g. registry.example.com/you)")
64
+ .option("--nfs-server <server>", "NFS server IP or hostname (your NAS)")
65
+ .option("--nfs-base-path <path>", "NFS base path for per-project workspaces")
66
+ .action(async (opts) => {
67
+ await setup(opts);
68
+ });
69
+
70
+ program
71
+ .command("create")
72
+ .description(
73
+ "deploy the project: global mode floats on the shared image (no build/push here), " +
74
+ "custom mode builds + pushes the project's own image first"
75
+ )
76
+ .option("--skip-build", "skip the docker build step")
77
+ .option("--skip-push", "skip the docker push step")
78
+ .action(async (opts) => {
79
+ const globalOpts = program.opts();
80
+ // No explicit -c and no ./config.yaml: scaffold one (project name from
81
+ // the folder, first free NodePort) and proceed with it.
82
+ if (!globalOpts.config) {
83
+ const cwdConfig = path.resolve(process.cwd(), "config.yaml");
84
+ if (!fs.existsSync(cwdConfig)) {
85
+ const { created, project, nodePort } = ensureProjectConfig(
86
+ process.cwd(),
87
+ globalOpts.project
88
+ );
89
+ if (created) {
90
+ console.log(
91
+ `No config.yaml in ${process.cwd()}\nCreated one for project "${project}" (nodePort ${nodePort}).`
92
+ );
93
+ }
94
+ }
95
+ }
96
+ const cfg = cfgFrom();
97
+ await create(cfg, {
98
+ skipBuild: opts.skipBuild,
99
+ skipPush: opts.skipPush,
100
+ });
101
+ });
102
+
103
+ program
104
+ .command("build")
105
+ .description("build the project's custom image (global-mode projects: use 'system create')")
106
+ .action(async () => {
107
+ const cfg = cfgFrom();
108
+ await build(cfg);
109
+ });
110
+
111
+ program
112
+ .command("push")
113
+ .description("push the project's custom image to the registry (global-mode projects: use 'system create')")
114
+ .action(async () => {
115
+ const cfg = cfgFrom();
116
+ await push(cfg);
117
+ });
118
+
119
+ program
120
+ .command("deploy")
121
+ .description("deploy the image to the k3s cluster")
122
+ .option("--no-wait", "do not wait for the rollout to finish")
123
+ .action(async (opts) => {
124
+ const cfg = cfgFrom();
125
+ await deploy(cfg, { wait: opts.wait });
126
+ });
127
+
128
+ program
129
+ .command("ssh")
130
+ .description("open an SSH session into the deployed container (lands in /workspace)")
131
+ .option("--direct", "connect via node IP + nodePort instead of kubectl port-forward")
132
+ .action(async (opts) => {
133
+ const cfg = cfgFrom();
134
+ await ssh(cfg, { direct: opts.direct });
135
+ });
136
+
137
+ program
138
+ .command("delete")
139
+ .alias("destroy")
140
+ .description("delete the deployment, service and ssh configmap from the cluster")
141
+ .option("--keep-secrets", "keep the ssh configmap")
142
+ .action(async (opts) => {
143
+ const cfg = cfgFrom();
144
+ await deleteProject(cfg, { keepSecrets: opts.keepSecrets });
145
+ });
146
+
147
+ program
148
+ .command("key")
149
+ .description("show (or generate) the SSH key pair used for this project")
150
+ .option("--force", "regenerate the key pair (invalidates existing access)")
151
+ .action((opts) => {
152
+ const cfg = cfgFrom();
153
+ if (opts.force) {
154
+ for (const file of [
155
+ path.join(cfg.ssh.keyDirPath, cfg.ssh.keyName),
156
+ path.join(cfg.ssh.keyDirPath, cfg.ssh.keyName) + ".pub",
157
+ ]) {
158
+ if (fs.existsSync(file)) fs.unlinkSync(file);
159
+ }
160
+ }
161
+ const pair = ensureKeyPair(cfg.ssh.keyDirPath, cfg.ssh.keyName);
162
+ console.log(`Private key: ${pair.private}`);
163
+ console.log(`Public key: ${pair.public}`);
164
+ let image;
165
+ try {
166
+ cfg.image.tag = resolveDeployTag(cfg);
167
+ image = imageRef(cfg);
168
+ } catch (err) {
169
+ image = `unresolved: ${String(err.message).split("\n")[0]}`;
170
+ }
171
+ console.log(`Image: ${image}`);
172
+ });
173
+
174
+ program
175
+ .command("validate")
176
+ .description("load and validate the config without changing anything")
177
+ .option("--manifest", "print the rendered k8s manifests instead of the summary")
178
+ .action((opts) => {
179
+ validate(cfgFrom(), { manifest: opts.manifest });
180
+ });
181
+
182
+ program
183
+ .command("gc")
184
+ .description(
185
+ "delete old tags from the project's custom image repo (keeps the pinned tag, in-use tags and N versions behind the pin; global-mode projects: use 'system gc')"
186
+ )
187
+ .option("--keep <n>", "how many versions behind the pinned tag to keep", (value) => {
188
+ const n = Number.parseInt(value, 10);
189
+ if (!Number.isInteger(n) || n < 0) {
190
+ throw new Error(`--keep must be a non-negative integer, got: ${value}`);
191
+ }
192
+ return n;
193
+ }, 5)
194
+ .option("--dry-run", "list the tags that would be deleted without deleting anything")
195
+ .action(async (opts) => {
196
+ const cfg = cfgFrom();
197
+ await gc(cfg, { keep: opts.keep, dryRun: opts.dryRun });
198
+ });
199
+
200
+ function parseIntOption(value, flag) {
201
+ const n = Number.parseInt(value, 10);
202
+ if (!Number.isInteger(n) || n < 0) {
203
+ throw new Error(`${flag} must be a non-negative integer, got: ${value}`);
204
+ }
205
+ return n;
206
+ }
207
+
208
+ // The shared global image: one ConfigMap (coding-system) in the cluster is
209
+ // the source of truth. `system create` is the only writer of the image;
210
+ // project deploys float on `current`.
211
+ const system = program
212
+ .command("system")
213
+ .description(
214
+ "manage the shared global coding image (cluster ConfigMap 'coding-system'; run from a global-mode project or the repo root)"
215
+ )
216
+ .enablePositionalOptions();
217
+
218
+ system
219
+ .command("init")
220
+ .description("create the coding-system ConfigMap (fresh clusters only; fails if it exists)")
221
+ .requiredOption("--tag <tag>", "starting version (semver vX.Y.Z)")
222
+ .option("--keep <n>", "gc window: versions kept behind current (default: 10)", (value) => {
223
+ return parseIntOption(value, "--keep");
224
+ }, 10)
225
+ .action(async (opts) => {
226
+ const cfg = cfgFrom();
227
+ await systemInit(cfg, { tag: opts.tag, keep: opts.keep });
228
+ });
229
+
230
+ system
231
+ .command("get")
232
+ .description("show current/previous/maxEver/keep and the resolved global image")
233
+ .action(async () => {
234
+ systemGet(cfgFrom());
235
+ });
236
+
237
+ system
238
+ .command("list")
239
+ .description("list the global repo's registry tags, semver-sorted (read-only)")
240
+ .action(async () => {
241
+ await systemList(cfgFrom());
242
+ });
243
+
244
+ system
245
+ .command("create")
246
+ .description(
247
+ "build + push a new global version and repoint current (the only writer of the global image)"
248
+ )
249
+ .requiredOption("--tag <tag>", "new version (semver vX.Y.Z, must not exist and must be > maxEver)")
250
+ .action(async (opts) => {
251
+ const cfg = cfgFrom();
252
+ await systemCreate(cfg, { tag: opts.tag });
253
+ });
254
+
255
+ system
256
+ .command("rollback")
257
+ .description("repoint current to an existing version (default: previous); no build/push/delete")
258
+ .argument("[tag]", "target version (semver vX.Y.Z; default: previous)")
259
+ .action(async (tag) => {
260
+ const cfg = cfgFrom();
261
+ await systemRollback(cfg, { tag });
262
+ });
263
+
264
+ system
265
+ .command("gc")
266
+ .description(
267
+ "delete old tags from the global repo (keeps current, previous, all in-use tags and the ConfigMap's keep-many behind current)"
268
+ )
269
+ .option("--keep <n>", "override the ConfigMap's keep for this run", (value) => {
270
+ return parseIntOption(value, "--keep");
271
+ })
272
+ .option("--dry-run", "list the tags that would be deleted without deleting anything")
273
+ .action(async (opts) => {
274
+ const cfg = cfgFrom();
275
+ await systemGc(cfg, { keep: opts.keep, dryRun: opts.dryRun });
276
+ });
277
+
278
+ program.parseAsync(process.argv).catch((err) => {
279
+ console.error(`\nError: ${err.message}\n`);
280
+ process.exit(1);
281
+ });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Minimal `${VAR}` expansion for env-ish config values.
3
+ *
4
+ * Only the braced form is expanded (bare `$VAR` is left alone), there are no
5
+ * defaults (`${VAR:-fallback}` is not supported), and there is no escape
6
+ * hatch: a literal `${...}` in one of these fields always expands. This keeps
7
+ * existing values without a `${...}` substring byte-identical.
8
+ */
9
+
10
+ /**
11
+ * Expand `${VAR}` references in a string from process.env.
12
+ * @param {string} value the raw config value
13
+ * @param {string} what human-readable label for error messages (e.g. "k8s.secretEnv 'OPENAI_API_KEY'")
14
+ */
15
+ export function expandEnvRefs(value, what = "value") {
16
+ return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (match, name) => {
17
+ if (!(name in process.env)) {
18
+ throw new Error(
19
+ `config: ${what} needs $${name} (not set in the local environment)`
20
+ );
21
+ }
22
+ return process.env[name];
23
+ });
24
+ }
25
+
26
+ /**
27
+ * Expand `${VAR}` in every string value of a flat env map. Non-string scalars
28
+ * pass through untouched (renderers coerce with String() later). Non-maps
29
+ * pass through untouched so validate() can reject them with a clear error.
30
+ */
31
+ export function expandEnvMap(map, what = "value") {
32
+ if (map == null || typeof map !== "object" || Array.isArray(map)) return map;
33
+ const out = {};
34
+ for (const [key, value] of Object.entries(map)) {
35
+ out[key] =
36
+ typeof value === "string" ? expandEnvRefs(value, `${what} '${key}'`) : value;
37
+ }
38
+ return out;
39
+ }
@@ -0,0 +1,71 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+
3
+ /**
4
+ * Which docker to run. DOCKER_BIN lets tests (and users with a wrapper)
5
+ * point at a specific binary. On Windows an explicit script stub (.cmd)
6
+ * needs a shell to spawn.
7
+ */
8
+ export function dockerExec() {
9
+ const overridden = Boolean(process.env.DOCKER_BIN);
10
+ const cmd = process.env.DOCKER_BIN || "docker";
11
+ const opts = overridden && process.platform === "win32" ? { shell: true } : {};
12
+ return { cmd, opts };
13
+ }
14
+
15
+ /**
16
+ * Run a command and return its stdout (trimmed). Throws on non-zero exit.
17
+ */
18
+ export function exec(cmd, args = [], opts = {}) {
19
+ const res = spawnSync(cmd, args, {
20
+ encoding: "utf8",
21
+ stdio: ["ignore", "pipe", "pipe"],
22
+ ...opts,
23
+ });
24
+ if (res.error) throw res.error;
25
+ if (res.status !== 0) {
26
+ const msg = (res.stderr || res.stdout || "").trim();
27
+ throw new Error(`Command failed (${res.status}): ${cmd} ${args.join(" ")}\n${msg}`);
28
+ }
29
+ return (res.stdout || "").trim();
30
+ }
31
+
32
+ /**
33
+ * Run a command with output streamed to the console. Resolves on exit 0.
34
+ */
35
+ export function run(cmd, args = [], opts = {}) {
36
+ return new Promise((resolve, reject) => {
37
+ const child = spawn(cmd, args, { stdio: "inherit", ...opts });
38
+ child.on("error", reject);
39
+ child.on("close", (code) => {
40
+ if (code === 0) resolve();
41
+ else reject(new Error(`Command exited with code ${code}: ${cmd} ${args.join(" ")}`));
42
+ });
43
+ });
44
+ }
45
+
46
+ /**
47
+ * Run a command, writing `input` to its stdin. Streams stdout/stderr to console.
48
+ */
49
+ export function runWithInput(cmd, args = [], input = "", opts = {}) {
50
+ return new Promise((resolve, reject) => {
51
+ const child = spawn(cmd, args, {
52
+ stdio: ["pipe", "inherit", "inherit"],
53
+ ...opts,
54
+ });
55
+ child.on("error", reject);
56
+ child.stdin.on("error", () => {});
57
+ child.stdin.write(input);
58
+ child.stdin.end();
59
+ child.on("close", (code) => {
60
+ if (code === 0) resolve();
61
+ else reject(new Error(`Command exited with code ${code}: ${cmd} ${args.join(" ")}`));
62
+ });
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Spawn a long-running command, returning the child handle.
68
+ */
69
+ export function start(cmd, args = [], opts = {}) {
70
+ return spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], ...opts });
71
+ }
@@ -0,0 +1,30 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { exec } from "./exec.js";
4
+
5
+ /**
6
+ * Ensure an SSH key pair exists in keyDir. Returns { private, public }.
7
+ * The private key is created with 0600 perms when possible.
8
+ */
9
+ export function ensureKeyPair(keyDir, keyName) {
10
+ fs.mkdirSync(keyDir, { recursive: true });
11
+ const privatePath = path.join(keyDir, keyName);
12
+ const publicPath = `${privatePath}.pub`;
13
+
14
+ if (fs.existsSync(privatePath) && fs.existsSync(publicPath)) {
15
+ return { private: privatePath, public: publicPath };
16
+ }
17
+
18
+ // Generate with an empty passphrase (file on Windows; -f path must exist dir)
19
+ exec("ssh-keygen", ["-t", "ed25519", "-N", "", "-C", "coding-container", "-f", privatePath]);
20
+ try {
21
+ fs.chmodSync(privatePath, 0o600);
22
+ } catch {
23
+ /* best effort on all platforms */
24
+ }
25
+ return { private: privatePath, public: publicPath };
26
+ }
27
+
28
+ export function readPublicKey(publicPath) {
29
+ return fs.readFileSync(publicPath, "utf8").trim();
30
+ }