@esuyo/coding-container 1.0.15 → 1.0.17

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 CHANGED
@@ -61,9 +61,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
61
61
  && rm -rf /var/lib/apt/lists/*
62
62
 
63
63
  # --- Coding agents / CLIs ---------------------------------------------------
64
- # opencode (installer adds PATH for interactive shells only; ENV + profile.d
64
+ # opencode v2 (installer adds PATH for interactive shells only; ENV + profile.d
65
65
  # cover non-interactive shells and login shells)
66
- RUN curl -fsSL https://opencode.ai/install | bash
66
+ RUN curl -fsSL https://opencode.ai/v2/install | bash && /root/.opencode/bin/opencode --version
67
67
  ENV PATH="/root/.opencode/bin:${PATH}"
68
68
  RUN printf 'export PATH="/root/.opencode/bin:$PATH"\n' > /etc/profile.d/opencode.sh
69
69
  # pi (pi.dev)
@@ -101,7 +101,7 @@ RUN mkdir -p /root/.ssh && \
101
101
  # sshd builds each session's environment from scratch (USER/HOME/PATH/SHELL +
102
102
  # PAM), so the container's env (image ENV + pod spec) is not visible in SSH
103
103
  # sessions; the entrypoint republishes it for login shells.
104
- RUN cat > /entrypoint.sh <<'ENTRYPOINT' && chmod +x /entrypoint.sh
104
+ RUN cat > /entrypoint.sh <<'ENTRYPOINT' && sed -i 's/\r$//' /entrypoint.sh && chmod +x /entrypoint.sh
105
105
  #!/bin/bash
106
106
  mkdir -p /run/sshd
107
107
  : > /etc/profile.d/99-container-env.sh
package/README.md CHANGED
@@ -129,5 +129,6 @@ MIT. See [LICENSE](./LICENSE).
129
129
  - [Project Structure](./docs/project-structure.md) — Folder layout
130
130
  - [Development Guide](./docs/development.md) — Local setup for contributors
131
131
  - [App config & setup files](./docs/config-files.md) — Persisting tool configs via /workspace
132
+ - [Windows setup](./docs/windows-setup.md) — Docker, kubectl, K3s, registry, and kubeconfig on Windows
132
133
  - [Plan: Additional NFS volumes](./docs/build/additional-volumes.md) — Design history for extra volumes
133
134
  - [Plan: Public prebuilt images](./docs/build/public-images.md) — Design history for public images
package/config.yaml CHANGED
@@ -8,6 +8,7 @@ project: my-project
8
8
 
9
9
  k8s:
10
10
  nodePort: 30022
11
+ # namespace: unset here -> global default; use one name per developer for private work, or one shared name per team.
11
12
  # Pin the pod to one named cluster node (kubectl get nodes). Empty/unset ->
12
13
  # the scheduler picks a node.
13
14
  # nodeName: worker-02
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esuyo/coding-container",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "CLI to build, publish and deploy SSH-accessible coding containers on k3s",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,7 @@
11
11
  "start": "node src/index.js",
12
12
  "dev": "node web/dev.js",
13
13
  "dev:kill": "node web/kill-dev.js",
14
+ "dev-stop": "node web/kill-dev.js",
14
15
  "dev:cli": "node src/index.js",
15
16
  "dev:server": "node web/server/server.js",
16
17
  "dev:client": "npm --prefix web/client run dev",
@@ -20,6 +20,22 @@ export async function buildImage(cfg) {
20
20
  `Fix image.context in your config.`
21
21
  );
22
22
  }
23
+ // A Dockerfile with Windows (CRLF) line endings bakes '\r' into the
24
+ // /entrypoint.sh heredoc, and the image then fails at boot with
25
+ // `exec /entrypoint.sh: no such file or directory` (exit 255).
26
+ // Normalizing line endings is content-preserving for the build, so fix
27
+ // it in place here (with a visible warning) instead of failing and
28
+ // asking the user to hand-edit the file.
29
+ const dockerfileText = fs.readFileSync(cfg.image.dockerfilePath, "utf8");
30
+ if (dockerfileText.includes("\r")) {
31
+ fs.writeFileSync(
32
+ cfg.image.dockerfilePath,
33
+ dockerfileText.replaceAll("\r\n", "\n").replaceAll("\r", "\n")
34
+ );
35
+ console.warn(
36
+ `warning: normalized Windows (CRLF) line endings to Unix (LF) in ${cfg.image.dockerfilePath}`
37
+ );
38
+ }
23
39
  const ref = imageRef(cfg);
24
40
  const args = ["build", "-t", ref, "-f", cfg.image.dockerfilePath];
25
41
  for (const [key, value] of Object.entries(cfg.image.buildArgs || {})) {
@@ -8,8 +8,10 @@ import { imageRef } from "../config.js";
8
8
  import { addHostEntry, ephemeralKnownHostsFile, removeLegacyKnownHostsFile, sshAlias, sshConfigPath } from "../utils/ssh-config.js";
9
9
  import { findNodePortOwner } from "../utils/project.js";
10
10
  import { kubectlExec, resolveDeployTag, SYSTEM_CM_NAME } from "../utils/system-config.js";
11
+ import { NAMESPACE_LABEL } from "../utils/namespaces.js";
12
+ import { setupAnnotationPairs } from "./setup.js";
11
13
 
12
- export async function ensureNamespace(ns) {
14
+ export async function ensureNamespace(ns, { setupValues } = {}) {
13
15
  const { cmd, args, opts } = kubectlExec();
14
16
  try {
15
17
  exec(cmd, [...args, "get", "namespace", ns], opts);
@@ -17,6 +19,13 @@ export async function ensureNamespace(ns) {
17
19
  console.log(`Creating namespace ${ns} ...`);
18
20
  await run(cmd, [...args, "create", "namespace", ns], opts);
19
21
  }
22
+ // Re-assert the label on every deploy/init: unlabeled namespaces are not
23
+ // listed as coding-container namespaces, so the invariant repairs itself.
24
+ await run(cmd, [...args, "label", "namespace", ns, NAMESPACE_LABEL, "--overwrite"], opts);
25
+ const pairs = setupAnnotationPairs(setupValues);
26
+ if (pairs.length) {
27
+ await run(cmd, [...args, "annotate", "namespace", ns, "--overwrite", ...pairs], opts);
28
+ }
20
29
  }
21
30
 
22
31
  export async function deploy(cfg, { wait = true } = {}) {
@@ -35,7 +44,13 @@ export async function deploy(cfg, { wait = true } = {}) {
35
44
  const publicKey = readPublicKey(publicPath);
36
45
 
37
46
  const ns = cfg.k8s.namespace;
38
- await ensureNamespace(ns);
47
+ await ensureNamespace(ns, {
48
+ setupValues: {
49
+ registry: cfg.image?.registry,
50
+ nfsServer: cfg.nfs?.server,
51
+ nfsBasePath: cfg.nfs?.basePath,
52
+ },
53
+ });
39
54
 
40
55
  // Fail fast on a taken NodePort: applying anyway leaves a partial state
41
56
  // (deployment + ingress created, service invalid).
@@ -3,15 +3,74 @@ import path from "node:path";
3
3
  import readline from "node:readline/promises";
4
4
  import { globalConfigDir, globalConfigPath, PROJECT_ROOT } from "../config.js";
5
5
  import { ensureKeyPair } from "../utils/keys.js";
6
+ import { exec, run } from "../utils/exec.js";
7
+ import { kubectlExec } from "../utils/system-config.js";
8
+ import { NAMESPACE_LABEL, SETUP_ANNOTATIONS, readNamespaceSetupValues, readDeploymentSetupValues } from "../utils/namespaces.js";
6
9
 
7
10
  // Values that differ per environment. When neither a flag nor a prompt supplies
8
11
  // one, the generated config gets a clearly-marked placeholder instead of a guess.
9
- const PLACEHOLDERS = {
12
+ export const PLACEHOLDERS = {
10
13
  registry: "your-registry.example.com/you",
11
14
  nfsServer: "your-nas-ip-or-hostname",
12
15
  nfsBasePath: "/path/to/shared/workspaces",
16
+ namespace: "coding",
13
17
  };
14
18
 
19
+ // Same DNS-1123 pattern as defaultNamespace() in src/utils/instance.js:
20
+ // lowercase alphanumerics and '-', max 63 chars.
21
+ const NAMESPACE_RE = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/;
22
+
23
+ export function normalizeNamespace(value) {
24
+ const v = String(value ?? "").trim();
25
+ if (v.length < 1 || v.length > 63 || !NAMESPACE_RE.test(v)) {
26
+ throw new Error(
27
+ `namespace must be a valid k8s name (lowercase alphanumerics and '-', max 63 chars), got: ${JSON.stringify(value)}`
28
+ );
29
+ }
30
+ return v;
31
+ }
32
+
33
+ /**
34
+ * Which of the three setup-value annotations to (re)write. Skips blanks
35
+ * and the setup placeholders so a never-configured environment never
36
+ * pollutes cluster annotations with "your-registry.example.com/you".
37
+ */
38
+ export function setupAnnotationPairs(setupValues) {
39
+ const skip = new Set(Object.values(PLACEHOLDERS));
40
+ const pairs = [];
41
+ if (!setupValues || typeof setupValues !== "object") return pairs;
42
+ for (const [field, key] of Object.entries(SETUP_ANNOTATIONS)) {
43
+ const v = setupValues[field];
44
+ if (typeof v !== "string") continue;
45
+ const t = v.trim();
46
+ if (!t || skip.has(t)) continue;
47
+ pairs.push(`${key}=${t}`);
48
+ }
49
+ return pairs;
50
+ }
51
+
52
+ /**
53
+ * Label + annotate an existing namespace; best effort, never throws.
54
+ * Only touches a namespace that already exists (creation happens on
55
+ * deploy/init), so setup stays fully usable offline.
56
+ */
57
+ async function tagNamespaceBestEffort(ns, setupValues) {
58
+ try {
59
+ const { cmd, args, opts } = kubectlExec();
60
+ exec(cmd, [...args, "get", "namespace", ns], opts);
61
+ await run(cmd, [...args, "label", "namespace", ns, NAMESPACE_LABEL, "--overwrite"], opts);
62
+ const pairs = setupAnnotationPairs(setupValues);
63
+ if (pairs.length) {
64
+ await run(cmd, [...args, "annotate", "namespace", ns, "--overwrite", ...pairs], opts);
65
+ }
66
+ } catch (err) {
67
+ console.warn(
68
+ `warning: could not tag namespace "${ns}" (it will be tagged on the next deploy): ` +
69
+ String(err.message ?? err).split("\n")[0]
70
+ );
71
+ }
72
+ }
73
+
15
74
  // Emit a YAML double-quoted scalar so special characters (":", "/", etc.) are safe.
