@esuyo/coding-container 1.0.4 → 1.0.6

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 CHANGED
@@ -1,21 +1,26 @@
1
- # Esuyo coding-container
1
+ ![coding-container — Standardized, isolated coding environments.](./docs/intro-image.jpg)
2
2
 
3
- Spin up a ready-to-code SSH container on your Kubernetes cluster in one command.
3
+ # coding-container
4
4
 
5
- `coding-container` gives every project its own consistent coding environment on Kubernetes.
6
- One command deploys an SSH-accessible container per project, you land in `/workspace`, backed by your NAS so work survives restarts.
7
- The default image is just a starting point (Ubuntu, Node.js, and common dev tools); the Dockerfile is yours to customize with any language or toolchain, plus optional sidecars for databases and queues.
5
+ > Ready-to-code containers on your Kubernetes cluster — per project, over SSH, with workspaces that survive restarts.
6
+
7
+ `coding-container` gives every project its own consistent coding environment on Kubernetes. One deploy puts an SSH-accessible container per project at your fingertips: you land in `/workspace`, backed by your NAS so work survives restarts. The default image is just a starting point (Ubuntu, Node.js, and common dev tools); the Dockerfile is yours to customize with any language or toolchain, plus optional sidecars for databases and queues.
8
+
9
+ Work your way: the terminal workflow, a browser admin UI (dashboard, project editor, terminal in the browser, job history), or the installable desktop app that wraps the same UI. All three share one project registry, so a project created in one place shows up in the others.
8
10
 
