@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/Dockerfile ADDED
@@ -0,0 +1,126 @@
1
+ # syntax=docker/dockerfile:1
2
+ #
3
+ # Coding container: Ubuntu + Node.js + opencode + pi + herdr + playwright + ssh
4
+ #
5
+ # Adjustable via build args (see config.yaml -> image.buildArgs):
6
+ # BASE_IMAGE Ubuntu base image
7
+ # NODE_VERSION Major Node.js version installed via NodeSource
8
+ # EXTRA_APT_PACKAGES Space separated list of extra apt packages
9
+ # EXTRA_NPM_PACKAGES Space separated list of extra global npm packages
10
+ # INSTALL_PLAYWRIGHT "true" (default) or "false"
11
+
12
+ ARG BASE_IMAGE=ubuntu:26.04
13
+ FROM ${BASE_IMAGE}
14
+
15
+ ARG NODE_VERSION=22
16
+ ARG EXTRA_APT_PACKAGES=""
17
+ ARG EXTRA_NPM_PACKAGES=""
18
+ ARG INSTALL_PLAYWRIGHT=true
19
+
20
+ ENV DEBIAN_FRONTEND=noninteractive \
21
+ PLAYWRIGHT_BROWSERS_PATH=/usr/lib/playwright-browsers
22
+
23
+ # --- Base tooling -----------------------------------------------------------
24
+ RUN apt-get update && apt-get install -y --no-install-recommends \
25
+ ca-certificates curl wget gnupg git \
26
+ openssh-server \
27
+ build-essential \
28
+ htop vim nano less tree unzip zip rsync \
29
+ jq fd-find ripgrep \
30
+ iputils-ping bind9-dnsutils \
31
+ ${EXTRA_APT_PACKAGES} \
32
+ && ( [ -x /usr/bin/fd-find ] && ln -sf /usr/bin/fd-find /usr/local/bin/fd \
33
+ || ln -sf /usr/bin/fdfind /usr/local/bin/fd ) \
34
+ && rm -rf /var/lib/apt/lists/*
35
+
36
+ # --- Node.js (NodeSource) ---------------------------------------------------
37
+ RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - \
38
+ && apt-get install -y --no-install-recommends nodejs \
39
+ && rm -rf /var/lib/apt/lists/*
40
+
41
+ # --- Package managers -------------------------------------------------------
42
+ # corepack exposes pnpm/yarn (and npm) shims that honor each repo's
43
+ # packageManager pin (e.g. "pnpm@11.6.0"); a repo pin not matching the
44
+ # pre-fetched versions is still downloaded on first use. Pre-fetch the
45
+ # current pnpm/yarn (incl. pnpm's native binary, pulled on first run) at
46
+ # build time so first use inside a pod is offline-fast.
47
+ RUN corepack enable \
48
+ && corepack prepare pnpm@latest --activate \
49
+ && corepack prepare yarn@latest --activate \
50
+ && pnpm --version >/dev/null \
51
+ && yarn --version >/dev/null
52
+
53
+ # --- Native module build headers ---------------------------------------------
54
+ # Headers for node-gyp / prebuild-install so native npm packages (sharp,
55
+ # bcrypt, sqlite3, ...) compile at npm install time instead of failing.
56
+ RUN apt-get update && apt-get install -y --no-install-recommends \
57
+ pkg-config \
58
+ zlib1g-dev libssl-dev libpq-dev libsqlite3-dev libffi-dev \
59
+ libxml2-dev libxslt1-dev libpng-dev libbz2-dev liblzma-dev \
60
+ uuid-dev libkrb5-dev libcairo2-dev \
61
+ && rm -rf /var/lib/apt/lists/*
62
+
63
+ # --- Coding agents / CLIs ---------------------------------------------------
64
+ # opencode (installer adds PATH for interactive shells only; ENV + profile.d
65
+ # cover non-interactive shells and login shells)
66
+ RUN curl -fsSL https://opencode.ai/install | bash
67
+ ENV PATH="/root/.opencode/bin:${PATH}"
68
+ RUN printf 'export PATH="/root/.opencode/bin:$PATH"\n' > /etc/profile.d/opencode.sh
69
+ # pi (pi.dev)
70
+ RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
71
+ # herdr (https://herdr.dev) - terminal multiplexer/runtime for coding agents.
72
+ # Install to /usr/local/bin so it's on PATH for all shells incl. sshd sessions.
73
+ RUN curl -fsSL https://herdr.dev/install.sh | HERDR_INSTALL_DIR=/usr/local/bin sh \
74
+ && herdr --version
75
+ # turbo (monorepo runner)
76
+ RUN npm install -g turbo
77
+ # extra global npm packages
78
+ RUN if [ -n "${EXTRA_NPM_PACKAGES}" ]; then npm install -g ${EXTRA_NPM_PACKAGES}; fi
79
+
80
+ # --- Playwright -------------------------------------------------------------
81
+ RUN if [ "${INSTALL_PLAYWRIGHT}" = "true" ]; then \
82
+ npm install -g playwright \
83
+ && npx playwright install --with-deps chrome; \
84
+ fi
85
+
86
+ # --- Database clients -------------------------------------------------------
87
+ RUN apt-get update && apt-get install -y --no-install-recommends \
88
+ postgresql-client \
89
+ && rm -rf /var/lib/apt/lists/*
90
+
91
+ # --- SSH server -------------------------------------------------------------
92
+ RUN mkdir -p /root/.ssh && \
93
+ chmod 700 /root/.ssh && \
94
+ touch /root/.ssh/authorized_keys && \
95
+ chmod 600 /root/.ssh/authorized_keys && \
96
+ sed -ri 's/^#?PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config && \
97
+ sed -ri 's/^#?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config && \
98
+ sed -ri 's/^#?PubkeyAuthentication.*/PubkeyAuthentication yes/' /etc/ssh/sshd_config && \
99
+ ssh-keygen -A
100
+ # /run may be a fresh tmpfs at start, so recreate the privsep dir at boot.
101
+ # sshd builds each session's environment from scratch (USER/HOME/PATH/SHELL +
102
+ # PAM), so the container's env (image ENV + pod spec) is not visible in SSH
103
+ # sessions; the entrypoint republishes it for login shells.
104
+ RUN cat > /entrypoint.sh <<'ENTRYPOINT' && chmod +x /entrypoint.sh
105
+ #!/bin/bash
106
+ mkdir -p /run/sshd
107
+ : > /etc/profile.d/99-container-env.sh
108
+ while IFS= read -r -d '' kv; do
109
+ name=${kv%%=*}
110
+ value=${kv#*=}
111
+ escaped=${value//"'"/"'\\''"}
112
+ printf "export %s='%s'\n" "$name" "$escaped"
113
+ done < /proc/self/environ >> /etc/profile.d/99-container-env.sh
114
+ exec /usr/sbin/sshd -D "$@"
115
+ ENTRYPOINT
116
+
117
+ # --- Workspace --------------------------------------------------------------
118
+ RUN mkdir -p /workspace
119
+ WORKDIR /workspace
120
+ # Land in /workspace on every interactive login
121
+ RUN printf '\ncd /workspace 2>/dev/null || cd ~\n' >> /root/.bashrc \
122
+ && printf '\ncd /workspace 2>/dev/null || cd ~\n' >> /root/.profile
123
+
124
+ EXPOSE 22
125
+
126
+ ENTRYPOINT ["/entrypoint.sh"]
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Esuyo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # Esuyo coding-container
2
+
3
+ > Spin up a ready-to-code SSH container on your k3s cluster in one command.
4
+
5
+ `coding-container` gives every project its own consistent coding environment. One command deploys a container with Ubuntu 26.04, Node.js 22, [opencode](https://opencode.ai), [pi](https://pi.dev), [herdr](https://herdr.dev), turbo, Playwright, and common dev tools (plus pnpm/yarn via corepack and a Postgres client) to your k3s cluster. You SSH in and land in `/workspace` — a folder backed by your NAS, so your work survives restarts and stays separate per project.
6
+
7
+ ## Features
8
+
9
+ - One command to deploy a ready-to-code container (`create`), SSH in (`ssh`), and tear down (`delete`)
10
+ - Persistent per-project workspace on your NAS — files survive restarts
11
+ - Generates a dedicated SSH key; VS Code Remote-SSH ready
12
+ - Optional browser access to dev servers and sidecars for databases and queues
13
+ - Shared team image with versioning, or a custom image per project
14
+
15
+ ## Prerequisites
16
+
17
+ - Node.js >= 18, Docker, `kubectl` pointed at your k3s cluster
18
+ - `docker login` to your container registry
19
+ - OpenSSH (`ssh`, `ssh-keygen`)
20
+ - Your NAS reachable from the cluster nodes
21
+
22
+ ## Installation
23
+
24
+ ```sh
25
+ npm install -g @esuyo/coding-container
26
+ coding-container --help
27
+ ```
28
+
29
+ Or run from source:
30
+
31
+ ```sh
32
+ npm install
33
+ npm link
34
+ coding-container --help
35
+ ```
36
+
37
+ `npm link` puts `coding-container` on your PATH. Prefer not to link globally? Run it with `node src/index.js ...` instead.
38
+
39
+ One-time setup (creates your global config and SSH key, never overwrites):
40
+
41
+ ```sh
42
+ coding-container setup
43
+ ```
44
+
45
+ ## Configuration
46
+
47
+ 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.
48
+
49
+ ```yaml
50
+ project: my-project
51
+ k8s:
52
+ nodePort: 30022
53
+ ```
54
+
55
+ See [Configuration](./docs/configuration.md) for layering, multiple projects, and NAS folders.
56
+
57
+ ## Quick Start
58
+
59
+ ```sh
60
+ coding-container create # deploy this project
61
+ coding-container ssh # connect — you land in /workspace
62
+ coding-container delete # remove cluster resources (files stay safe on the NAS)
63
+ ```
64
+
65
+ Working in another folder? `cd` there and run `create` — a minimal `config.yaml` is scaffolded automatically. Each project gets its own container and workspace.
66
+
67
+ For everything else — web URLs, databases, image versions, all commands and flags — see [CLI reference](./docs/cli-reference.md) and the guides below.
68
+
69
+ ## Troubleshooting
70
+
71
+ - **Missing registry or NAS settings?** Run `coding-container setup`.
72
+ - **Push auth error?** Run `docker login <your-registry>` first.
73
+ - **Pod stuck?** Usually NFS or image pull — see [Troubleshooting](./docs/troubleshooting.md).
74
+
75
+ ## License
76
+
77
+ MIT — see [LICENSE](./LICENSE).
78
+
79
+ ## Developer Documentation
80
+
81
+ - [Configuration](./docs/configuration.md) — Config layering, multiple projects, NAS volumes
82
+ - [Shared global image](./docs/global-image.md) — Team image versions, publishing, rollback, cleanup
83
+ - [Web access](./docs/web-access.md) — Browser access to dev servers via Traefik
84
+ - [Sidecars](./docs/sidecars.md) — Databases, queues, and other pod companions
85
+ - [CLI reference](./docs/cli-reference.md) — All commands and flags
86
+ - [SSH and VS Code](./docs/ssh-and-vscode.md) — Key rotation and Remote-SSH setup
87
+ - [Troubleshooting](./docs/troubleshooting.md) — Common failures and fixes
88
+ - [Architecture](./docs/architecture.md) — Tech stack and system design
89
+ - [Project Structure](./docs/project-structure.md) — Folder layout
90
+ - [Development Guide](./docs/development.md) — Local setup for contributors
91
+ - [App config & setup files](./docs/config-files.md) — Persisting tool configs via /workspace
92
+ - [Plan: Additional NFS volumes](./docs/build/additional-volumes.md) — Design history for extra volumes
93
+ - [Plan: Public prebuilt images](./docs/build/public-images.md) — Design history for public images
94
+
95
+ ---
package/config.yaml ADDED
@@ -0,0 +1,27 @@
1
+ # Project-specific configuration for this repo's own container.
2
+ #
3
+ # Shared settings (registry, image build, k8s, nfs, ssh) come from the
4
+ # global config at ~/.config/coding-container/config.yaml; CLI flags override
5
+ # everything. Add keys here only for values that differ per project.
6
+
7
+ project: my-project
8
+
9
+ k8s:
10
+ nodePort: 30022
11
+
12
+ # Expose container ports to the browser through Traefik: one Ingress host per
13
+ # port, each routing to that port on this project's service. The hosts must
14
+ # resolve (DNS) to your Traefik LoadBalancer IP. An optional nodePort
15
+ # (30000-32767) additionally reaches the port on every cluster node at
16
+ # http://<node-ip>:<nodePort>/ (unique across ssh, web.ports, sidecars).
17
+ # web:
18
+ # ports:
19
+ # - { port: 3000, host: my-web.example.com, nodePort: 30080 }
20
+ # - { port: 8080, host: my-api.example.com, name: api }
21
+
22
+ # Extra NAS folders mounted into the container alongside /workspace. Each
23
+ # subPath is a subfolder of nfs.basePath (created on first mount if missing);
24
+ # readOnly: true makes the mount read-only for the container.
25
+ # nfs:
26
+ # volumes:
27
+ # - { subPath: shared-datasets, mountPath: /data, readOnly: true }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@esuyo/coding-container",
3
+ "version": "1.0.2",
4
+ "description": "CLI to build, publish and deploy SSH-accessible coding containers on k3s",
5
+ "type": "module",
6
+ "bin": {
7
+ "coding-container": "src/index.js"
8
+ },
9
+ "main": "src/index.js",
10
+ "scripts": {
11
+ "start": "node src/index.js",
12
+ "dev": "node src/index.js",
13
+ "test": "node --test"
14
+ },
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "keywords": [
19
+ "docker",
20
+ "kubernetes",
21
+ "k3s",
22
+ "ssh",
23
+ "coding-container",
24
+ "devcontainer"
25
+ ],
26
+ "license": "MIT",
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "files": [
31
+ "src/",
32
+ "Dockerfile",
33
+ "config.yaml"
34
+ ],
35
+ "dependencies": {
36
+ "commander": "^12.1.0",
37
+ "js-yaml": "^4.1.0"
38
+ }
39
+ }
@@ -0,0 +1,35 @@
1
+ import { dockerExec, run } from "../utils/exec.js";
2
+ import { imageRef } from "../config.js";
3
+
4
+ /**
5
+ * The raw docker build of cfg's image (cfg.image.tag must be set). Used by
6
+ * custom-mode projects and by `system create` (the only writer of the
7
+ * global image).
8
+ */
9
+ export async function buildImage(cfg) {
10
+ const ref = imageRef(cfg);
11
+ const args = ["build", "-t", ref, "-f", cfg.image.dockerfilePath];
12
+ for (const [key, value] of Object.entries(cfg.image.buildArgs || {})) {
13
+ args.push("--build-arg", `${key}=${value}`);
14
+ }
15
+ args.push(cfg.image.contextPath);
16
+ console.log(`Building image ${ref} ...`);
17
+ const { cmd, opts } = dockerExec();
18
+ await run(cmd, args, opts);
19
+ console.log(`Built ${ref}`);
20
+ }
21
+
22
+ export async function build(cfg) {
23
+ if (cfg.imageMode === "global") {
24
+ throw new Error(
25
+ "this project uses the shared global image — building here would tag the global repo. " +
26
+ "Publish a new global version with: coding-container system create --tag vX.Y.Z"
27
+ );
28
+ }
29
+ if (!cfg.image.tag) {
30
+ throw new Error(
31
+ "custom image mode requires a tag: set image.tag in the project config or pass --tag"
32
+ );
33
+ }
34
+ await buildImage(cfg);
35
+ }
@@ -0,0 +1,38 @@
1
+ import { build } from "./build.js";
2
+ import { push } from "./push.js";
3
+ import { deploy } from "./deploy.js";
4
+
5
+ /**
6
+ * Global mode: nothing is built or pushed here — the shared image is
7
+ * managed by `system create`, and deploy floats on the cluster ConfigMap.
8
+ * Custom mode: build + push the project's own image (coding-<project>),
9
+ * then deploy.
10
+ */
11
+ export async function create(cfg, { skipBuild = false, skipPush = false } = {}) {
12
+ if (cfg.imageMode === "global") {
13
+ if (skipBuild || skipPush) {
14
+ console.log(
15
+ "Global image mode: no build/push happens here — the shared image is managed by " +
16
+ "'coding-container system create'."
17
+ );
18
+ }
19
+ await deploy(cfg);
20
+ } else {
21
+ if (skipBuild) {
22
+ console.log("Skipping build");
23
+ } else {
24
+ await build(cfg);
25
+ }
26
+ if (skipPush) {
27
+ console.log("Skipping push");
28
+ } else {
29
+ await push(cfg);
30
+ }
31
+ await deploy(cfg);
32
+ }
33
+
34
+ console.log(`
35
+ Done. Connect with:
36
+ coding-container ssh
37
+ `);
38
+ }
@@ -0,0 +1,24 @@
1
+ import { run } from "../utils/exec.js";
2
+ import { removeHostEntry, sshAlias, sshConfigPath } from "../utils/ssh-config.js";
3
+
4
+ export async function deleteProject(cfg, { keepSecrets = false } = {}) {
5
+ const ns = cfg.k8s.namespace;
6
+ await run("kubectl", ["delete", "deployment", cfg.project, "-n", ns, "--ignore-not-found"]);
7
+ await run("kubectl", ["delete", "service", cfg.project, "-n", ns, "--ignore-not-found"]);
8
+ await run("kubectl", ["delete", "ingress", cfg.project, "-n", ns, "--ignore-not-found"]);
9
+ if (!keepSecrets) {
10
+ await run("kubectl", ["delete", "configmap", `${cfg.project}-ssh`, "-n", ns, "--ignore-not-found"]);
11
+ await run("kubectl", ["delete", "secret", `${cfg.project}-env`, "-n", ns, "--ignore-not-found"]);
12
+ }
13
+ console.log(`Deleted ${cfg.project} from namespace ${ns}`);
14
+
15
+ // Drop the SSH alias for the container.
16
+ try {
17
+ const alias = sshAlias(cfg.project);
18
+ if (removeHostEntry(alias)) {
19
+ console.log(`Removed SSH alias "${alias}" from ${sshConfigPath()}`);
20
+ }
21
+ } catch (err) {
22
+ console.warn(`Warning: could not update SSH config: ${err.message}`);
23
+ }
24
+ }
@@ -0,0 +1,138 @@
1
+ import path from "node:path";
2
+ import { exec, run, runWithInput } from "../utils/exec.js";
3
+ import { ensureKeyPair, readPublicKey } from "../utils/keys.js";
4
+ import { ensurePullSecret } from "../utils/registry.js";
5
+ import { fullManifest, webPorts, sidecarNodePorts as sidecarNodePortsList } from "../utils/manifest.js";
6
+ import { imageRef } from "../config.js";
7
+ import { addHostEntry, sshAlias, sshConfigPath } from "../utils/ssh-config.js";
8
+ import { findNodePortOwner } from "../utils/project.js";
9
+ import { kubectlExec, resolveDeployTag, SYSTEM_CM_NAME } from "../utils/system-config.js";
10
+
11
+ export async function ensureNamespace(ns) {
12
+ const { cmd, args, opts } = kubectlExec();
13
+ try {
14
+ exec(cmd, [...args, "get", "namespace", ns], opts);
15
+ } catch {
16
+ console.log(`Creating namespace ${ns} ...`);
17
+ await run(cmd, [...args, "create", "namespace", ns], opts);
18
+ }
19
+ }
20
+
21
+ export async function deploy(cfg, { wait = true } = {}) {
22
+ // Tag resolution (the float): custom mode uses the project pin; global
23
+ // mode uses the project pin/--tag when set, else the ConfigMap's current.
24
+ const hadExplicitTag = Boolean(cfg.image.tag);
25
+ cfg.image.tag = resolveDeployTag(cfg);
26
+ if (cfg.imageMode === "global" && !hadExplicitTag) {
27
+ console.log(
28
+ `Global image mode: deploying version ${cfg.image.tag} from the ${SYSTEM_CM_NAME} ConfigMap ` +
29
+ `('system get' to inspect, 'system rollback' to repoint, 'system create --tag vX.Y.Z' to publish new).`
30
+ );
31
+ }
32
+
33
+ const { public: publicPath } = ensureKeyPair(cfg.ssh.keyDirPath, cfg.ssh.keyName);
34
+ const publicKey = readPublicKey(publicPath);
35
+
36
+ const ns = cfg.k8s.namespace;
37
+ await ensureNamespace(ns);
38
+
39
+ // Fail fast on a taken NodePort: applying anyway leaves a partial state
40
+ // (deployment + ingress created, service invalid).
41
+ if (cfg.k8s.serviceType === "NodePort") {
42
+ const wanted = [
43
+ [cfg.k8s.nodePort, "k8s.nodePort (SSH)"],
44
+ ...webPorts(cfg)
45
+ .filter((p) => p.nodePort !== undefined)
46
+ .map((p) => [p.nodePort, `web port "${p.name}"`]),
47
+ ...sidecarNodePortsList(cfg).map((p) => [p.nodePort, `sidecar port "${p.name}"`]),
48
+ ];
49
+ for (const [port, what] of wanted) {
50
+ const owner = findNodePortOwner(port);
51
+ // Our own service (redeploy with an unchanged port) is not a conflict.
52
+ if (owner && !(owner.namespace === ns && owner.name === cfg.project)) {
53
+ throw new Error(
54
+ `nodePort ${port} (${what}) is already allocated to service "${owner.name}" ` +
55
+ `in namespace "${owner.namespace}"; pick another port (30000-32767) in config.yaml`
56
+ );
57
+ }
58
+ }
59
+ }
60
+
61
+ const pullSecret = await ensurePullSecret(cfg);
62
+
63
+ const manifest = fullManifest(cfg, publicKey, pullSecret);
64
+ const { cmd: kubectl, args: kargs, opts: kopts } = kubectlExec();
65
+ console.log(`Deploying ${imageRef(cfg)} to namespace ${cfg.k8s.namespace} ...`);
66
+ await runWithInput(kubectl, [...kargs, "apply", "-f", "-"], manifest, kopts);
67
+
68
+ if (wait) {
69
+ console.log("Waiting for rollout to complete ...");
70
+ await run(kubectl, [
71
+ ...kargs,
72
+ "rollout",
73
+ "status",
74
+ `deployment/${cfg.project}`,
75
+ "-n",
76
+ cfg.k8s.namespace,
77
+ "--timeout=300s",
78
+ ], kopts);
79
+ }
80
+ console.log(`Deployed ${cfg.project} in ${cfg.k8s.namespace}`);
81
+
82
+ // Node IP for the NodePort hints below and the SSH alias.
83
+ let nodeIp;
84
+ try {
85
+ nodeIp = exec(kubectl, [
86
+ ...kargs,
87
+ "get",
88
+ "nodes",
89
+ "-o",
90
+ "jsonpath={.items[0].status.addresses[?(@.type==\"InternalIP\")].address}",
91
+ ], kopts);
92
+ } catch {
93
+ /* handled below */
94
+ }
95
+
96
+ const web = webPorts(cfg);
97
+ if (web.length) {
98
+ console.log("Web (Traefik Ingress):");
99
+ for (const p of web) {
100
+ console.log(` http://${p.host}/ (container port ${p.port})`);
101
+ }
102
+ const webNodePorts = web.filter((p) => p.nodePort !== undefined);
103
+ if (webNodePorts.length) {
104
+ console.log("Web NodePorts (reachable on any cluster node):");
105
+ for (const p of webNodePorts) {
106
+ console.log(` http://${nodeIp ?? "<node-ip>"}:${p.nodePort}/ (container port ${p.port})`);
107
+ }
108
+ }
109
+ }
110
+
111
+ const sidecarNodePorts = sidecarNodePortsList(cfg);
112
+ if (sidecarNodePorts.length) {
113
+ console.log("Sidecar NodePorts (reachable on any cluster node):");
114
+ for (const p of sidecarNodePorts) {
115
+ console.log(` ${p.name}: ${nodeIp ?? "<node-ip>"}:${p.nodePort} (container port ${p.port})`);
116
+ }
117
+ }
118
+
119
+ // Register an SSH alias for the container (VS Code Remote-SSH / ssh).
120
+ if (nodeIp) {
121
+ try {
122
+ const alias = sshAlias(cfg.project);
123
+ addHostEntry({
124
+ alias,
125
+ hostName: nodeIp,
126
+ port: cfg.k8s.nodePort,
127
+ user: cfg.ssh.user,
128
+ identityFile: path.join(cfg.ssh.keyDirPath, cfg.ssh.keyName),
129
+ knownHostsFile: path.join(cfg.ssh.keyDirPath, `${cfg.project}-known_hosts`),
130
+ });
131
+ console.log(`SSH alias "${alias}" written to ${sshConfigPath()} (connect: ssh ${alias})`);
132
+ } catch (err) {
133
+ console.warn(`Warning: could not update SSH config: ${err.message}`);
134
+ }
135
+ } else {
136
+ console.warn("Warning: could not resolve a node IP; skipped the SSH alias in ~/.ssh/config");
137
+ }
138
+ }