16
75
  function yamlValue(v) {
17
76
  return JSON.stringify(v);
@@ -48,7 +107,7 @@ image:
48
107
  EXTRA_NPM_PACKAGES: ""
49
108
 
50
109
  k8s:
51
- namespace: coding
110
+ namespace: ${yamlValue(values.namespace)}
52
111
  replicas: 1
53
112
  serviceType: NodePort
54
113
  nodePort: 30022 # default; override per project if several run at once
@@ -92,13 +151,29 @@ container:
92
151
 
93
152
  // Copy the Dockerfile from the repo into the global folder (the build context)
94
153
  // and add a .dockerignore so the SSH keys / this config never enter the build
95
- // context. Existing files are never overwritten.
154
+ // context. Existing files are never overwritten. The copy is normalized to
155
+ // Unix (LF) line endings: a Windows (CRLF) checkout would otherwise bake
156
+ // '\r' into /entrypoint.sh via the Dockerfile heredoc, and the image then
157
+ // fails at boot with `exec /entrypoint.sh: no such file or directory`
158
+ // (the kernel looks for the interpreter `/bin/bash\r`).
96
159
  function provisionDockerfile(dir) {
97
160
  const dst = path.join(dir, "Dockerfile");
98
161
  if (fs.existsSync(dst)) {
99
- console.log(`kept: ${dst} (already exists, left untouched)`);
162
+ // An existing Dockerfile from an older setup may carry Windows (CRLF)
163
+ // line endings, which would bake '\r' into /entrypoint.sh. Normalizing
164
+ // is content-preserving for the build, so repair it in place rather
165
+ // than leaving a file that can only produce a broken image.
166
+ const existing = fs.readFileSync(dst, "utf8");
167
+ if (existing.includes("\r")) {
168
+ fs.writeFileSync(dst, existing.replaceAll("\r\n", "\n").replaceAll("\r", "\n"));
169
+ console.log(`normalized: ${dst} (converted CRLF to LF)`);
170
+ } else {
171
+ console.log(`kept: ${dst} (already exists, left untouched)`);
172
+ }
100
173
  } else {
101
- fs.copyFileSync(path.join(PROJECT_ROOT, "Dockerfile"), dst);
174
+ const src = path.join(PROJECT_ROOT, "Dockerfile");
175
+ const content = fs.readFileSync(src, "utf8").replaceAll("\r\n", "\n");
176
+ fs.writeFileSync(dst, content);
102
177
  console.log(`created: ${dst} (copied from the repo)`);
103
178
  }
104
179
  const ignore = path.join(dir, ".dockerignore");
@@ -136,12 +211,14 @@ async function captureEnvironment(opts) {
136
211
  const rl = interactive
137
212
  ? readline.createInterface({ input: process.stdin, output: process.stdout })
138
213
  : null;
139
- const willPrompt = Boolean(rl && (!opts.registry || !opts.nfsServer || !opts.nfsBasePath));
214
+ const willPrompt = Boolean(
215
+ rl && (!opts.registry || !opts.nfsServer || !opts.nfsBasePath || !opts.namespace)
216
+ );
140
217
  if (willPrompt) {
141
218
  console.log("A few questions about your environment (Enter skips to a placeholder):");
142
219
  }
143
220
  try {
144
- return {
221
+ const values = {
145
222
  registry: await ask(rl, opts.registry, {
146
223
  flagName: "registry",
147
224
  label: "Container registry",
@@ -160,7 +237,15 @@ async function captureEnvironment(opts) {
160
237
  example: "/workspaces",
161
238
  placeholder: PLACEHOLDERS.nfsBasePath,
162
239
  }),
240
+ namespace: await ask(rl, opts.namespace, {
241
+ flagName: "namespace",
242
+ label: "Coding namespace",
243
+ example: "coding / team-alpha",
244
+ placeholder: PLACEHOLDERS.namespace,
245
+ }),
163
246
  };
247
+ values.namespace = normalizeNamespace(values.namespace);
248
+ return values;
164
249
  } finally {
165
250
  if (rl) rl.close();
166
251
  }
@@ -175,10 +260,45 @@ export async function setup(opts = {}) {
175
260
  const cfgFile = globalConfigPath();
176
261
  if (fs.existsSync(cfgFile)) {
177
262
  console.log(`kept: ${cfgFile} (already exists, left untouched)`);
263
+ // An explicit --namespace still tags that namespace (label + any
264
+ // explicitly-passed values) without touching the kept config file.
265
+ if (typeof opts.namespace === "string" && opts.namespace.trim()) {
266
+ await tagNamespaceBestEffort(normalizeNamespace(opts.namespace), {
267
+ registry: opts.registry,
268
+ nfsServer: opts.nfsServer,
269
+ nfsBasePath: opts.nfsBasePath,
270
+ });
271
+ }
178
272
  } else {
179
- const values = await captureEnvironment(opts);
273
+ // Existing-namespace flow: the GUI hides registry/NFS and sends only
274
+ // --namespace. Fill blanks from the namespace's stored annotations so
275
+ // a reselect needs no retyping. Legacy namespaces created before the
276
+ // annotations existed carry no stored values, so fall back to the live
277
+ // workloads (deployment NFS volume + image registry). Missing/offline
278
+ // degrades to the normal prompt-or-placeholder path below.
279
+ const effective = { ...opts };
280
+ if (typeof effective.namespace === "string" && effective.namespace.trim()) {
281
+ try {
282
+ const ns = normalizeNamespace(effective.namespace);
283
+ const stored = readNamespaceSetupValues(ns);
284
+ for (const field of ["registry", "nfsServer", "nfsBasePath"]) {
285
+ if (!effective[field] && stored[field]) effective[field] = stored[field];
286
+ }
287
+ const missing = ["registry", "nfsServer", "nfsBasePath"].some((f) => !effective[f]);
288
+ if (missing) {
289
+ const inferred = readDeploymentSetupValues(ns);
290
+ for (const field of ["registry", "nfsServer", "nfsBasePath"]) {
291
+ if (!effective[field] && inferred[field]) effective[field] = inferred[field];
292
+ }
293
+ }
294
+ } catch {
295
+ /* invalid name surfaces via captureEnvironment validation */
296
+ }
297
+ }
298
+ const values = await captureEnvironment(effective);
180
299
  fs.writeFileSync(cfgFile, renderTemplate(values));
181
300
  console.log(`created: ${cfgFile}`);
301
+ await tagNamespaceBestEffort(values.namespace, values);
182
302
  }
183
303
 
184
304
  const pair = ensureKeyPair(path.join(dir, "keys"), "id_ed25519");
@@ -64,7 +64,13 @@ export async function systemInit(cfg, { tag, keep = 10 } = {}) {
64
64
  } catch (err) {
65
65
  if (!/not found/i.test(err.message)) throw err;
66
66
  }
67
- await ensureNamespace(ns);
67
+ await ensureNamespace(ns, {
68
+ setupValues: {
69
+ registry: cfg.image?.registry,
70
+ nfsServer: cfg.nfs?.server,
71
+ nfsBasePath: cfg.nfs?.basePath,
72
+ },
73
+ });
68
74
  await patchSystemConfig(ns, { current: tag, previous: undefined, maxEver: tag, keep });
69
75
  console.log(
70
76
  `Created ${SYSTEM_CM_NAME} in namespace ${ns}: current=${tag} maxEver=${tag} keep=${keep}`
package/src/index.js CHANGED
@@ -99,6 +99,16 @@ program
99
99
  .option("--registry <registry>", "container registry (e.g. registry.example.com/you)")
100
100
  .option("--nfs-server <server>", "NFS server IP or hostname (your NAS)")
101
101
  .option("--nfs-base-path <path>", "NFS base path for per-project workspaces")
102
+ // Same DNS-1123 pattern as defaultNamespace() in src/utils/instance.js.
103
+ .option("--namespace <name>", "coding namespace (e.g. coding or team-alpha)", (value) => {
104
+ const v = String(value).trim();
105
+ if (v.length < 1 || v.length > 63 || !/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/.test(v)) {
106
+ throw new Error(
107
+ `--namespace must be a valid k8s name (lowercase alphanumerics and '-', max 63 chars), got: ${JSON.stringify(value)}`
108
+ );
109
+ }
110
+ return v;
111
+ })
102
112
  .action(async (opts) => {
103
113
  await setup(opts);
104
114
  });
package/src/utils/exec.js CHANGED
@@ -8,7 +8,13 @@ import { spawn, spawnSync } from "node:child_process";
8
8
  export function dockerExec() {
9
9
  const overridden = Boolean(process.env.DOCKER_BIN);
10
10
  const cmd = process.env.DOCKER_BIN || "docker";
11
- const opts = overridden && process.platform === "win32" ? { shell: true } : {};
11
+ // windowsHide keeps console tools (docker/kubectl/ssh-keygen) from
12
+ // flashing a terminal window when the parent has no console (notably the
13
+ // packaged desktop app). No-op on other platforms.
14
+ const opts = {
15
+ windowsHide: true,
16
+ ...(overridden && process.platform === "win32" ? { shell: true } : {}),
17
+ };
12
18
  return { cmd, opts };
13
19
  }
14
20
 
@@ -64,6 +70,7 @@ export function ensureDockerAvailable() {
64
70
  */
65
71
  export function exec(cmd, args = [], opts = {}) {
66
72
  const res = spawnSync(cmd, args, {
73
+ windowsHide: true,
67
74
  encoding: "utf8",
68
75
  stdio: ["ignore", "pipe", "pipe"],
69
76
  ...opts,
@@ -81,7 +88,7 @@ export function exec(cmd, args = [], opts = {}) {
81
88
  */
82
89
  export function run(cmd, args = [], opts = {}) {
83
90
  return new Promise((resolve, reject) => {
84
- const child = spawn(cmd, args, { stdio: "inherit", ...opts });
91
+ const child = spawn(cmd, args, { windowsHide: true, stdio: "inherit", ...opts });
85
92
  child.on("error", reject);
86
93
  child.on("close", (code) => {
87
94
  if (code === 0) resolve();
@@ -96,6 +103,7 @@ export function run(cmd, args = [], opts = {}) {
96
103
  export function runWithInput(cmd, args = [], input = "", opts = {}) {
97
104
  return new Promise((resolve, reject) => {
98
105
  const child = spawn(cmd, args, {
106
+ windowsHide: true,
99
107
  stdio: ["pipe", "inherit", "inherit"],
100
108
  ...opts,
101
109
  });
@@ -114,5 +122,5 @@ export function runWithInput(cmd, args = [], input = "", opts = {}) {
114
122
  * Spawn a long-running command, returning the child handle.
115
123
  */
116
124
  export function start(cmd, args = [], opts = {}) {
117
- return spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], ...opts });
125
+ return spawn(cmd, args, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"], ...opts });
118
126
  }
@@ -0,0 +1,248 @@
1
+ import { exec } from "./exec.js";
2
+ import { kubectlExec, SYSTEM_CM_NAME } from "./system-config.js";
3
+
4
+ /**
5
+ * Label every coding-container namespace carries. The namespaces endpoint
6
+ * lists with `-l coding-container=true` server-side, so unlabeled namespaces
7
+ * are not coding-container namespaces by definition and never show up.
8
+ */
9
+ export const NAMESPACE_LABEL = "coding-container=true";
10
+
11
+ /**
12
+ * Free-form setup values live in annotations (labels are for selection).
13
+ * Written at setup/init time, read back to prefill the Setup form when an
14
+ * existing namespace is picked.
15
+ */
16
+ export const SETUP_ANNOTATIONS = {
17
+ registry: "coding-container.io/registry",
18
+ nfsServer: "coding-container.io/nfs-server",
19
+ nfsBasePath: "coding-container.io/nfs-base-path",
20
+ };
21
+
22
+ function coerceItems(json) {
23
+ let parsed = json;
24
+ if (typeof parsed === "string") {
25
+ try {
26
+ parsed = JSON.parse(parsed);
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.items)) {
32
+ return null;
33
+ }
34
+ return parsed.items;
35
+ }
36
+
37
+ /**
38
+ * Pure merge of `kubectl get ns -l coding-container=true -o json` with
39
+ * `kubectl get cm coding-system -A -o json`. A namespace counts as
40
+ * `initialized` when a coding-system ConfigMap lives in it. Namespaces
41
+ * without annotations prefill namespace-only (setupValues {}).
42
+ *
43
+ * Both inputs accept an already-parsed object or a raw JSON string.
44
+ * Anything unparseable or wrongly shaped degrades to [] so Setup stays
45
+ * usable offline with a manual-input fallback.
46
+ */
47
+ export function parseNamespaces(nsJson, cmJson) {
48
+ const nsItems = coerceItems(nsJson);
49
+ if (!nsItems) return [];
50
+ const cmItems = coerceItems(cmJson) ?? [];
51
+ const initializedByNs = new Set();
52
+ for (const cm of cmItems) {
53
+ const cmNs = cm?.metadata?.namespace;
54
+ // The kubectl call is already name-filtered server-side; the name check
55
+ // keeps the pure function honest when handed an unfiltered list.
56
+ if (typeof cmNs === "string" && cm?.metadata?.name === SYSTEM_CM_NAME) {
57
+ initializedByNs.add(cmNs);
58
+ }
59
+ }
60
+ const out = [];
61
+ for (const item of nsItems) {
62
+ const name = item?.metadata?.name;
63
+ if (typeof name !== "string" || !name) continue;
64
+ const annotations = item?.metadata?.annotations;
65
+ const setupValues = {};
66
+ if (annotations && typeof annotations === "object") {
67
+ for (const [field, key] of Object.entries(SETUP_ANNOTATIONS)) {
68
+ const v = annotations[key];
69
+ if (typeof v === "string" && v.trim()) setupValues[field] = v;
70
+ }
71
+ }
72
+ out.push({
73
+ name,
74
+ initialized: initializedByNs.has(name),
75
+ setupValues,
76
+ createdAt:
77
+ typeof item?.metadata?.creationTimestamp === "string"
78
+ ? item.metadata.creationTimestamp
79
+ : undefined,
80
+ });
81
+ }
82
+ out.sort((a, b) => a.name.localeCompare(b.name));
83
+ return out;
84
+ }
85
+
86
+ /**
87
+ * Read-only cluster edge: exactly two `get` calls, never anything
88
+ * destructive. A failed namespace list means the cluster is unreachable and
89
+ * throws (the namespaces endpoint maps that to `{ ok: false }`); a failed
90
+ * ConfigMap read degrades to all-`initialized: false` so a partial answer
91
+ * still lists the namespaces.
92
+ */
93
+ export function getNamespaces() {
94
+ const { cmd, args, opts } = kubectlExec();
95
+ let nsJson;
96
+ try {
97
+ nsJson = exec(cmd, [...args, "get", "ns", "-l", NAMESPACE_LABEL, "-o", "json"], opts);
98
+ } catch (err) {
99
+ throw new Error(`cannot list coding-container namespaces:\n${err.message}`);
100
+ }
101
+ let cmJson = '{"items":[]}';
102
+ try {
103
+ cmJson = exec(cmd, [...args, "get", "cm", SYSTEM_CM_NAME, "-A", "-o", "json"], opts);
104
+ } catch {
105
+ cmJson = '{"items":[]}';
106
+ }
107
+ return parseNamespaces(nsJson, cmJson);
108
+ }
109
+
110
+ /**
111
+ * Extract the setup registry (everything before the image name) from a
112
+ * container image reference. Strips tag/digest first, then drops the last
113
+ * path segment (the image name):
114
+ * "registry.example.com/you/coding-container:v1.0.0" -> "registry.example.com/you"
115
+ * "10.0.0.5:5000/coding-container:v1.0.0" -> "10.0.0.5:5000"
116
+ * Returns "" when there is no registry prefix to infer.
117
+ */
118
+ export function registryFromImage(image) {
119
+ const raw = String(image ?? "").trim();
120
+ if (!raw) return "";
121
+ let s = raw.split("@")[0];
122
+ const lastSlash = s.lastIndexOf("/");
123
+ const lastColon = s.lastIndexOf(":");
124
+ if (lastColon > lastSlash) s = s.slice(0, lastColon);
125
+ const slash = s.lastIndexOf("/");
126
+ if (slash <= 0) return "";
127
+ return s.slice(0, slash).trim();
128
+ }
129
+
130
+ /**
131
+ * Placeholder values that must never count as live cluster state. Mirrors
132
+ * PLACEHOLDERS in src/commands/setup.js (kept local to avoid a cycle:
133
+ * setup.js imports this module). A broken deploy written from placeholders
134
+ * (e.g. the legacy `wicked` case) must not poison inference for the rest.
135
+ */
136
+ const PLACEHOLDER_VALUES = new Set([
137
+ "your-registry.example.com/you",
138
+ "your-nas-ip-or-hostname",
139
+ "/path/to/shared/workspaces",
140
+ ]);
141
+
142
+ function isUsableSetupValue(v) {
143
+ if (typeof v !== "string") return false;
144
+ const t = v.trim();
145
+ return Boolean(t) && !PLACEHOLDER_VALUES.has(t);
146
+ }
147
+
148
+ /**
149
+ * Pure extraction of setup values from `kubectl get deploy -n <ns> -o json`.
150
+ * Reads the NFS volume (`volumes[].nfs.{server,path}`) and the container
151
+ * image registry across all workloads and returns the most common value
152
+ * per field (majority vote), so one broken deploy written from placeholders
153
+ * cannot override the healthy majority. Placeholders and blanks are ignored.
154
+ * Accepts an already-parsed object or a raw JSON string; anything
155
+ * unparseable or wrongly shaped degrades to {}.
156
+ */
157
+ export function parseDeploymentSetupValues(deployJson) {
158
+ let parsed = deployJson;
159
+ if (typeof parsed === "string") {
160
+ try {
161
+ parsed = JSON.parse(parsed);
162
+ } catch {
163
+ return {};
164
+ }
165
+ }
166
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.items)) return {};
167
+ const votes = { registry: new Map(), nfsServer: new Map(), nfsBasePath: new Map() };
168
+ const vote = (field, value) => {
169
+ if (!isUsableSetupValue(value)) return;
170
+ const t = value.trim();
171
+ votes[field].set(t, (votes[field].get(t) ?? 0) + 1);
172
+ };
173
+ for (const item of parsed.items) {
174
+ const podSpec = item?.spec?.template?.spec;
175
+ if (!podSpec || typeof podSpec !== "object") continue;
176
+ const volumes = Array.isArray(podSpec.volumes) ? podSpec.volumes : [];
177
+ for (const vol of volumes) {
178
+ const nfs = vol?.nfs;
179
+ if (!nfs || typeof nfs !== "object") continue;
180
+ vote("nfsServer", nfs.server);
181
+ let basePath = typeof nfs.path === "string" ? nfs.path.trim() : "";
182
+ if (basePath) basePath = basePath.replace(/\/+$/, "") || "/";
183
+ vote("nfsBasePath", basePath);
184
+ }
185
+ const containers = Array.isArray(podSpec.containers) ? podSpec.containers : [];
186
+ for (const c of containers) vote("registry", registryFromImage(c?.image));
187
+ }
188
+ const out = {};
189
+ for (const [field, counts] of Object.entries(votes)) {
190
+ let best = "";
191
+ let bestN = 0;
192
+ for (const [value, n] of counts) {
193
+ if (n > bestN) {
194
+ best = value;
195
+ bestN = n;
196
+ }
197
+ }
198
+ if (best) out[field] = best;
199
+ }
200
+ return out;
201
+ }
202
+
203
+ /**
204
+ * Best-effort read of one namespace's live workload values (deployments).
205
+ * Returns {} when the namespace has no deployments, is unreachable, or
206
+ * carries no NFS/image info. Never throws, so `setup` stays usable offline.
207
+ */
208
+ export function readDeploymentSetupValues(ns) {
209
+ const name = String(ns ?? "").trim();
210
+ if (!name) return {};
211
+ try {
212
+ const { cmd, args, opts } = kubectlExec();
213
+ const out = exec(cmd, [...args, "get", "deploy", "-n", name, "-o", "json"], opts);
214
+ return parseDeploymentSetupValues(out);
215
+ } catch {
216
+ return {};
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Best-effort read of one namespace's stored setup values (annotations).
222
+ * Returns {} when the namespace is missing, unreachable, or has no
223
+ * annotations. Never throws, so `setup` stays usable offline.
224
+ */
225
+ export function readNamespaceSetupValues(ns) {
226
+ const name = String(ns ?? "").trim();
227
+ if (!name) return {};
228
+ try {
229
+ const { cmd, args, opts } = kubectlExec();
230
+ const out = exec(cmd, [...args, "get", "namespace", name, "-o", "json"], opts);
231
+ let obj;
232
+ try {
233
+ obj = typeof out === "string" ? JSON.parse(out) : out;
234
+ } catch {
235
+ return {};
236
+ }
237
+ const annotations = obj?.metadata?.annotations;
238
+ if (!annotations || typeof annotations !== "object") return {};
239
+ const values = {};
240
+ for (const [field, key] of Object.entries(SETUP_ANNOTATIONS)) {
241
+ const v = annotations[key];
242
+ if (typeof v === "string" && v.trim()) values[field] = v.trim();
243
+ }
244
+ return values;
245
+ } catch {
246
+ return {};
247
+ }
248
+ }
@@ -88,6 +88,7 @@ export function queryCredentialHelper(store, host, spawn = spawnSync) {
88
88
  encoding: "utf8",
89
89
  stdio: ["pipe", "pipe", "pipe"],
90
90
  timeout: 15000,
91
+ windowsHide: true,
91
92
  });
92
93
  if (res.error || res.status !== 0 || !res.stdout) continue;
93
94
  const parsed = JSON.parse(res.stdout.trim());
@@ -31,12 +31,16 @@ export const INIT_HINT =
31
31
  export function kubectlExec() {
32
32
  const bin = process.env.KUBECTL_BIN || "kubectl";
33
33
  if (process.env.KUBECTL_BIN && /\.(c|m)?js$/.test(bin)) {
34
- return { cmd: process.execPath, args: [bin], opts: {} };
34
+ return { cmd: process.execPath, args: [bin], opts: { windowsHide: true } };
35
35
  }
36
- const opts =
37
- process.env.KUBECTL_BIN && process.platform === "win32" && /\.(cmd|bat)$/i.test(bin)
36
+ // windowsHide keeps kubectl from flashing a terminal window when the
37
+ // parent has no console (notably the packaged desktop app). No-op elsewhere.
38
+ const opts = {
39
+ windowsHide: true,
40
+ ...(process.env.KUBECTL_BIN && process.platform === "win32" && /\.(cmd|bat)$/i.test(bin)
38
41
  ? { shell: true }
39
- : {};
42
+ : {}),
43
+ };
40
44
  return { cmd: bin, args: [], opts };
41
45
  }
42
46