9
11
  For private, internal Kubernetes clusters only. Do not expose to the public web. Every project opens NodePorts on all cluster nodes (SSH plus optional web/sidecar ports) and serves Ingress hosts without authentication or TLS by default. Only run this on a trusted internal network (homelab, LAN, or VPN-gated cluster) behind a firewall. See [Architecture](./docs/architecture.md#security-notes) for details.
10
12
 
11
13
  ## Features
12
14
 
13
- - One command to deploy a ready-to-code container (`create`), SSH in (`ssh`), and tear down (`delete`)
14
- - Kubernetes-native: renders Deployment, Service, Ingress, ConfigMap, and Secret manifests and applies them with `kubectl`
15
+ - One deploy for a ready-to-code container, SSH in, and tear down when done
16
+ - Kubernetes-native: Deployment, Service, Ingress, ConfigMap, and Secret manifests applied with `kubectl`
15
17
  - Persistent per-project workspace on your NAS, so files survive restarts
16
- - Generates a dedicated SSH key; VS Code Remote-SSH ready
18
+ - Dedicated SSH key; VS Code Remote-SSH ready
17
19
  - Optional browser access to dev servers and sidecars for databases and queues
18
20
  - Shared team image with versioning, or a custom image per project. Bring your own Dockerfile for any language or toolchain
21
+ - Browser admin UI: dashboard with live workload status, project editor with validation, terminal in the browser, image publishing, and a job log for every operation
22
+ - Desktop app (Windows / macOS / Linux): the same UI in an installable window with encrypted sign-in and automatic updates
23
+ - Shared project registry, so the terminal, browser, and desktop views stay in sync
19
24
 
20
25
  ## Prerequisites
21
26
 
@@ -24,9 +29,12 @@ For private, internal Kubernetes clusters only. Do not expose to the public web.
24
29
  - `docker login` to your container registry
25
30
  - OpenSSH (`ssh`, `ssh-keygen`)
26
31
  - Your NAS reachable from the cluster nodes
32
+ - For the browser UI / desktop app: a sign-in (email + password) configured in the checkout `.env` file
27
33
 
28
34
  ## Installation
29
35
 
36
+ Terminal workflow:
37
+
30
38
  ```sh
31
39
  npm install -g @esuyo/coding-container
32
40
  coding-container --help
@@ -48,6 +56,18 @@ One-time setup (creates your global config and SSH key, never overwrites):
48
56
  coding-container setup
49
57
  ```
50
58
 
59
+ Browser UI (from source):
60
+
61
+ ```sh
62
+ npm --prefix web/client install # first time only
63
+ copy .env.example .env # Windows (cp on macOS/Linux) — then set ADMIN_EMAIL + ADMIN_PASSWORD
64
+ node web/dev.js # pages on :3400, API on the .env PORT
65
+ ```
66
+
67
+ Production build: `npm --prefix web/client run build`, then `node web/server/server.js` serves the pages and the API on one port. See [Web GUI](./docs/gui.md).
68
+
69
+ Desktop app: download an installer from the releases page, or build one from source (`npm --prefix web/client run build`, `npm --prefix electron install`, `npm --prefix electron run dist:win`). See [Desktop](./docs/desktop.md).
70
+
51
71
  ## Configuration
52
72
 
53
73
  Two small YAML files drive everything: a global config for your environment (`~/.config/coding-container/config.yaml`) and a per-project `config.yaml` for what differs per project.
@@ -58,17 +78,25 @@ k8s:
58
78
  nodePort: 30022
59
79
  ```
60
80
 
61
- See [Configuration](./docs/configuration.md) for layering, multiple projects, and NAS folders.
81
+ Each checkout also carries its own identity in a `.env` file (copied from `.env.example`): separate config folders, connection-alias prefixes, and namespaces for isolated dev/prod instances, plus the browser UI sign-in and port.
82
+
83
+ See [Configuration](./docs/configuration.md) for layering, multiple projects, the shared registry, and NAS folders.
62
84
 
63
85
  ## Quick Start
64
86
 
87
+ Terminal workflow:
88
+
65
89
  ```sh
66
90
  coding-container create # deploy this project
67
91
  coding-container ssh # connect, you land in /workspace
68
92
  coding-container delete # remove cluster resources (files stay safe on the NAS)
69
93
  ```
70
94
 
71
- Working in another folder? `cd` there and run `create`: a minimal `config.yaml` is scaffolded automatically. Each project gets its own container and workspace.
95
+ Working in another folder? `cd` there and run `create`: a minimal `config.yaml` is scaffolded automatically. Each project gets its own container and workspace. Registered projects can be addressed from anywhere (`coding-container -p <name> deploy`).
96
+
97
+ Browser UI: open the pages, sign in, and use the Dashboard for live status, Projects to add/edit/deploy (including a terminal per project), Images and System for the shared image, and Jobs for the log of every operation.
98
+
99
+ Desktop app: sign in once on first launch — the dashboard opens automatically from then on, with the sign-in kept in the OS keychain.
72
100
 
73
101
  For everything else (web URLs, databases, image versions, all commands and flags), see [CLI reference](./docs/cli-reference.md) and the guides below.
74
102
 
@@ -77,6 +105,8 @@ For everything else (web URLs, databases, image versions, all commands and flags
77
105
  - **Missing registry or NAS settings?** Run `coding-container setup`.
78
106
  - **Push auth error?** Run `docker login <your-registry>` first.
79
107
  - **Pod stuck?** Usually NFS or image pull. See [Troubleshooting](./docs/troubleshooting.md).
108
+ - **Can't sign in to the browser UI?** Set `ADMIN_EMAIL` + `ADMIN_PASSWORD` in `.env` and restart the server.
109
+ - **Pages show "GUI not built"?** Run `npm --prefix web/client run build` first.
80
110
 
81
111
  ## License
82
112
 
@@ -84,19 +114,20 @@ MIT. See [LICENSE](./LICENSE).
84
114
 
85
115
  ## Developer Documentation
86
116
 
87
- - [Configuration](./docs/configuration.md): config layering, multiple projects, NAS volumes
88
- - [Shared global image](./docs/global-image.md): team image versions, publishing, rollback, cleanup
89
- - [Web access](./docs/web-access.md): browser access to dev servers via Traefik
90
- - [Sidecars](./docs/sidecars.md): databases, queues, and other pod companions
91
- - [CLI reference](./docs/cli-reference.md): all commands and flags
92
- - [SSH and VS Code](./docs/ssh-and-vscode.md): key rotation and Remote-SSH setup
93
- - [SSH image paste](./docs/ssh-image-paste.md): clipboard images into opencode over SSH
94
- - [Troubleshooting](./docs/troubleshooting.md): common failures and fixes
95
- - [Architecture](./docs/architecture.md): tech stack and system design
96
- - [Project Structure](./docs/project-structure.md): folder layout
97
- - [Development Guide](./docs/development.md): local setup for contributors
98
- - [App config & setup files](./docs/config-files.md): persisting tool configs via /workspace
99
- - [Plan: Additional NFS volumes](./docs/build/additional-volumes.md): design history for extra volumes
100
- - [Plan: Public prebuilt images](./docs/build/public-images.md): design history for public images
101
-
102
- ---
117
+ - [Web GUI](./docs/gui.md) — Pages, jobs, and server settings
118
+ - [Desktop app](./docs/desktop.md) — Installable Electron shell
119
+ - [Build & Deployment](./docs/build-and-deployment.md) — npm releases, GUI bundle, installers
120
+ - [Configuration](./docs/configuration.md) — Config layering, multiple projects, NAS volumes
121
+ - [Shared global image](./docs/global-image.md) — Team image versions, publishing, rollback, cleanup
122
+ - [Web access](./docs/web-access.md) — Browser access to dev servers via Traefik
123
+ - [Sidecars](./docs/sidecars.md) — Databases, queues, and other pod companions
124
+ - [CLI reference](./docs/cli-reference.md) — All commands and flags
125
+ - [SSH and VS Code](./docs/ssh-and-vscode.md) — Key rotation and Remote-SSH setup
126
+ - [SSH image paste](./docs/ssh-image-paste.md) — Clipboard images into opencode over SSH
127
+ - [Troubleshooting](./docs/troubleshooting.md) — Common failures and fixes
128
+ - [Architecture](./docs/architecture.md) — Tech stack and system design
129
+ - [Project Structure](./docs/project-structure.md) — Folder layout
130
+ - [Development Guide](./docs/development.md) — Local setup for contributors
131
+ - [App config & setup files](./docs/config-files.md) — Persisting tool configs via /workspace
132
+ - [Plan: Additional NFS volumes](./docs/build/additional-volumes.md) — Design history for extra volumes
133
+ - [Plan: Public prebuilt images](./docs/build/public-images.md) — Design history for public images
package/config.yaml CHANGED
@@ -8,6 +8,9 @@ project: my-project
8
8
 
9
9
  k8s:
10
10
  nodePort: 30022
11
+ # Pin the pod to one named cluster node (kubectl get nodes). Empty/unset ->
12
+ # the scheduler picks a node.
13
+ # nodeName: worker-02
11
14
 
12
15
  # Expose container ports to the browser through Traefik: one Ingress rule per
13
16
  # host ('hosts' list, or a single 'host'), each routing to that port on this
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esuyo/coding-container",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "CLI to build, publish and deploy SSH-accessible coding containers on k3s",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,8 +9,14 @@
9
9
  "main": "src/index.js",
10
10
  "scripts": {
11
11
  "start": "node src/index.js",
12
- "dev": "node src/index.js",
13
- "test": "node --test"
12
+ "dev": "node web/dev.js",
13
+ "dev:kill": "node web/kill-dev.js",
14
+ "dev:cli": "node src/index.js",
15
+ "dev:server": "node web/server/server.js",
16
+ "dev:client": "npm --prefix web/client run dev",
17
+ "build:gui": "npm --prefix web/client run build",
18
+ "start:gui": "node web/server/server.js",
19
+ "test": "node --test test/*.test.* web/server/*.test.mjs web/client/src/**/*.test.mjs"
14
20
  },
15
21
  "engines": {
16
22
  "node": ">=18"
@@ -34,6 +40,7 @@
34
40
  ],
35
41
  "dependencies": {
36
42
  "commander": "^12.1.0",
37
- "js-yaml": "^4.1.0"
43
+ "js-yaml": "^4.1.0",
44
+ "ssh2": "^1.17.0"
38
45
  }
39
46
  }
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { exec, run, runWithInput } from "../utils/exec.js";
3
+ import { resolveSshNodeIp } from "../utils/node-ip.js";
3
4
  import { ensureKeyPair, readPublicKey } from "../utils/keys.js";
4
5
  import { ensurePullSecret } from "../utils/registry.js";
5
6
  import { fullManifest, webPorts, sidecarNodePorts as sidecarNodePortsList } from "../utils/manifest.js";
@@ -79,16 +80,12 @@ export async function deploy(cfg, { wait = true } = {}) {
79
80
  }
80
81
  console.log(`Deployed ${cfg.project} in ${cfg.k8s.namespace}`);
81
82
 
82
- // Node IP for the NodePort hints below and the SSH alias.
83
+ // Node IP for the NodePort hints below and the SSH alias: the node the
84
+ // pod actually runs on (when Ready), else the first Ready node. A NotReady
85
+ // node never serves NodePorts, so items[0] is not a valid default.
83
86
  let nodeIp;
84
87
  try {
85
- nodeIp = exec(kubectl, [
86
- ...kargs,
87
- "get",
88
- "nodes",
89
- "-o",
90
- "jsonpath={.items[0].status.addresses[?(@.type==\"InternalIP\")].address}",
91
- ], kopts);
88
+ nodeIp = resolveSshNodeIp(cfg);
92
89
  } catch {
93
90
  /* handled below */
94
91
  }
@@ -3,6 +3,7 @@ import {
3
3
  ephemeralKnownHostsFile,
4
4
  listHostEntries,
5
5
  sshConfigPath,
6
+ sshProjectFromAlias,
6
7
  } from "../utils/ssh-config.js";
7
8
  import { exec } from "../utils/exec.js";
8
9
  import { kubectlExec } from "../utils/system-config.js";
@@ -194,7 +195,11 @@ export function listUpdateSsh({ file, dryRun = false } = {}) {
194
195
  let updated = 0;
195
196
  let current = 0;
196
197
  for (const entry of entries) {
197
- const project = entry.alias.replace(/^coding-/, "");
198
+ const project = sshProjectFromAlias(entry.alias);
199
+ if (project === null) {
200
+ console.log(`"${entry.alias}": belongs to another copy (different SSH prefix) — leaving it untouched`);
201
+ continue;
202
+ }
198
203
  const matches = services.get(project) ?? [];
199
204
  if (matches.length === 0) {
200
205
  console.warn(
@@ -0,0 +1,81 @@
1
+ import fs from "node:fs";
2
+ import {
3
+ computeProjectSlugs,
4
+ defaultProjectsFile,
5
+ readProjects,
6
+ } from "../utils/projects.js";
7
+
8
+ /**
9
+ * `projects list`: tabular view of the shared registry
10
+ * (<globalConfigDir()>/projects.json unless PROJECTS_FILE overrides it).
11
+ * Every value in SLUG/NAME/ID can be passed to `-p, --project` to run any
12
+ * config-dependent command against that project, e.g.
13
+ * `coding-container --project esuyo-mapping deploy`.
14
+ */
15
+ export function listProjects({ projectsFile, json = false } = {}) {
16
+ const file = projectsFile ?? defaultProjectsFile();
17
+ const projects = readProjects(file);
18
+ const entries = computeProjectSlugs(projects);
19
+
20
+ if (json) {
21
+ console.log(
22
+ JSON.stringify(
23
+ entries.map(({ project, slug }) => {
24
+ const configPath = String(project?.configPath ?? "");
25
+ let exists = false;
26
+ try {
27
+ exists = configPath ? fs.existsSync(configPath) : false;
28
+ } catch {
29
+ exists = false;
30
+ }
31
+ return {
32
+ slug,
33
+ name: project?.name ?? "",
34
+ id: project?.id ?? "",
35
+ configPath,
36
+ exists,
37
+ };
38
+ }),
39
+ null,
40
+ 2
41
+ )
42
+ );
43
+ return;
44
+ }
45
+
46
+ if (entries.length === 0) {
47
+ console.log(`No projects registered in ${file}.`);
48
+ return;
49
+ }
50
+
51
+ const rows = entries.map(({ project, slug }) => {
52
+ const configPath = String(project?.configPath ?? "-");
53
+ let display = configPath;
54
+ try {
55
+ if (configPath !== "-" && !fs.existsSync(configPath)) display = `${configPath} (missing)`;
56
+ } catch {
57
+ /* keep raw path */
58
+ }
59
+ return {
60
+ slug,
61
+ name: String(project?.name ?? "-"),
62
+ id: String(project?.id ?? "-"),
63
+ config: display,
64
+ };
65
+ });
66
+ const widths = {
67
+ slug: Math.max("SLUG".length, ...rows.map((r) => r.slug.length)),
68
+ name: Math.max("NAME".length, ...rows.map((r) => r.name.length)),
69
+ id: Math.max("ID".length, ...rows.map((r) => r.id.length)),
70
+ };
71
+ const pad = (s, w) => s.padEnd(w, " ");
72
+ console.log(
73
+ `${pad("SLUG", widths.slug)} ${pad("NAME", widths.name)} ${pad("ID", widths.id)} CONFIG`
74
+ );
75
+ for (const r of rows) {
76
+ console.log(`${pad(r.slug, widths.slug)} ${pad(r.name, widths.name)} ${pad(r.id, widths.id)} ${r.config}`);
77
+ }
78
+ console.log(
79
+ `\n${entries.length} project(s) from ${file} (run: coding-container --project <slug|name|id> <command>)`
80
+ );
81
+ }
@@ -1,5 +1,6 @@
1
1
  import net from "node:net";
2
- import { exec, run, start } from "../utils/exec.js";
2
+ import { run, start } from "../utils/exec.js";
3
+ import { resolveSshNodeIp } from "../utils/node-ip.js";
3
4
  import { ensureKeyPair } from "../utils/keys.js";
4
5
  import { ephemeralKnownHostsFile, removeLegacyKnownHostsFile } from "../utils/ssh-config.js";
5
6
 
@@ -53,12 +54,13 @@ function sshCommonOptions(cfg, port, privateKey) {
53
54
  }
54
55
 
55
56
  async function sshDirect(cfg, privateKey) {
56
- const nodeIp = exec("kubectl", [
57
- "get",
58
- "nodes",
59
- "-o",
60
- "jsonpath={.items[0].status.addresses[?(@.type==\"InternalIP\")].address}",
61
- ]);
57
+ // Same resolution as deploy: the pod's own (Ready) node, else the first
58
+ // Ready node. Never items[0] blindly — a NotReady node drops NodePort
59
+ // traffic even though the Service looks fine.
60
+ const nodeIp = resolveSshNodeIp(cfg);
61
+ if (!nodeIp) {
62
+ throw new Error("could not resolve a Ready cluster node for --direct (is any node Ready?)");
63
+ }
62
64
  await run("ssh", [...sshCommonOptions(cfg, cfg.k8s.nodePort, privateKey), `${cfg.ssh.user}@${nodeIp}`]);
63
65
  }
64
66
 
@@ -1,6 +1,6 @@
1
1
  import { exec } from "../utils/exec.js";
2
2
  import { listTags } from "../utils/registry-api.js";
3
- import { isSemver, compareSemver, semverSortAsc } from "../utils/semver.js";
3
+ import { isSemver, compareSemver, semverSortAsc, bumpPatch } from "../utils/semver.js";
4
4
  import {
5
5
  getSystemConfig,
6
6
  patchSystemConfig,
@@ -126,9 +126,13 @@ export async function systemList(cfg) {
126
126
  */
127
127
  export async function systemCreate(cfg, { tag } = {}) {
128
128
  requireGlobalMode(cfg);
129
- requireSemverTag(tag);
130
129
  const ns = cfg.k8s.namespace;
131
130
  const sys = getSystemConfig(ns);
131
+ if (!tag) {
132
+ tag = bumpPatch(sys.maxEver);
133
+ console.log(`No --tag given, auto-bumped to ${tag} (patch +1 over maxEver ${sys.maxEver})`);
134
+ }
135
+ requireSemverTag(tag);
132
136
  if (compareSemver(tag, sys.maxEver) <= 0) {
133
137
  throw new Error(
134
138
  `tag ${tag} is not greater than maxEver ${sys.maxEver} — global tags are immutable and ` +
@@ -68,7 +68,10 @@ export function validate(cfg, { manifest = false } = {}) {
68
68
  : `${cfg.image.registry}/${cfg.image.name}:<no tag — run 'coding-container system init' or set image.tag>`
69
69
  }`
70
70
  );
71
- console.log(` cluster: namespace ${cfg.k8s.namespace}, ${cfg.k8s.serviceType} port ${cfg.k8s.nodePort}`);
71
+ console.log(
72
+ ` cluster: namespace ${cfg.k8s.namespace}, ${cfg.k8s.serviceType} port ${cfg.k8s.nodePort}` +
73
+ (cfg.k8s.nodeName ? `, node ${cfg.k8s.nodeName}` : "")
74
+ );
72
75
  const secretKeys = secretEnvEntries(cfg).map(([name]) => name);
73
76
  if (secretKeys.length) {
74
77
  console.log(` secrets: ${secretKeys.join(", ")} (via Secret ${cfg.project}-env, values never shown)`);
package/src/config.js CHANGED
@@ -4,12 +4,16 @@ import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import yaml from "js-yaml";
6
6
  import { expandEnvMap } from "./utils/envsubst.js";
7
+ import { configDirLeaf, defaultNamespace } from "./utils/instance.js";
7
8
 
8
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
10
  export const PROJECT_ROOT = path.resolve(__dirname, "..");
10
11
 
11
12
  /**
12
- * Global config directory: $XDG_CONFIG_HOME/coding-container or ~/.config/coding-container.
13
+ * Global config directory: $XDG_CONFIG_HOME/<leaf> or ~/.config/<leaf>.
14
+ * Leaf defaults to "coding-container" (CODING_CONFIG_LEAF in
15
+ * PROJECT_FOLDER/.env or the environment overrides it), so repeated
16
+ * checkouts (dev vs prod) get separate config.yaml/Dockerfile/keys folders.
13
17
  * Holds the shared settings (config.yaml) and, by default, the SSH key
14
18
  * material (keys/).
15
19
  */
@@ -17,7 +21,7 @@ export function globalConfigDir() {
17
21
  const base = process.env.XDG_CONFIG_HOME
18
22
  ? path.resolve(process.env.XDG_CONFIG_HOME)
19
23
  : path.join(os.homedir(), ".config");
20
- return path.join(base, "coding-container");
24
+ return path.join(base, configDirLeaf());
21
25
  }
22
26
 
23
27
  export function globalConfigPath() {
@@ -50,6 +54,12 @@ export const DEFAULTS = {
50
54
  replicas: 1,
51
55
  serviceType: "NodePort",
52
56
  nodePort: 30022,
57
+ // Exact cluster node the pod is pinned to (k8s podSpec.nodeName).
58
+ // Unset (empty) -> the scheduler picks a node. Set it to force placement,
59
+ // e.g. a node with the NAS mount or a GPU:
60
+ // k8s:
61
+ // nodeName: worker-02
62
+ nodeName: "",
53
63
  imagePullSecret: "",
54
64
  resources: {
55
65
  requests: { cpu: "100m", memory: "128Mi" },
@@ -184,7 +194,7 @@ export function resolveDefaultConfigPath(explicit) {
184
194
  * 1. hardcoded DEFAULTS
185
195
  * 2. global config (~/.config/coding-container/config.yaml, if present)
186
196
  * 3. local project config (configPath)
187
- * 4. CLI overrides (e.g. -p/--project, --tag)
197
+ * 4. CLI overrides (e.g. --project-name, --tag)
188
198
  *
189
199
  * The cluster is a layer too, but it is read by the deploy/system commands,
190
200
  * not here: in global mode the image tag floats on the coding-system
@@ -196,18 +206,26 @@ export function resolveDefaultConfigPath(explicit) {
196
206
  *
197
207
  * @param {string} configPath path to the local project yaml file
198
208
  * @param {object} overrides flat-ish overrides applied on top (e.g. from CLI flags)
209
+ * @param {object} opts optional knobs; `opts.globalConfigPath` swaps which
210
+ * file backs the global layer (used to validate an unsaved global draft
211
+ * without touching the real file on disk).
199
212
  */
200
- export function loadConfig(configPath, overrides = {}) {
213
+ export function loadConfig(configPath, overrides = {}, opts = {}) {
201
214
  const resolved = path.resolve(configPath);
202
215
  if (!fs.existsSync(resolved)) {
203
216
  throw new Error(`Config file not found: ${resolved}`);
204
217
  }
205
218
  const localDir = path.dirname(resolved);
206
- const gDir = globalConfigDir();
219
+ const gPath = opts.globalConfigPath ? path.resolve(opts.globalConfigPath) : globalConfigPath();
220
+ const gDir = path.dirname(gPath);
207
221
 
208
- let cfg = withPaths(DEFAULTS, localDir);
209
- if (fs.existsSync(globalConfigPath())) {
210
- cfg = deepMerge(cfg, withPaths(readYaml(globalConfigPath()), gDir));
222
+ // Instance default namespace (CODING_NAMESPACE): base layer only, so the
223
+ // global/project configs still override it per project.
224
+ const baseDefaults = structuredClone(DEFAULTS);
225
+ baseDefaults.k8s.namespace = defaultNamespace();
226
+ let cfg = withPaths(baseDefaults, localDir);
227
+ if (fs.existsSync(gPath)) {
228
+ cfg = deepMerge(cfg, withPaths(readYaml(gPath), gDir));
211
229
  }
212
230
  const local = readYaml(resolved);
213
231
  cfg = deepMerge(cfg, withPaths(local, localDir));
@@ -369,6 +387,17 @@ function validate(cfg) {
369
387
  if (st !== "NodePort" && st !== "LoadBalancer") {
370
388
  throw new Error("config: k8s.serviceType must be NodePort or LoadBalancer");
371
389
  }
390
+ if (cfg.k8s.nodeName !== undefined && cfg.k8s.nodeName !== "") {
391
+ if (
392
+ typeof cfg.k8s.nodeName !== "string" ||
393
+ cfg.k8s.nodeName.length > 253 ||
394
+ !/^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$/.test(cfg.k8s.nodeName)
395
+ ) {
396
+ throw new Error(
397
+ `config: k8s.nodeName must be a valid node name (lowercase a-z/0-9/-/. , max 253 chars): ${JSON.stringify(cfg.k8s.nodeName)}`
398
+ );
399
+ }
400
+ }
372
401
  for (const field of ["env", "secretEnv"]) {
373
402
  const map = cfg.k8s[field];
374
403
  if (map == null) continue;
package/src/index.js CHANGED
@@ -25,6 +25,13 @@ import {
25
25
  import { ensureKeyPair } from "./utils/keys.js";
26
26
  import { ensureProjectConfig } from "./utils/project.js";
27
27
  import { resolveDeployTag } from "./utils/system-config.js";
28
+ import {
29
+ defaultProjectsFile,
30
+ readProjects,
31
+ resolveProjectRefOrThrow,
32
+ resolveStoredConfigPath,
33
+ } from "./utils/projects.js";
34
+ import { listProjects } from "./commands/projects.js";
28
35
 
29
36
  const program = new Command();
30
37
 
@@ -40,15 +47,43 @@ program
40
47
  // not swallowed by the global --tag.
41
48
  .enablePositionalOptions()
42
49
  .option("-c, --config <file>", "path to config yaml (default: ./config.yaml if present, else the repo's)")
43
- .option("-p, --project <name>", "override the project name")
50
+ .option("-p, --project <slug|name|id>", "select a project from the shared registry (see 'projects list')")
51
+ .option("--project-name <name>", "override the project name")
44
52
  .option("--tag <tag>", "override the image tag");
45
53
 
54
+ /**
55
+ * Which local config file to load: an explicit -c wins, else a -p/--project
56
+ * registry entry (same store the GUI uses), else ./config.yaml or the repo's.
57
+ * -c and -p together are an error, never a guess.
58
+ */
59
+ function resolveSelectedConfigPath(globalOpts) {
60
+ if (globalOpts.config && globalOpts.project) {
61
+ throw new Error("Use either -c/--config or -p/--project, not both.");
62
+ }
63
+ if (globalOpts.project) {
64
+ const projectsFile = defaultProjectsFile();
65
+ const projects = readProjects(projectsFile);
66
+ const { project } = resolveProjectRefOrThrow(projects, globalOpts.project, projectsFile);
67
+ const configPath = resolveStoredConfigPath(project.configPath);
68
+ if (!configPath) {
69
+ throw new Error(`Project "${globalOpts.project}" has no configPath in ${projectsFile}.`);
70
+ }
71
+ if (!fs.existsSync(configPath)) {
72
+ throw new Error(
73
+ `Project "${globalOpts.project}" config not found: ${configPath}\nRun "coding-container projects list" to see all projects.`
74
+ );
75
+ }
76
+ return configPath;
77
+ }
78
+ return resolveDefaultConfigPath(globalOpts.config);
79
+ }
80
+
46
81
  function cfgFrom(ctx) {
47
82
  const globalOpts = program.opts();
48
83
  const overrides = {};
49
- if (globalOpts.project) overrides.project = globalOpts.project;
84
+ if (globalOpts.projectName) overrides.project = globalOpts.projectName;
50
85
  if (globalOpts.tag) overrides.image = { tag: globalOpts.tag };
51
- const cfg = loadConfig(resolveDefaultConfigPath(globalOpts.config), overrides);
86
+ const cfg = loadConfig(resolveSelectedConfigPath(globalOpts), overrides);
52
87
  // First-run nudge: shown once, on stderr, until the global config exists.
53
88
  if (process.argv[2] !== "setup" && !fs.existsSync(globalConfigPath())) {
54
89
  console.error(
@@ -78,14 +113,14 @@ program
78
113
  .option("--skip-push", "skip the docker push step")
79
114
  .action(async (opts) => {
80
115
  const globalOpts = program.opts();
81
- // No explicit -c and no ./config.yaml: scaffold one (project name from
82
- // the folder, first free NodePort) and proceed with it.
83
- if (!globalOpts.config) {
116
+ // No explicit -c/-p and no ./config.yaml: scaffold one (project name from
117
+ // the folder or --project-name, first free NodePort) and proceed with it.
118
+ if (!globalOpts.config && !globalOpts.project) {
84
119
  const cwdConfig = path.resolve(process.cwd(), "config.yaml");
85
120
  if (!fs.existsSync(cwdConfig)) {
86
121
  const { created, project, nodePort } = ensureProjectConfig(
87
122
  process.cwd(),
88
- globalOpts.project
123
+ globalOpts.projectName
89
124
  );
90
125
  if (created) {
91
126
  console.log(
@@ -152,6 +187,18 @@ list
152
187
  await listUpdateSsh({ dryRun: opts.dryRun });
153
188
  });
154
189
 
190
+ const projects = program
191
+ .command("projects")
192
+ .description("work with the shared project registry (same store the GUI uses)");
193
+
194
+ projects
195
+ .command("list")
196
+ .description("list registered projects (any SLUG, NAME or ID works with -p/--project)")
197
+ .option("--json", "print the registry as JSON (includes slugs and file-exists flags)")
198
+ .action((opts) => {
199
+ listProjects({ json: opts.json });
200
+ });
201
+
155
202
  program
156
203
  .command("delete")
157
204
  .alias("destroy")
@@ -264,10 +311,12 @@ system
264
311
  .description(
265
312
  "build + push a new global version and repoint current (the only writer of the global image)"
266
313
  )
267
- .requiredOption("--tag <tag>", "new version (semver vX.Y.Z, must not exist and must be > maxEver)")
314
+ .option("--tag <tag>", "new version (semver vX.Y.Z, must not exist and must be > maxEver; default: maxEver patch +1)")
268
315
  .action(async (opts) => {
269
316
  const cfg = cfgFrom();
270
- await systemCreate(cfg, { tag: opts.tag });
317
+ // Global --tag (before `system`) also works: `coding-container --tag vX.Y.Z system create`.
318
+ const tag = opts.tag ?? program.opts().tag;
319
+ await systemCreate(cfg, { tag });
271
320
  });
272
321
 
273
322
  system
@@ -0,0 +1,85 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+ // Checkout root (PROJECT_FOLDER): src/utils -> src -> root.
7
+ export const INSTANCE_ROOT = path.resolve(__dirname, "..", "..");
8
+
9
+ export const DEFAULT_CONFIG_LEAF = "coding-container";
10
+ export const DEFAULT_SSH_PREFIX = "coding-";
11
+ export const DEFAULT_NAMESPACE = "coding";
12
+
13
+ let loaded = false;
14
+
15
+ /**
16
+ * Minimal .env parser (no dependency): KEY=VALUE lines, optional `export`
17
+ * prefix, `#` comments, single/double-quoted values. Real environment wins:
18
+ * keys already in process.env are never overwritten. Loads once from
19
+ * PROJECT_FOLDER/.env (the checkout root), so each repeated folder carries
20
+ * its own dev/prod identity no matter where commands run from.
21
+ */
22
+ export function loadInstanceEnv(root = INSTANCE_ROOT) {
23
+ if (loaded) return;
24
+ loaded = true;
25
+ let text;
26
+ try {
27
+ text = fs.readFileSync(path.join(root, ".env"), "utf8");
28
+ } catch {
29
+ return;
30
+ }
31
+ for (const rawLine of text.split(/\r?\n/)) {
32
+ const line = rawLine.trim();
33
+ if (!line || line.startsWith("#")) continue;
34
+ const m = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
35
+ if (!m) continue;
36
+ const [, key, rest] = m;
37
+ if (key in process.env) continue;
38
+ process.env[key] = unquote(rest.trim());
39
+ }
40
+ }
41
+
42
+ // Test-only: allow re-loading after env swaps.
43
+ export function resetInstanceEnvForTests() {
44
+ loaded = false;
45
+ }
46
+
47
+ function unquote(v) {
48
+ if (v.length >= 2 && ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))) {
49
+ const inner = v.slice(1, -1);
50
+ // Double-quoted: honor common escapes; single-quoted: literal.
51
+ return v.startsWith('"') ? inner.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, "\t").replace(/\\"/g, '"').replace(/\\\\/g, "\\") : inner;
52
+ }
53
+ const comment = v.search(/(^|\s)#/);
54
+ return (comment === -1 ? v : v.slice(0, comment).trim());
55
+ }
56
+
57
+ /** Global config dir leaf: CODING_CONFIG_LEAF, default "coding-container". */
58
+ export function configDirLeaf() {
59
+ loadInstanceEnv();
60
+ const v = (process.env.CODING_CONFIG_LEAF ?? "").trim() || DEFAULT_CONFIG_LEAF;
61
+ if (!/^[A-Za-z0-9._-]+$/.test(v) || v === "." || v === "..") {
62
+ throw new Error(`config: CODING_CONFIG_LEAF must be a plain folder name (no slashes): ${JSON.stringify(v)}`);
63
+ }
64
+ return v;
65
+ }
66
+
67
+ /** SSH alias prefix: CODING_SSH_PREFIX, default "coding-". */
68
+ export function sshPrefix() {
69
+ loadInstanceEnv();
70
+ const v = (process.env.CODING_SSH_PREFIX ?? "").trim() || DEFAULT_SSH_PREFIX;
71
+ if (!/^[a-z0-9-]+$/.test(v) || !v.endsWith("-") || v.length > 32) {
72
+ throw new Error(`config: CODING_SSH_PREFIX must be lowercase alnum/dashes ending in '-': ${JSON.stringify(v)}`);
73
+ }
74
+ return v;
75
+ }
76
+
77
+ /** Default k8s namespace: CODING_NAMESPACE, default "coding". Config files still override it. */
78
+ export function defaultNamespace() {
79
+ loadInstanceEnv();
80
+ const v = (process.env.CODING_NAMESPACE ?? "").trim() || DEFAULT_NAMESPACE;
81
+ if (v.length > 63 || !/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/.test(v)) {
82
+ throw new Error(`config: CODING_NAMESPACE must be a valid k8s name (lowercase alphanum and '-'): ${v}`);
83
+ }
84
+ return v;
85
+ }
@@ -227,6 +227,9 @@ export function deploymentManifest(cfg, pullSecret = null) {
227
227
  },
228
228
  spec: {
229
229
  ...(pullSecret ? { imagePullSecrets: [{ name: pullSecret }] } : {}),
230
+ // Pin the pod to one named cluster node when k8s.nodeName is set.
231
+ // Empty/unset -> the scheduler picks a node as before.
232
+ ...(cfg.k8s?.nodeName ? { nodeName: cfg.k8s.nodeName } : {}),
230
233
  containers: [container, ...sidecarContainers(cfg)],
231
234
  volumes: [
232
235
  {
@@ -0,0 +1,74 @@
1
+ import { exec } from "./exec.js";
2
+ import { kubectlExec } from "./system-config.js";
3
+
4
+ /**
5
+ * Parse `kubectl get nodes -o json` into [{ name, internalIP, ready }].
6
+ * Unknown/missing fields stay undefined/false (never throws on shape).
7
+ */
8
+ export function parseNodesJson(jsonStr) {
9
+ const { items = [] } = JSON.parse(jsonStr);
10
+ return items.map((n) => {
11
+ const addresses = n.status?.addresses || [];
12
+ const internalIP = (addresses.find((a) => a.type === "InternalIP") || {}).address;
13
+ const readyCond = (n.status?.conditions || []).find((c) => c.type === "Ready");
14
+ return {
15
+ name: n.metadata?.name,
16
+ internalIP,
17
+ ready: readyCond?.status === "True",
18
+ };
19
+ });
20
+ }
21
+
22
+ /**
23
+ * Parse `kubectl get pods -o json` into [{ name, phase, hostIP, nodeName }].
24
+ */
25
+ export function parsePodsJson(jsonStr) {
26
+ const { items = [] } = JSON.parse(jsonStr);
27
+ return items.map((p) => ({
28
+ name: p.metadata?.name,
29
+ phase: p.status?.phase,
30
+ hostIP: p.status?.hostIP,
31
+ nodeName: p.spec?.nodeName,
32
+ }));
33
+ }
34
+
35
+ /**
36
+ * Pick the SSH NodePort IP: the pod's own hostIP when that node is Ready,
37
+ * else the first Ready node with an InternalIP. Returns null when no Ready
38
+ * node exists (caller warns instead of writing a dead alias).
39
+ */
40
+ export function pickNodeIp(nodes, podHostIP = null) {
41
+ const ready = nodes.filter((n) => n.ready && n.internalIP);
42
+ if (!ready.length) return null;
43
+ if (podHostIP && ready.some((n) => n.internalIP === podHostIP)) return podHostIP;
44
+ return ready[0].internalIP;
45
+ }
46
+
47
+ /**
48
+ * Resolve where the project actually runs: ask the cluster for this
49
+ * project's pods, then map the Running pod's hostIP onto the Ready nodes.
50
+ * Falls back to the first Ready node when the pod list is empty/pending;
51
+ * returns null when the cluster is unreachable or nothing is Ready (so
52
+ * deploy can warn and skip the SSH alias instead of writing a dead IP).
53
+ *
54
+ * Uses `-o json` (not jsonpath) so Windows script stubs don't hit shell
55
+ * quoting issues.
56
+ */
57
+ export function resolveSshNodeIp(cfg) {
58
+ const { cmd, args, opts } = kubectlExec();
59
+ let podHostIP = null;
60
+ try {
61
+ const out = exec(cmd, [...args, "get", "pods", "-n", cfg.k8s.namespace, "-l", `app=${cfg.project}`, "-o", "json"], opts);
62
+ const pods = parsePodsJson(out);
63
+ const running = pods.find((p) => p.phase === "Running" && p.hostIP) || pods.find((p) => p.hostIP);
64
+ podHostIP = running?.hostIP ?? null;
65
+ } catch {
66
+ /* pods unreadable -> Ready-node fallback below */
67
+ }
68
+ try {
69
+ const out = exec(cmd, [...args, "get", "nodes", "-o", "json"], opts);
70
+ return pickNodeIp(parseNodesJson(out), podHostIP);
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
@@ -0,0 +1,218 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { PROJECT_ROOT, globalConfigDir } from "../config.js";
4
+
5
+ /**
6
+ * Shared projects registry (the GUI datastore), reused by the CLI.
7
+ *
8
+ * Store shape: JSON array of { id, name, configPath } at
9
+ * <globalConfigDir()>/projects.json
10
+ * unless PROJECTS_FILE overrides it (absolute, or relative to the repo root).
11
+ * Managed per-project configs live under <globalConfigDir()>/projects/<slug>/.
12
+ */
13
+
14
+ /** Resolve an explicit PROJECTS_FILE value to an absolute path. */
15
+ export function resolveProjectsFile(raw) {
16
+ const value = raw && String(raw).trim() ? String(raw).trim() : "";
17
+ if (!value) return path.join(globalConfigDir(), "projects.json");
18
+ return path.isAbsolute(value) ? value : path.join(PROJECT_ROOT, value);
19
+ }
20
+
21
+ /** Default registry path: PROJECTS_FILE env wins, else next to global config. */
22
+ export function defaultProjectsFile() {
23
+ // Touch the global dir first so a PROJECTS_FILE set in the checkout's .env
24
+ // is loaded into process.env before reading it — matching the GUI server,
25
+ // which reads the same .env file directly.
26
+ globalConfigDir();
27
+ return resolveProjectsFile(process.env.PROJECTS_FILE ?? "");
28
+ }
29
+
30
+ export function readProjects(projectsFile) {
31
+ const file = projectsFile ?? defaultProjectsFile();
32
+ try {
33
+ if (!fs.existsSync(file)) return [];
34
+ const data = JSON.parse(fs.readFileSync(file, "utf8"));
35
+ return Array.isArray(data) ? data : [];
36
+ } catch {
37
+ return [];
38
+ }
39
+ }
40
+
41
+ export function writeProjects(projectsFile, projects) {
42
+ fs.mkdirSync(path.dirname(projectsFile), { recursive: true });
43
+ fs.writeFileSync(projectsFile, `${JSON.stringify(projects, null, 2)}\n`);
44
+ }
45
+
46
+ export function resolveStoredConfigPath(configPath) {
47
+ const raw = String(configPath ?? "").trim();
48
+ if (!raw) return null;
49
+ return path.isAbsolute(raw) ? raw : path.join(PROJECT_ROOT, raw);
50
+ }
51
+
52
+ /**
53
+ * Filesystem + k8s-safe slug from a display name (DNS-1123 label style).
54
+ * Mirrors deriveProjectName(): lowercase, non [a-z0-9-] -> "-", collapse,
55
+ * trim dashes, cap at 63 chars, fallback to "project".
56
+ */
57
+ export function slugifyProjectName(name) {
58
+ let slug = String(name ?? "")
59
+ .toLowerCase()
60
+ .replace(/[^a-z0-9-]+/g, "-")
61
+ .replace(/-+/g, "-")
62
+ .replace(/^-+|-+$/g, "");
63
+ if (!slug) slug = "project";
64
+ if (slug.length > 63) slug = slug.slice(0, 63).replace(/-+$/g, "") || "project";
65
+ return slug;
66
+ }
67
+
68
+ /**
69
+ * Display slugs for every registry entry, in registry order. The slug tracks
70
+ * the current name; collisions get -2, -3, ... suffixes.
71
+ * Returns [{ project, slug }].
72
+ */
73
+ export function computeProjectSlugs(projects) {
74
+ const used = new Set();
75
+ const out = [];
76
+ for (const project of projects ?? []) {
77
+ const base = slugifyProjectName(project?.name);
78
+ let slug = base;
79
+ let n = 1;
80
+ while (used.has(slug)) {
81
+ n += 1;
82
+ slug = `${base}-${n}`;
83
+ }
84
+ used.add(slug);
85
+ out.push({ project, slug });
86
+ }
87
+ return out;
88
+ }
89
+
90
+ /** Folder holding auto-managed per-project configs: <globalDir>/projects. */
91
+ export function managedProjectsDir() {
92
+ return path.join(globalConfigDir(), "projects");
93
+ }
94
+
95
+ /** Default config file for an auto-managed project slug. */
96
+ export function defaultProjectConfigPath(slug) {
97
+ return path.join(managedProjectsDir(), slug, "config.yaml");
98
+ }
99
+
100
+ /**
101
+ * True when configPath lives inside the managed projects dir (i.e. it was
102
+ * auto-assigned, not a user-supplied custom path). Such paths follow renames.
103
+ */
104
+ export function isManagedConfigPath(configPath) {
105
+ const resolved = path.resolve(String(configPath ?? ""));
106
+ const base = path.resolve(managedProjectsDir());
107
+ const rel = path.relative(base, resolved);
108
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
109
+ }
110
+
111
+ /**
112
+ * Unique managed config path for a base slug. Suffixes -2, -3, ... while the
113
+ * candidate is taken by another registry entry (excluding excludeId) or an
114
+ * existing file on disk (e.g. leftovers from a removed entry).
115
+ */
116
+ export function uniqueManagedConfigPath(projects, baseSlug, excludeId = null) {
117
+ const takenPaths = new Set(
118
+ (projects ?? [])
119
+ .filter((p) => p.id !== excludeId)
120
+ .map((p) => path.resolve(String(p.configPath ?? "")))
121
+ );
122
+ let slug = baseSlug;
123
+ let n = 1;
124
+ while (true) {
125
+ const candidate = defaultProjectConfigPath(slug);
126
+ const resolved = path.resolve(candidate);
127
+ if (!takenPaths.has(resolved) && !fs.existsSync(candidate)) return { slug, configPath: candidate };
128
+ n += 1;
129
+ slug = `${baseSlug}-${n}`;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * One-line summary used in "unknown project" errors.
135
+ * `entries` is the result of computeProjectSlugs().
136
+ */
137
+ function availableProjectsHint(entries, projectsFile) {
138
+ if (!entries || entries.length === 0) {
139
+ return `No projects registered in ${projectsFile}.`;
140
+ }
141
+ const lines = entries.map(({ project, slug }) => ` ${slug} ("${project?.name ?? ""}")`);
142
+ return `Available projects (slug, name or id):\n${lines.join("\n")}`;
143
+ }
144
+
145
+ /**
146
+ * Match a user-supplied ref against the registry. Ref may be a display slug,
147
+ * a name, or an id (exact or unique prefix >= 4 chars).
148
+ * Returns { project, slug } or null when nothing matches.
149
+ * Throws on ambiguous names/prefixes.
150
+ */
151
+ export function findProjectByRef(projects, ref) {
152
+ const needle = String(ref ?? "").trim();
153
+ if (!needle) return null;
154
+ const list = projects ?? [];
155
+ const entries = computeProjectSlugs(list);
156
+
157
+ const byId = list.find((p) => p?.id === needle);
158
+ if (byId) {
159
+ const slug = entries.find((e) => e.project === byId)?.slug ?? slugifyProjectName(byId.name);
160
+ return { project: byId, slug };
161
+ }
162
+
163
+ const lowered = needle.toLowerCase();
164
+ const slugHits = entries.filter((e) => e.slug.toLowerCase() === lowered);
165
+ if (slugHits.length === 1) return slugHits[0];
166
+ if (slugHits.length > 1) {
167
+ throw new Error(
168
+ `Ambiguous project "${needle}" matches several slugs: ${slugHits.map((e) => e.slug).join(", ")}`
169
+ );
170
+ }
171
+
172
+ const exactName = entries.filter((e) => String(e.project?.name ?? "") === needle);
173
+ if (exactName.length === 1) return exactName[0];
174
+ if (exactName.length > 1) {
175
+ throw new Error(
176
+ `Ambiguous project name "${needle}" matches ${exactName.length} projects; use the slug or id instead.`
177
+ );
178
+ }
179
+
180
+ const ciName = entries.filter(
181
+ (e) => String(e.project?.name ?? "").toLowerCase() === lowered
182
+ );
183
+ if (ciName.length === 1) return ciName[0];
184
+ if (ciName.length > 1) {
185
+ throw new Error(
186
+ `Ambiguous project name "${needle}" matches ${ciName.length} projects; use the slug or id instead.`
187
+ );
188
+ }
189
+
190
+ if (needle.length >= 4) {
191
+ const prefix = list.filter((p) => typeof p?.id === "string" && p.id.startsWith(needle));
192
+ if (prefix.length === 1) {
193
+ const project = prefix[0];
194
+ const slug = entries.find((e) => e.project === project)?.slug ?? slugifyProjectName(project.name);
195
+ return { project, slug };
196
+ }
197
+ if (prefix.length > 1) {
198
+ throw new Error(
199
+ `Ambiguous project id prefix "${needle}" matches ${prefix.length} projects; use the full id.`
200
+ );
201
+ }
202
+ }
203
+
204
+ return null;
205
+ }
206
+
207
+ /**
208
+ * Like findProjectByRef, but throws a copy-paste friendly error naming the
209
+ * registry file and every available slug when nothing matches.
210
+ */
211
+ export function resolveProjectRefOrThrow(projects, ref, projectsFile) {
212
+ const match = findProjectByRef(projects, ref);
213
+ if (match) return match;
214
+ const entries = computeProjectSlugs(projects ?? []);
215
+ throw new Error(
216
+ `Unknown project "${String(ref ?? "").trim()}".\n${availableProjectsHint(entries, projectsFile)}\nRun "coding-container projects list" to see all projects.`
217
+ );
218
+ }
@@ -186,7 +186,7 @@ async function send(cfg, method, path, authHeader, extraHeaders = {}) {
186
186
  * Authorization header, and on 401 follow the WWW-Authenticate challenge
187
187
  * (Bearer token exchange or plain Basic) with the local docker credentials.
188
188
  */
189
- async function apiRequest(cfg, method, path, extraHeaders = {}) {
189
+ async function apiRequest(cfg, method, path, extraHeaders = {}, scopeOverride = null) {
190
190
  let res = await send(cfg, method, path, state.authHeader, extraHeaders);
191
191
  if (res.status !== 401) return res;
192
192
 
@@ -214,7 +214,7 @@ async function apiRequest(cfg, method, path, extraHeaders = {}) {
214
214
  authHeader = basic;
215
215
  } else if (challenge.type === "bearer") {
216
216
  const repo = registryRepoName(cfg);
217
- const scope = challenge.scope || `repository:${repo}:pull,push`;
217
+ const scope = scopeOverride || challenge.scope || `repository:${repo}:pull,push`;
218
218
  const key = `${challenge.realm}|${challenge.service || ""}|${scope}`;
219
219
  let token = state.tokens.get(key);
220
220
  if (!token) {
@@ -287,12 +287,67 @@ export async function listTags(cfg) {
287
287
  }
288
288
 
289
289
  /**
290
- * Delete one tag. Returns normally on success (2xx); throws otherwise.
290
+ * Delete one tag. Uses the standard Registry V2 / OCI flow:
291
+ * GET /v2/<repo>/manifests/<tag> (with a manifest Accept header) to
292
+ * resolve the Docker-Content-Digest, then
293
+ * DELETE /v2/<repo>/manifests/<digest>.
294
+ * Gitea/Forgejo also accepts DELETE .../manifests/<tag> directly, so when
295
+ * the digest lookup fails we fall back to deleting by tag reference.
296
+ * Returns normally on success (2xx); throws otherwise.
291
297
  */
292
298
  export async function deleteTag(cfg, tag) {
293
299
  const repo = registryRepoName(cfg);
294
300
  const host = registryHost(cfg.image.registry);
295
- const res = await apiRequest(cfg, "DELETE", `/v2/${repo}/tags/${encodeURIComponent(tag)}`);
301
+ const ref = encodeURIComponent(tag);
302
+ const deleteScope = `repository:${repo}:pull,push,delete`;
303
+ const manifestAccept = [
304
+ "application/vnd.docker.distribution.manifest.v2+json",
305
+ "application/vnd.docker.distribution.manifest.list.v2+json",
306
+ "application/vnd.oci.image.manifest.v1+json",
307
+ "application/vnd.oci.image.index.v1+json",
308
+ ].join(", ");
309
+
310
+ // Resolve the manifest digest for the tag. A 404 here means the tag is
311
+ // already gone.
312
+ const manifestRes = await apiRequest(
313
+ cfg,
314
+ "GET",
315
+ `/v2/${repo}/manifests/${ref}`,
316
+ { Accept: manifestAccept },
317
+ deleteScope
318
+ );
319
+ let digest = manifestRes.headers.get("docker-content-digest");
320
+ if (manifestRes.status === 404) {
321
+ throw new Error(`tag ${tag} not found (already deleted?)`);
322
+ }
323
+ if (!manifestRes.ok || !digest) {
324
+ // Registry did not return a digest (e.g. it rejects GET manifests by
325
+ // tag): try deleting the tag reference directly (Gitea supports
326
+ // DELETE .../manifests/<tag>).
327
+ const direct = await apiRequest(
328
+ cfg,
329
+ "DELETE",
330
+ `/v2/${repo}/manifests/${ref}`,
331
+ {},
332
+ deleteScope
333
+ );
334
+ if (direct.status === 404) {
335
+ throw new Error(`tag ${tag} not found (already deleted?)`);
336
+ }
337
+ if (direct.status === 405) {
338
+ throw new Error(
339
+ `registry ${host} does not support deleting tags through the registry API (405) — ` +
340
+ `e.g. AWS ECR requires the AWS BatchDeleteImage API instead`
341
+ );
342
+ }
343
+ if (!direct.ok) {
344
+ throw new Error(`deleting tag ${tag} failed with ${direct.status} ${direct.statusText}`.trim());
345
+ }
346
+ return;
347
+ }
348
+
349
+ digest = digest.trim();
350
+ const res = await apiRequest(cfg, "DELETE", `/v2/${repo}/manifests/${digest}`, {}, deleteScope);
296
351
  if (res.status === 404) {
297
352
  throw new Error(`tag ${tag} not found (already deleted?)`);
298
353
  }
@@ -19,6 +19,17 @@ export function compareSemver(a, b) {
19
19
  return 0;
20
20
  }
21
21
 
22
+ /**
23
+ * Patch-bump a semver tag (vX.Y.Z -> vX.Y.Z+1). Assumes the input is semver.
24
+ */
25
+ export function bumpPatch(tag) {
26
+ const m = typeof tag === "string" && tag.match(SEMVER_RE);
27
+ if (!m) {
28
+ throw new Error(`cannot bump patch: not a semver tag (vX.Y.Z), got: ${tag}`);
29
+ }
30
+ return `v${m[1]}.${m[2]}.${Number(m[3]) + 1}`;
31
+ }
32
+
22
33
  /**
23
34
  * The semver tags of a list, sorted ascending (oldest first). Non-semver
24
35
  * tags (e.g. legacy timestamp tags) are excluded.
@@ -11,11 +11,29 @@ export function sshConfigPath() {
11
11
  return path.join(os.homedir(), ".ssh", "config");
12
12
  }
13
13
 
14
+ import { sshPrefix } from "./instance.js";
15
+
14
16
  /**
15
- * Alias convention for container hosts: coding-<project>.
17
+ * Alias convention for container hosts: <prefix><project> (default
18
+ * "coding-<project>"; CODING_SSH_PREFIX in PROJECT_FOLDER/.env overrides
19
+ * the prefix so dev/prod copies don't overwrite each other's aliases).
16
20
  */
17
21
  export function sshAlias(project) {
18
- return `coding-${project}`;
22
+ return `${sshPrefix()}${project}`;
23
+ }
24
+
25
+ /**
26
+ * Inverse of sshAlias(): strip this instance's prefix. Returns null when the
27
+ * alias belongs to a sibling instance sharing the same managed block, so
28
+ * `list update` leaves other copies' aliases untouched (each copy manages
29
+ * only its own entries; the sibling's own runs update those).
30
+ */
31
+ export function sshProjectFromAlias(alias) {
32
+ const prefix = sshPrefix();
33
+ if (alias.startsWith(prefix) && alias.length > prefix.length) {
34
+ return alias.slice(prefix.length);
35
+ }
36
+ return null;
19
37
  }
20
38
 
21
39
  /**
@@ -64,7 +82,9 @@ function readManaged(file) {
64
82
  const entries = {};
65
83
  let current = null;
66
84
  for (const line of lines.slice(bi + 1, ei)) {
67
- const m = line.match(/^Host (coding-[a-z0-9-]+)\s*$/);
85
+ // Shared managed block: accept any Host alias inside the markers so
86
+ // sibling instances (different CODING_SSH_PREFIX) coexist.
87
+ const m = line.match(/^Host ([A-Za-z0-9._-]+)\s*$/);
68
88
  if (m) {
69
89
  current = m[1];
70
90
  entries[current] = [line];