@akash-chowdhury-24/deployhub 2.0.7 → 2.0.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akash-chowdhury-24/deployhub",
3
- "version": "2.0.7",
3
+ "version": "2.0.9",
4
4
  "description": "Zero-configuration deployment and artifact manager",
5
5
  "type": "module",
6
6
  "main": "./src/cli/index.js",
@@ -2,6 +2,7 @@ import chalk from 'chalk';
2
2
  import { execa } from 'execa';
3
3
  import fs from 'fs-extra';
4
4
  import path from 'path';
5
+ import os from 'os';
5
6
  import axios from 'axios';
6
7
  import { loadConfig, loadEnv } from '../core/config.js';
7
8
  import { testProvider } from '../storage/index.js';
@@ -19,6 +20,8 @@ import {
19
20
  formatDeployPathWriteFailure,
20
21
  } from '../utils/shell-quote.js';
21
22
  import { formatPasswordlessSudoGuidance } from '../utils/nginx.js';
23
+ import { checkImagePullability } from '../utils/docker-image-deploy.js';
24
+ import { namespaceExists } from '../utils/kubernetes-namespace.js';
22
25
 
23
26
  /**
24
27
  * @typedef {{ name: string, pass: boolean, message: string }} CheckResult
@@ -410,6 +413,66 @@ async function runDeploymentChecks(config, envName, envConfig) {
410
413
  }
411
414
  })
412
415
  );
416
+
417
+ checks.push(
418
+ await runCheck('Kubernetes namespace', async () => {
419
+ const ns = process.env.KUBE_NAMESPACE || config.project || 'default';
420
+ const kubeconfig =
421
+ process.env.KUBECONFIG || path.join(os.homedir(), '.kube', 'config');
422
+ const context = process.env.KUBE_CONTEXT || '';
423
+ const expanded = kubeconfig.replace(/^~/, os.homedir());
424
+ const kubectlEnv = { ...process.env, KUBECONFIG: path.resolve(expanded) };
425
+
426
+ /** @param {string[]} baseArgs */
427
+ function kubectlClusterArgs(baseArgs) {
428
+ const args = [...baseArgs];
429
+ if (context) args.push('--context', context);
430
+ return args;
431
+ }
432
+
433
+ const exists = await namespaceExists(ns, {
434
+ kubectlArgs: kubectlClusterArgs,
435
+ getKubectlEnv: () => kubectlEnv,
436
+ });
437
+
438
+ if (exists) {
439
+ return {
440
+ name: 'Kubernetes namespace',
441
+ pass: true,
442
+ message: `Namespace '${ns}' exists`,
443
+ };
444
+ }
445
+ return {
446
+ name: 'Kubernetes namespace',
447
+ pass: true,
448
+ message: `Namespace '${ns}' does not exist yet — deploy will prompt locally or auto-create in CI (kubectl create namespace ${ns})`,
449
+ };
450
+ })
451
+ );
452
+
453
+ checks.push(
454
+ await runCheck('Container image pullable', async () => {
455
+ const result = await checkImagePullability(config, process.env);
456
+ return {
457
+ name: 'Container image pullable',
458
+ pass: result.ok,
459
+ message: result.message,
460
+ };
461
+ })
462
+ );
463
+ }
464
+
465
+ if (deployType === 'docker' && process.env.DOCKER_REGISTRY_USERNAME && process.env.DOCKER_REGISTRY_TOKEN) {
466
+ checks.push(
467
+ await runCheck('Container image pullable', async () => {
468
+ const result = await checkImagePullability(config, process.env);
469
+ return {
470
+ name: 'Container image pullable',
471
+ pass: result.ok,
472
+ message: result.message,
473
+ };
474
+ })
475
+ );
413
476
  }
414
477
 
415
478
  if (deployType === 'ec2' && process.env.EC2_INSTANCE_ID) {
@@ -309,19 +309,18 @@ export const DEPLOYMENT_ENV_DEFS = {
309
309
  optionalReason: 'defaults to your project name or "default" if unset',
310
310
  comment: [
311
311
  'Kubernetes namespace for your deployment.',
312
- 'Create one first if needed: kubectl create namespace my-app',
312
+ 'If missing, deployhub deploy prompts to create it locally (or auto-creates in CI).',
313
313
  ],
314
314
  example: 'my-app',
315
315
  when: 'optional',
316
316
  },
317
317
  {
318
318
  key: 'DOCKER_IMAGE_NAME',
319
- optionalReason: 'only required if your manifests need an image override at deploy time',
320
319
  comment: [
321
- 'Container image to deploy (must match manifests or be overridden).',
320
+ 'Container image to build, push, and deploy.',
321
+ 'Kubernetes clusters pull from a registry — local-only Docker images will not work.',
322
322
  ],
323
323
  example: 'ghcr.io/myorg/myapp',
324
- when: 'optional',
325
324
  },
326
325
  {
327
326
  key: 'DOCKER_IMAGE_TAG',
@@ -332,6 +331,30 @@ export const DEPLOYMENT_ENV_DEFS = {
332
331
  example: 'latest',
333
332
  when: 'optional',
334
333
  },
334
+ {
335
+ key: 'DOCKER_REGISTRY_URL',
336
+ optionalReason: 'leave empty for Docker Hub',
337
+ comment: [
338
+ 'Container registry URL. Leave empty for Docker Hub.',
339
+ 'Examples: https://index.docker.io/v1/ | https://ghcr.io',
340
+ ],
341
+ when: 'optional',
342
+ },
343
+ {
344
+ key: 'DOCKER_REGISTRY_USERNAME',
345
+ comment: [
346
+ 'Registry username — required to push so the cluster can pull your image.',
347
+ 'Even public Docker Hub repos require authentication to push.',
348
+ ],
349
+ example: 'myuser',
350
+ },
351
+ {
352
+ key: 'DOCKER_REGISTRY_TOKEN',
353
+ comment: [
354
+ 'Registry password or personal access token — required to push so the cluster can pull.',
355
+ 'Docker Hub: access token. GHCR: GitHub PAT with write:packages.',
356
+ ],
357
+ },
335
358
  {
336
359
  key: 'KUBE_IMAGE_PULL_SECRET',
337
360
  optionalReason: 'only required when pulling from a private container registry',
@@ -683,21 +706,27 @@ export const DEPLOYMENT_GUIDE = {
683
706
  before: [
684
707
  'An existing Kubernetes cluster (DeployHub does not provision clusters).',
685
708
  'kubectl installed and configured (kubectl cluster-info works).',
709
+ 'A container registry account — clusters pull images from a registry, not your local Docker daemon.',
710
+ 'Registry credentials (username + token) to push your image so the cluster can pull it.',
686
711
  'Kubernetes manifests (Deployment, Service, etc.) in your repo or artifact.',
687
712
  'Cluster access from CI: kubeconfig or cloud-specific auth for GitHub Actions.',
688
713
  ],
689
714
  automates: [
690
715
  'Lists available kubectl contexts during init so you pick from a menu.',
691
716
  'Auto-detects ~/.kube/config if present.',
692
- 'Generates complete .env.example for kubeconfig, context, and namespace.',
717
+ 'Generates complete .env.example for kubeconfig, context, namespace, and registry settings.',
693
718
  'Tests cluster connectivity during init.',
719
+ 'Builds and pushes your container image during deployhub build/deploy (pipeline docker stage).',
694
720
  ],
695
721
  after: [
696
722
  'Ensure your kubeconfig context points to the correct cluster.',
697
- 'Create namespace if needed: kubectl create namespace YOUR_NAMESPACE',
698
- 'For private registries: kubectl create secret docker-registry ... and set KUBE_IMAGE_PULL_SECRET.',
699
- 'Copy .env.example to .env; add KUBECONFIG contents or auth secrets to GitHub Actions.',
700
- 'Run deployhub doctor, then git push origin main.',
723
+ 'Namespace is created on first deploy if missing (prompt locally; auto-create in CI). Or: kubectl create namespace YOUR_NAMESPACE',
724
+ 'Copy .env.example to .env and set DOCKER_IMAGE_NAME, DOCKER_REGISTRY_USERNAME, and DOCKER_REGISTRY_TOKEN.',
725
+ 'Skipping registry credentials will very likely cause ImagePullBackOff the cluster cannot see local Docker images.',
726
+ 'For private registries: also create kubectl create secret docker-registry ... and set KUBE_IMAGE_PULL_SECRET.',
727
+ 'Add the GitHub Secrets listed below (Settings → Secrets and variables → Actions).',
728
+ 'Run deployhub doctor to verify cluster access and that your image is pullable.',
729
+ 'git push origin main to trigger your first deployment.',
701
730
  ],
702
731
  },
703
732
  };
@@ -94,6 +94,23 @@ async function promptKubernetesDeployment(base, projectName, projectType) {
94
94
  message: 'Container image name (e.g. ghcr.io/myorg/myapp):',
95
95
  default: projectName,
96
96
  },
97
+ {
98
+ type: 'input',
99
+ name: 'dockerRegistryUrl',
100
+ message: 'Registry URL (leave empty for Docker Hub):',
101
+ },
102
+ {
103
+ type: 'input',
104
+ name: 'dockerRegistryUsername',
105
+ message:
106
+ 'Registry username (required — needed to push your image so the cluster can pull it):',
107
+ },
108
+ {
109
+ type: 'password',
110
+ name: 'dockerRegistryToken',
111
+ message:
112
+ 'Registry token/password (required — needed to push your image so the cluster can pull it):',
113
+ },
97
114
  {
98
115
  type: 'input',
99
116
  name: 'healthUrl',
@@ -115,6 +132,9 @@ async function promptKubernetesDeployment(base, projectName, projectType) {
115
132
  kubeContext: kubeAnswers.kubeContext,
116
133
  kubeNamespace: kubeAnswers.kubeNamespace,
117
134
  dockerImageName: kubeAnswers.dockerImageName,
135
+ dockerRegistryUrl: kubeAnswers.dockerRegistryUrl,
136
+ dockerRegistryUsername: kubeAnswers.dockerRegistryUsername,
137
+ dockerRegistryToken: kubeAnswers.dockerRegistryToken,
118
138
  healthUrl: kubeAnswers.healthUrl,
119
139
  };
120
140
  }
@@ -366,6 +386,7 @@ export function buildServerEnvEntry(
366
386
  envEntry.kubeContext = deployAnswers.kubeContext;
367
387
  envEntry.kubeNamespace = deployAnswers.kubeNamespace || projectName;
368
388
  envEntry.dockerImageName = deployAnswers.dockerImageName || projectName;
389
+ envEntry.dockerRegistryUrl = deployAnswers.dockerRegistryUrl || '';
369
390
  return envEntry;
370
391
  }
371
392
 
@@ -418,7 +439,7 @@ export function buildServerEnvEntry(
418
439
  * @returns {Record<string, string>|null}
419
440
  */
420
441
  export function getDockerEnvSecrets(deployAnswers) {
421
- if (deployAnswers.deployType !== 'docker') return null;
442
+ if (!['docker', 'kubernetes'].includes(deployAnswers.deployType)) return null;
422
443
 
423
444
  /** @type {Record<string, string>} */
424
445
  const vars = {};
@@ -1,18 +1,6 @@
1
1
  import { execa } from 'execa';
2
- import fs from 'fs-extra';
3
- import path from 'path';
4
2
  import { createLogger } from '../../logger/index.js';
5
- import { extractArtifact } from '../../artifact/engine.js';
6
- import {
7
- describeInterpretedBackendGap,
8
- generateDotnetRuntimeDockerfile,
9
- generateFrontendRuntimeDockerfile,
10
- generateGoRuntimeDockerfile,
11
- generateSpringRuntimeDockerfile,
12
- isFrontendStaticFramework,
13
- isInterpretedBackendFramework,
14
- resolveDockerImageRef,
15
- } from '../../utils/docker-image.js';
3
+ import { createDockerImageDeployContext } from '../../utils/docker-image-deploy.js';
16
4
 
17
5
  /**
18
6
  * @param {import('../../core/config.js').DeployHubConfig} config
@@ -21,277 +9,8 @@ import {
21
9
  */
22
10
  export function createDockerProvider(config, envName, env = process.env) {
23
11
  const log = createLogger('docker');
24
-
25
- const { fullImage, latestImage, legacyLatestImage, imageTag } =
26
- resolveDockerImageRef(config, env);
27
- const registryUrl = env.DOCKER_REGISTRY_URL || '';
28
- const registryUser = env.DOCKER_REGISTRY_USERNAME || '';
29
- const registryToken = env.DOCKER_REGISTRY_TOKEN || '';
30
- const dockerHost = env.DOCKER_HOST || '';
31
-
32
- function getDockerEnv() {
33
- /** @type {Record<string, string>} */
34
- const dockerEnv = { ...process.env };
35
- if (dockerHost) dockerEnv.DOCKER_HOST = dockerHost;
36
- if (env.DOCKER_TLS_VERIFY) dockerEnv.DOCKER_TLS_VERIFY = env.DOCKER_TLS_VERIFY;
37
- if (env.DOCKER_CERT_PATH) dockerEnv.DOCKER_CERT_PATH = env.DOCKER_CERT_PATH;
38
- return dockerEnv;
39
- }
40
-
41
- function hasRegistryCredentials() {
42
- return Boolean(registryUser && registryToken);
43
- }
44
-
45
- async function dockerLogin() {
46
- if (!hasRegistryCredentials()) return;
47
- const registry = registryUrl || 'https://index.docker.io/v1/';
48
- log.info('Logging in to container registry...');
49
- await execa(
50
- 'docker',
51
- ['login', registry, '-u', registryUser, '--password-stdin'],
52
- {
53
- input: registryToken,
54
- stdio: ['pipe', 'inherit', 'inherit'],
55
- env: getDockerEnv(),
56
- }
57
- );
58
- }
59
-
60
- /**
61
- * Push only when registry credentials are configured. Avoids a noisy failed
62
- * push to Docker Hub for local-only image names.
63
- */
64
- async function maybePushImage() {
65
- if (!hasRegistryCredentials()) {
66
- log.info(
67
- 'docker push skipped (DOCKER_REGISTRY_USERNAME/TOKEN not set — local image only)'
68
- );
69
- return;
70
- }
71
-
72
- log.info(`Pushing ${fullImage} to registry...`);
73
- await execa('docker', ['push', fullImage], {
74
- stdio: 'inherit',
75
- env: getDockerEnv(),
76
- });
77
- log.success(`Pushed ${fullImage}`);
78
- }
79
-
80
- /**
81
- * @param {string} ref
82
- */
83
- async function imageExists(ref) {
84
- try {
85
- await execa('docker', ['image', 'inspect', ref], {
86
- stdio: 'pipe',
87
- env: getDockerEnv(),
88
- });
89
- return true;
90
- } catch {
91
- return false;
92
- }
93
- }
94
-
95
- /**
96
- * Prefer the image already built during the pipeline `docker` stage.
97
- * Retag when the pipeline used `:latest` and deploy needs a version tag.
98
- */
99
- async function ensureImageFromPipeline() {
100
- if (await imageExists(fullImage)) {
101
- log.info(`Reusing existing image ${fullImage}`);
102
- return true;
103
- }
104
-
105
- const candidates = [...new Set([latestImage, legacyLatestImage])].filter(
106
- (ref) => ref !== fullImage
107
- );
108
-
109
- for (const candidate of candidates) {
110
- if (!(await imageExists(candidate))) continue;
111
- log.info(`Re-tagging pipeline image ${candidate} → ${fullImage}`);
112
- await execa('docker', ['tag', candidate, fullImage], {
113
- stdio: 'inherit',
114
- env: getDockerEnv(),
115
- });
116
- return true;
117
- }
118
-
119
- return false;
120
- }
121
-
122
- /**
123
- * Backend artifacts omit lockfiles/node_modules. Prefer a pre-built binary
124
- * runtime image when present; otherwise fail with an actionable error.
125
- * @param {string} buildContext
126
- * @param {Record<string, unknown>} metadata
127
- * @param {string} framework
128
- * @param {number} port
129
- */
130
- async function prepareBackendBuildContext(buildContext, metadata, framework, port) {
131
- if (framework === 'spring') {
132
- const targetDir = path.join(buildContext, 'target');
133
- if (await fs.pathExists(targetDir)) {
134
- const jars = (await fs.readdir(targetDir)).filter((f) => f.endsWith('.jar'));
135
- if (jars.length > 0) {
136
- const jarRel = `target/${jars[0]}`;
137
- log.info(`Building runtime image from pre-built JAR (${jarRel})...`);
138
- await fs.writeFile(
139
- path.join(buildContext, 'Dockerfile'),
140
- generateSpringRuntimeDockerfile(jarRel, port)
141
- );
142
- return;
143
- }
144
- }
145
- }
146
-
147
- if (framework === 'go') {
148
- const binDir = path.join(buildContext, 'bin');
149
- if (await fs.pathExists(binDir)) {
150
- const bins = await fs.readdir(binDir);
151
- if (bins.length > 0) {
152
- const binRel = `bin/${bins[0]}`;
153
- log.info(`Building runtime image from pre-built Go binary (${binRel})...`);
154
- await fs.writeFile(
155
- path.join(buildContext, 'Dockerfile'),
156
- generateGoRuntimeDockerfile(binRel, port)
157
- );
158
- return;
159
- }
160
- }
161
- }
162
-
163
- if (framework === 'dotnet') {
164
- const publishDir =
165
- /** @type {string} */ (metadata.buildOutput) ||
166
- config.buildOutput ||
167
- 'publish';
168
- const publishPath = path.join(buildContext, publishDir);
169
- if (await fs.pathExists(publishPath)) {
170
- log.info(`Building runtime image from pre-built .NET output (${publishDir}/)...`);
171
- await fs.writeFile(
172
- path.join(buildContext, 'Dockerfile'),
173
- generateDotnetRuntimeDockerfile(publishDir, port)
174
- );
175
- return;
176
- }
177
- }
178
-
179
- const dockerfilePath = path.join(buildContext, 'Dockerfile');
180
- if (!(await fs.pathExists(dockerfilePath))) {
181
- throw new Error(
182
- 'No Dockerfile found in artifact and no pipeline image to reuse. ' +
183
- 'Add a Dockerfile and enable pipeline.docker so the image is built from project source.'
184
- );
185
- }
186
-
187
- // Interpreted backends (Node/Python/PHP/Ruby): never rebuild from artifact.
188
- // Artifacts ship source + manifest files, not installed dependency trees.
189
- if (isInterpretedBackendFramework(framework)) {
190
- const gap = describeInterpretedBackendGap(framework);
191
- throw new Error(
192
- `Cannot rebuild ${gap.ecosystem} backend image "${fullImage}" from the packaged artifact.\n` +
193
- `Backend artifacts include source/manifests but not ${gap.missing}, ` +
194
- `so Dockerfiles that run \`${gap.installCmd}\` cannot reliably succeed from the artifact alone.\n\n` +
195
- 'What to do instead:\n' +
196
- ' 1. Enable pipeline.docker in deployhub.config.json (default when Docker deploy is selected).\n' +
197
- ' 2. Run a full `deployhub build` so the image is built from the project root (with full deps).\n' +
198
- ' 3. Deploy will reuse that local image (retag/push/run) — it will not rebuild from the artifact.\n\n' +
199
- `Standalone \`deployhub deploy\` without a pre-built local image is not supported for ${gap.ecosystem} backends.`
200
- );
201
- }
202
-
203
- // Remaining backends (unknown frameworks): try the packaged Dockerfile, but warn.
204
- log.warn(
205
- 'Building backend image from extracted artifact. Prefer pipeline.docker so the image ' +
206
- 'is built once from full project source, then reused on deploy.'
207
- );
208
- }
209
-
210
- /**
211
- * Standalone deploy fallback: extract the packaged artifact and build a
212
- * runtime image from pre-built output (frontend) or compiled backend artifacts.
213
- * Never builds from artifactDir root — that only has zip/metadata + a source Dockerfile.
214
- * @param {string} artifactDir
215
- */
216
- async function buildFromArtifactContents(artifactDir) {
217
- const zipPath = path.join(artifactDir, 'artifact.zip');
218
- if (!(await fs.pathExists(zipPath))) {
219
- throw new Error(
220
- `No local image found for ${fullImage} and no artifact.zip to build from. ` +
221
- 'Enable pipeline.docker so the image is built from project source, or run deployhub build first.'
222
- );
223
- }
224
-
225
- const buildContext = path.join(artifactDir, '_docker_build');
226
- await fs.remove(buildContext);
227
- await extractArtifact(artifactDir, buildContext);
228
-
229
- try {
230
- const metadataPath = path.join(buildContext, 'metadata.json');
231
- const metadata = (await fs.pathExists(metadataPath))
232
- ? await fs.readJson(metadataPath)
233
- : {};
234
-
235
- const projectType = metadata.projectType || config.projectType || 'frontend';
236
- const framework =
237
- metadata.framework ||
238
- config.framework ||
239
- config.frontend?.framework ||
240
- '';
241
- const buildOutput =
242
- metadata.buildOutput ||
243
- config.buildOutput ||
244
- config.frontend?.buildOutput ||
245
- 'dist';
246
- const port =
247
- Number(metadata.port) ||
248
- config.port ||
249
- config.backend?.port ||
250
- 3000;
251
-
252
- const composePath = path.join(buildContext, 'docker-compose.yml');
253
- if (await fs.pathExists(composePath)) {
254
- log.info('Building via docker compose from extracted artifact...');
255
- await execa('docker', ['compose', 'up', '-d', '--build'], {
256
- cwd: buildContext,
257
- stdio: 'inherit',
258
- env: getDockerEnv(),
259
- });
260
- return { ranCompose: true };
261
- }
262
-
263
- const isStaticFrontend =
264
- projectType === 'frontend' || isFrontendStaticFramework(framework);
265
-
266
- if (isStaticFrontend) {
267
- const outputDir = path.join(buildContext, buildOutput);
268
- if (!(await fs.pathExists(outputDir))) {
269
- throw new Error(
270
- `Frontend artifact is missing build output "${buildOutput}". ` +
271
- 'Cannot build a runtime image from this artifact.'
272
- );
273
- }
274
- log.info(
275
- `Building runtime image from pre-built ${buildOutput}/ (no source rebuild)...`
276
- );
277
- await fs.writeFile(
278
- path.join(buildContext, 'Dockerfile'),
279
- generateFrontendRuntimeDockerfile(buildOutput)
280
- );
281
- } else {
282
- await prepareBackendBuildContext(buildContext, metadata, framework, port);
283
- }
284
-
285
- await execa('docker', ['build', '-t', fullImage, '.'], {
286
- cwd: buildContext,
287
- stdio: 'inherit',
288
- env: getDockerEnv(),
289
- });
290
- return { ranCompose: false };
291
- } finally {
292
- await fs.remove(buildContext).catch(() => {});
293
- }
294
- }
12
+ const imageOps = createDockerImageDeployContext(config, env, log);
13
+ const { fullImage, getDockerEnv, ensureImageReadyForDeploy } = imageOps;
295
14
 
296
15
  /**
297
16
  * @param {string} artifactDir
@@ -300,31 +19,12 @@ export function createDockerProvider(config, envName, env = process.env) {
300
19
  log.info(`Deploying via Docker (image: ${fullImage})...`);
301
20
  const dockerEnv = getDockerEnv();
302
21
 
303
- await dockerLogin();
304
-
305
- const reused = await ensureImageFromPipeline();
306
- let ranCompose = false;
307
-
308
- if (!reused) {
309
- const result = await buildFromArtifactContents(artifactDir);
310
- ranCompose = Boolean(result?.ranCompose);
311
- }
312
-
313
- if (ranCompose) {
22
+ const result = await ensureImageReadyForDeploy(artifactDir);
23
+ if (result.ranCompose) {
314
24
  log.success('Docker deployment complete');
315
25
  return;
316
26
  }
317
27
 
318
- await maybePushImage();
319
-
320
- // Keep :latest in sync when deploy uses a version tag
321
- if (imageTag !== 'latest' && fullImage !== latestImage) {
322
- await execa('docker', ['tag', fullImage, latestImage], {
323
- stdio: 'pipe',
324
- env: dockerEnv,
325
- }).catch(() => {});
326
- }
327
-
328
28
  await execa(
329
29
  'docker',
330
30
  ['rm', '-f', config.project],
@@ -4,6 +4,8 @@ import path from 'path';
4
4
  import os from 'os';
5
5
  import { createLogger } from '../../logger/index.js';
6
6
  import { sanitizeK8sName } from '../../utils/kubernetes-manifests.js';
7
+ import { createDockerImageDeployContext } from '../../utils/docker-image-deploy.js';
8
+ import { ensureKubernetesNamespace } from '../../utils/kubernetes-namespace.js';
7
9
 
8
10
  /**
9
11
  * @param {import('../../core/config.js').DeployHubConfig} config
@@ -12,6 +14,7 @@ import { sanitizeK8sName } from '../../utils/kubernetes-manifests.js';
12
14
  */
13
15
  export function createKubernetesProvider(config, envName, env = process.env) {
14
16
  const log = createLogger('kubernetes');
17
+ const imageOps = createDockerImageDeployContext(config, env, log);
15
18
 
16
19
  const kubeconfig = env.KUBECONFIG || path.join(os.homedir(), '.kube', 'config');
17
20
  const context = env.KUBE_CONTEXT || '';
@@ -24,12 +27,22 @@ export function createKubernetesProvider(config, envName, env = process.env) {
24
27
  }
25
28
 
26
29
  /**
30
+ * Cluster-scoped kubectl args (context only). Used for Namespace get/create.
27
31
  * @param {string[]} baseArgs
28
32
  */
29
- function kubectlArgs(baseArgs) {
33
+ function kubectlClusterArgs(baseArgs) {
30
34
  /** @type {string[]} */
31
35
  const args = [...baseArgs];
32
36
  if (context) args.push('--context', context);
37
+ return args;
38
+ }
39
+
40
+ /**
41
+ * @param {string[]} baseArgs
42
+ */
43
+ function kubectlArgs(baseArgs) {
44
+ /** @type {string[]} */
45
+ const args = kubectlClusterArgs(baseArgs);
33
46
  if (namespace) args.push('--namespace', namespace);
34
47
  return args;
35
48
  }
@@ -51,6 +64,21 @@ export function createKubernetesProvider(config, envName, env = process.env) {
51
64
  );
52
65
  }
53
66
 
67
+ log.info(`Ensuring container image ${imageOps.fullImage} is built and pushed before apply...`);
68
+ const imageResult = await imageOps.ensureImageReadyForDeploy(artifactDir);
69
+ if (imageResult.ranCompose) {
70
+ log.warn(
71
+ 'docker compose was used — ensure the cluster can pull the resulting image from your registry.'
72
+ );
73
+ }
74
+
75
+ await ensureKubernetesNamespace({
76
+ namespace,
77
+ log,
78
+ kubectlArgs: kubectlClusterArgs,
79
+ getKubectlEnv,
80
+ });
81
+
54
82
  const applyTarget = (await fs.pathExists(path.join(manifestDir, 'k8s')))
55
83
  ? path.join(manifestDir, 'k8s')
56
84
  : manifestDir;
@@ -0,0 +1,420 @@
1
+ import { execa } from 'execa';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import { extractArtifact } from '../artifact/engine.js';
5
+ import {
6
+ describeInterpretedBackendGap,
7
+ generateDotnetRuntimeDockerfile,
8
+ generateFrontendRuntimeDockerfile,
9
+ generateGoRuntimeDockerfile,
10
+ generateSpringRuntimeDockerfile,
11
+ isFrontendStaticFramework,
12
+ isInterpretedBackendFramework,
13
+ resolveDockerImageRef,
14
+ } from './docker-image.js';
15
+
16
+ /**
17
+ * Shared Docker image build, reuse, push, and pullability logic used by
18
+ * docker and kubernetes deploy providers.
19
+ *
20
+ * @param {import('../core/config.js').DeployHubConfig} config
21
+ * @param {Record<string, string>} [env]
22
+ * @param {{ info: Function, warn: Function, success: Function }} log
23
+ */
24
+ export function createDockerImageDeployContext(config, env = process.env, log) {
25
+ const { fullImage, latestImage, legacyLatestImage, imageTag } =
26
+ resolveDockerImageRef(config, env);
27
+ const registryUrl = env.DOCKER_REGISTRY_URL || '';
28
+ const registryUser = env.DOCKER_REGISTRY_USERNAME || '';
29
+ const registryToken = env.DOCKER_REGISTRY_TOKEN || '';
30
+ const dockerHost = env.DOCKER_HOST || '';
31
+
32
+ function getDockerEnv() {
33
+ /** @type {Record<string, string>} */
34
+ const dockerEnv = { ...process.env };
35
+ if (dockerHost) dockerEnv.DOCKER_HOST = dockerHost;
36
+ if (env.DOCKER_TLS_VERIFY) dockerEnv.DOCKER_TLS_VERIFY = env.DOCKER_TLS_VERIFY;
37
+ if (env.DOCKER_CERT_PATH) dockerEnv.DOCKER_CERT_PATH = env.DOCKER_CERT_PATH;
38
+ return dockerEnv;
39
+ }
40
+
41
+ function hasRegistryCredentials() {
42
+ return Boolean(registryUser && registryToken);
43
+ }
44
+
45
+ async function dockerLogin() {
46
+ if (!hasRegistryCredentials()) return;
47
+ const registry = registryUrl || 'https://index.docker.io/v1/';
48
+ log.info('Logging in to container registry...');
49
+ await execa(
50
+ 'docker',
51
+ ['login', registry, '-u', registryUser, '--password-stdin'],
52
+ {
53
+ input: registryToken,
54
+ stdio: ['pipe', 'inherit', 'inherit'],
55
+ env: getDockerEnv(),
56
+ }
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Push only when registry credentials are configured. Avoids a noisy failed
62
+ * push to Docker Hub for local-only image names.
63
+ */
64
+ async function maybePushImage() {
65
+ if (!hasRegistryCredentials()) {
66
+ log.info(
67
+ 'docker push skipped (DOCKER_REGISTRY_USERNAME/TOKEN not set — local image only)'
68
+ );
69
+ return;
70
+ }
71
+
72
+ log.info(`Pushing ${fullImage} to registry...`);
73
+ await execa('docker', ['push', fullImage], {
74
+ stdio: 'inherit',
75
+ env: getDockerEnv(),
76
+ });
77
+ log.success(`Pushed ${fullImage}`);
78
+ }
79
+
80
+ /**
81
+ * @param {string} ref
82
+ */
83
+ async function imageExistsLocally(ref) {
84
+ try {
85
+ await execa('docker', ['image', 'inspect', ref], {
86
+ stdio: 'pipe',
87
+ env: getDockerEnv(),
88
+ });
89
+ return true;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Prefer the image already built during the pipeline `docker` stage.
97
+ * Retag when the pipeline used `:latest` and deploy needs a version tag.
98
+ */
99
+ async function ensureImageFromPipeline() {
100
+ if (await imageExistsLocally(fullImage)) {
101
+ log.info(`Reusing existing image ${fullImage}`);
102
+ return true;
103
+ }
104
+
105
+ const candidates = [...new Set([latestImage, legacyLatestImage])].filter(
106
+ (ref) => ref !== fullImage
107
+ );
108
+
109
+ for (const candidate of candidates) {
110
+ if (!(await imageExistsLocally(candidate))) continue;
111
+ log.info(`Re-tagging pipeline image ${candidate} → ${fullImage}`);
112
+ await execa('docker', ['tag', candidate, fullImage], {
113
+ stdio: 'inherit',
114
+ env: getDockerEnv(),
115
+ });
116
+ return true;
117
+ }
118
+
119
+ return false;
120
+ }
121
+
122
+ /**
123
+ * @param {string} buildContext
124
+ * @param {Record<string, unknown>} metadata
125
+ * @param {string} framework
126
+ * @param {number} port
127
+ */
128
+ async function prepareBackendBuildContext(buildContext, metadata, framework, port) {
129
+ if (framework === 'spring') {
130
+ const targetDir = path.join(buildContext, 'target');
131
+ if (await fs.pathExists(targetDir)) {
132
+ const jars = (await fs.readdir(targetDir)).filter((f) => f.endsWith('.jar'));
133
+ if (jars.length > 0) {
134
+ const jarRel = `target/${jars[0]}`;
135
+ log.info(`Building runtime image from pre-built JAR (${jarRel})...`);
136
+ await fs.writeFile(
137
+ path.join(buildContext, 'Dockerfile'),
138
+ generateSpringRuntimeDockerfile(jarRel, port)
139
+ );
140
+ return;
141
+ }
142
+ }
143
+ }
144
+
145
+ if (framework === 'go') {
146
+ const binDir = path.join(buildContext, 'bin');
147
+ if (await fs.pathExists(binDir)) {
148
+ const bins = await fs.readdir(binDir);
149
+ if (bins.length > 0) {
150
+ const binRel = `bin/${bins[0]}`;
151
+ log.info(`Building runtime image from pre-built Go binary (${binRel})...`);
152
+ await fs.writeFile(
153
+ path.join(buildContext, 'Dockerfile'),
154
+ generateGoRuntimeDockerfile(binRel, port)
155
+ );
156
+ return;
157
+ }
158
+ }
159
+ }
160
+
161
+ if (framework === 'dotnet') {
162
+ const publishDir =
163
+ /** @type {string} */ (metadata.buildOutput) ||
164
+ config.buildOutput ||
165
+ 'publish';
166
+ const publishPath = path.join(buildContext, publishDir);
167
+ if (await fs.pathExists(publishPath)) {
168
+ log.info(`Building runtime image from pre-built .NET output (${publishDir}/)...`);
169
+ await fs.writeFile(
170
+ path.join(buildContext, 'Dockerfile'),
171
+ generateDotnetRuntimeDockerfile(publishDir, port)
172
+ );
173
+ return;
174
+ }
175
+ }
176
+
177
+ const dockerfilePath = path.join(buildContext, 'Dockerfile');
178
+ if (!(await fs.pathExists(dockerfilePath))) {
179
+ throw new Error(
180
+ 'No Dockerfile found in artifact and no pipeline image to reuse. ' +
181
+ 'Add a Dockerfile and enable pipeline.docker so the image is built from project source.'
182
+ );
183
+ }
184
+
185
+ if (isInterpretedBackendFramework(framework)) {
186
+ const gap = describeInterpretedBackendGap(framework);
187
+ throw new Error(
188
+ `Cannot rebuild ${gap.ecosystem} backend image "${fullImage}" from the packaged artifact.\n` +
189
+ `Backend artifacts include source/manifests but not ${gap.missing}, ` +
190
+ `so Dockerfiles that run \`${gap.installCmd}\` cannot reliably succeed from the artifact alone.\n\n` +
191
+ 'What to do instead:\n' +
192
+ ' 1. Enable pipeline.docker in deployhub.config.json (default when Docker deploy is selected).\n' +
193
+ ' 2. Run a full `deployhub build` so the image is built from the project root (with full deps).\n' +
194
+ ' 3. Deploy will reuse that local image (retag/push/run) — it will not rebuild from the artifact.\n\n' +
195
+ `Standalone \`deployhub deploy\` without a pre-built local image is not supported for ${gap.ecosystem} backends.`
196
+ );
197
+ }
198
+
199
+ log.warn(
200
+ 'Building backend image from extracted artifact. Prefer pipeline.docker so the image ' +
201
+ 'is built once from full project source, then reused on deploy.'
202
+ );
203
+ }
204
+
205
+ /**
206
+ * @param {string} artifactDir
207
+ */
208
+ async function buildFromArtifactContents(artifactDir) {
209
+ const zipPath = path.join(artifactDir, 'artifact.zip');
210
+ if (!(await fs.pathExists(zipPath))) {
211
+ throw new Error(
212
+ `No local image found for ${fullImage} and no artifact.zip to build from. ` +
213
+ 'Enable pipeline.docker so the image is built from project source, or run deployhub build first.'
214
+ );
215
+ }
216
+
217
+ const buildContext = path.join(artifactDir, '_docker_build');
218
+ await fs.remove(buildContext);
219
+ await extractArtifact(artifactDir, buildContext);
220
+
221
+ try {
222
+ const metadataPath = path.join(buildContext, 'metadata.json');
223
+ const metadata = (await fs.pathExists(metadataPath))
224
+ ? await fs.readJson(metadataPath)
225
+ : {};
226
+
227
+ const projectType = metadata.projectType || config.projectType || 'frontend';
228
+ const framework =
229
+ metadata.framework ||
230
+ config.framework ||
231
+ config.frontend?.framework ||
232
+ '';
233
+ const buildOutput =
234
+ metadata.buildOutput ||
235
+ config.buildOutput ||
236
+ config.frontend?.buildOutput ||
237
+ 'dist';
238
+ const port =
239
+ Number(metadata.port) ||
240
+ config.port ||
241
+ config.backend?.port ||
242
+ 3000;
243
+
244
+ const composePath = path.join(buildContext, 'docker-compose.yml');
245
+ if (await fs.pathExists(composePath)) {
246
+ log.info('Building via docker compose from extracted artifact...');
247
+ await execa('docker', ['compose', 'up', '-d', '--build'], {
248
+ cwd: buildContext,
249
+ stdio: 'inherit',
250
+ env: getDockerEnv(),
251
+ });
252
+ return { ranCompose: true };
253
+ }
254
+
255
+ const isStaticFrontend =
256
+ projectType === 'frontend' || isFrontendStaticFramework(framework);
257
+
258
+ if (isStaticFrontend) {
259
+ const outputDir = path.join(buildContext, buildOutput);
260
+ if (!(await fs.pathExists(outputDir))) {
261
+ throw new Error(
262
+ `Frontend artifact is missing build output "${buildOutput}". ` +
263
+ 'Cannot build a runtime image from this artifact.'
264
+ );
265
+ }
266
+ log.info(
267
+ `Building runtime image from pre-built ${buildOutput}/ (no source rebuild)...`
268
+ );
269
+ await fs.writeFile(
270
+ path.join(buildContext, 'Dockerfile'),
271
+ generateFrontendRuntimeDockerfile(buildOutput)
272
+ );
273
+ } else {
274
+ await prepareBackendBuildContext(buildContext, metadata, framework, port);
275
+ }
276
+
277
+ await execa('docker', ['build', '-t', fullImage, '.'], {
278
+ cwd: buildContext,
279
+ stdio: 'inherit',
280
+ env: getDockerEnv(),
281
+ });
282
+ return { ranCompose: false };
283
+ } finally {
284
+ await fs.remove(buildContext).catch(() => {});
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Ensure a deployable image exists locally, then push when credentials are set.
290
+ * @param {string} artifactDir
291
+ * @param {{ skipPush?: boolean }} [options]
292
+ */
293
+ async function ensureImageReadyForDeploy(artifactDir, options = {}) {
294
+ await dockerLogin();
295
+
296
+ const reused = await ensureImageFromPipeline();
297
+ let ranCompose = false;
298
+
299
+ if (!reused) {
300
+ const result = await buildFromArtifactContents(artifactDir);
301
+ ranCompose = Boolean(result?.ranCompose);
302
+ }
303
+
304
+ if (ranCompose) {
305
+ return { ranCompose: true };
306
+ }
307
+
308
+ if (!options.skipPush) {
309
+ await maybePushImage();
310
+ }
311
+
312
+ const dockerEnv = getDockerEnv();
313
+ if (imageTag !== 'latest' && fullImage !== latestImage) {
314
+ await execa('docker', ['tag', fullImage, latestImage], {
315
+ stdio: 'pipe',
316
+ env: dockerEnv,
317
+ }).catch(() => {});
318
+ }
319
+
320
+ return { ranCompose: false };
321
+ }
322
+
323
+ return {
324
+ fullImage,
325
+ latestImage,
326
+ imageTag,
327
+ getDockerEnv,
328
+ hasRegistryCredentials,
329
+ dockerLogin,
330
+ maybePushImage,
331
+ ensureImageFromPipeline,
332
+ buildFromArtifactContents,
333
+ ensureImageReadyForDeploy,
334
+ };
335
+ }
336
+
337
+ /**
338
+ * Verify that an image:tag exists in a registry and is accessible with current credentials.
339
+ *
340
+ * @param {import('../core/config.js').DeployHubConfig} config
341
+ * @param {Record<string, string>} [env]
342
+ * @returns {Promise<{ ok: boolean, message: string }>}
343
+ */
344
+ export async function checkImagePullability(config, env = process.env) {
345
+ const { fullImage } = resolveDockerImageRef(config, env);
346
+ const registryUser = env.DOCKER_REGISTRY_USERNAME || '';
347
+ const registryToken = env.DOCKER_REGISTRY_TOKEN || '';
348
+ const registryUrl = env.DOCKER_REGISTRY_URL || '';
349
+ const dockerHost = env.DOCKER_HOST || '';
350
+
351
+ /** @type {Record<string, string>} */
352
+ const dockerEnv = { ...process.env };
353
+ if (dockerHost) dockerEnv.DOCKER_HOST = dockerHost;
354
+ if (env.DOCKER_TLS_VERIFY) dockerEnv.DOCKER_TLS_VERIFY = env.DOCKER_TLS_VERIFY;
355
+ if (env.DOCKER_CERT_PATH) dockerEnv.DOCKER_CERT_PATH = env.DOCKER_CERT_PATH;
356
+
357
+ if (registryUser && registryToken) {
358
+ const registry = registryUrl || 'https://index.docker.io/v1/';
359
+ try {
360
+ await execa(
361
+ 'docker',
362
+ ['login', registry, '-u', registryUser, '--password-stdin'],
363
+ {
364
+ input: registryToken,
365
+ stdio: ['pipe', 'pipe', 'pipe'],
366
+ env: dockerEnv,
367
+ }
368
+ );
369
+ } catch (err) {
370
+ const msg = err instanceof Error ? err.message : String(err);
371
+ return {
372
+ ok: false,
373
+ message:
374
+ `Image ${fullImage} is not pullable — registry login failed (${msg}). ` +
375
+ 'Check DOCKER_REGISTRY_USERNAME and DOCKER_REGISTRY_TOKEN.',
376
+ };
377
+ }
378
+ }
379
+
380
+ try {
381
+ await execa('docker', ['manifest', 'inspect', fullImage], {
382
+ stdio: 'pipe',
383
+ env: dockerEnv,
384
+ });
385
+ return { ok: true, message: `Image ${fullImage} is pullable` };
386
+ } catch (err) {
387
+ const stderr =
388
+ err instanceof Error && 'stderr' in err
389
+ ? String(/** @type {{ stderr?: string }} */ (err).stderr || '')
390
+ : '';
391
+ const combined = `${err instanceof Error ? err.message : String(err)} ${stderr}`.toLowerCase();
392
+
393
+ let hint = '';
394
+ if (combined.includes('unauthorized') || combined.includes('authentication required')) {
395
+ hint =
396
+ 'The image may exist but is private and inaccessible with your current credentials. ' +
397
+ 'Set DOCKER_REGISTRY_USERNAME and DOCKER_REGISTRY_TOKEN, or configure KUBE_IMAGE_PULL_SECRET for the cluster.';
398
+ } else if (combined.includes('not found') || combined.includes('manifest unknown')) {
399
+ hint =
400
+ 'The image does not exist in the registry yet.';
401
+ } else if (combined.includes('denied')) {
402
+ hint = 'Access denied — check registry credentials and repository permissions.';
403
+ } else {
404
+ hint = 'Verify the image name/tag and registry connectivity.';
405
+ }
406
+
407
+ const hasCreds = Boolean(registryUser && registryToken);
408
+
409
+ return {
410
+ ok: false,
411
+ message:
412
+ `Image ${fullImage} is not pullable. ${hint}\n` +
413
+ (hasCreds
414
+ ? ' DeployHub will build and push this image automatically during your next `deployhub build`.\n'
415
+ : ' If you have DOCKER_REGISTRY_USERNAME/DOCKER_REGISTRY_TOKEN configured, DeployHub will build and push this image automatically during your next `deployhub build`.\n' +
416
+ ' If not, either configure those variables, or push the image manually before deploying:\n') +
417
+ ` docker push ${fullImage}`,
418
+ };
419
+ }
420
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Detect whether the CLI can safely prompt the user.
3
+ * Non-interactive when stdin is not a TTY, or when common CI env vars are set.
4
+ *
5
+ * @param {{ env?: NodeJS.ProcessEnv, stdinIsTTY?: boolean|null }} [options]
6
+ * @returns {boolean}
7
+ */
8
+ export function isInteractive(options = {}) {
9
+ const env = options.env || process.env;
10
+ const stdinIsTTY =
11
+ options.stdinIsTTY !== undefined ? options.stdinIsTTY : Boolean(process.stdin.isTTY);
12
+
13
+ if (env.CI === 'true' || env.CI === '1') return false;
14
+ if (env.GITHUB_ACTIONS === 'true' || env.GITHUB_ACTIONS === '1') return false;
15
+ if (!stdinIsTTY) return false;
16
+ return true;
17
+ }
18
+
19
+ /**
20
+ * @param {{ env?: NodeJS.ProcessEnv, stdinIsTTY?: boolean|null }} [options]
21
+ * @returns {boolean}
22
+ */
23
+ export function isNonInteractive(options = {}) {
24
+ return !isInteractive(options);
25
+ }
26
+
27
+ export default { isInteractive, isNonInteractive };
@@ -0,0 +1,130 @@
1
+ import { execa } from 'execa';
2
+ import inquirer from 'inquirer';
3
+ import { isInteractive } from './interactive.js';
4
+
5
+ /**
6
+ * @param {unknown} err
7
+ * @returns {string}
8
+ */
9
+ function kubectlErrorDetail(err) {
10
+ if (err && typeof err === 'object' && 'stderr' in err) {
11
+ const stderr = String(/** @type {{ stderr?: unknown }} */ (err).stderr || '').trim();
12
+ if (stderr) return stderr;
13
+ }
14
+ if (err instanceof Error && err.message) return err.message;
15
+ return String(err);
16
+ }
17
+
18
+ /**
19
+ * Check whether a namespace exists using a cluster-scoped get that exits 0 for
20
+ * both found and missing. Non-zero exits (auth, connectivity, bad kubeconfig)
21
+ * are rethrown — they must not be treated as "not found".
22
+ *
23
+ * @param {string} namespace
24
+ * @param {{
25
+ * kubectlArgs?: (args: string[]) => string[],
26
+ * getKubectlEnv?: () => NodeJS.ProcessEnv,
27
+ * execaFn?: typeof execa,
28
+ * }} [options]
29
+ * @returns {Promise<boolean>}
30
+ */
31
+ export async function namespaceExists(namespace, options = {}) {
32
+ const kubectlArgs = options.kubectlArgs || ((args) => args);
33
+ const getKubectlEnv = options.getKubectlEnv || (() => process.env);
34
+ const execaFn = options.execaFn || execa;
35
+
36
+ try {
37
+ const { stdout } = await execaFn(
38
+ 'kubectl',
39
+ kubectlArgs(['get', 'namespace', namespace, '--ignore-not-found', '-o', 'name']),
40
+ {
41
+ stdio: 'pipe',
42
+ env: getKubectlEnv(),
43
+ }
44
+ );
45
+ return Boolean(stdout && String(stdout).trim());
46
+ } catch (err) {
47
+ throw new Error(
48
+ `Failed to check whether namespace '${namespace}' exists: ${kubectlErrorDetail(err)}`,
49
+ { cause: err instanceof Error ? err : undefined }
50
+ );
51
+ }
52
+ }
53
+
54
+ /**
55
+ * @param {string} namespace
56
+ * @returns {Promise<boolean>}
57
+ */
58
+ async function defaultConfirmCreate(namespace) {
59
+ const { create } = await inquirer.prompt([
60
+ {
61
+ type: 'confirm',
62
+ name: 'create',
63
+ message: `Namespace '${namespace}' does not exist. Create it now?`,
64
+ default: true,
65
+ },
66
+ ]);
67
+ return Boolean(create);
68
+ }
69
+
70
+ /**
71
+ * Ensure the target namespace exists before kubectl apply.
72
+ * Interactive: prompt to create. CI / non-TTY: auto-create with a clear log.
73
+ * Connectivity/auth failures from the existence check abort before create/prompt.
74
+ *
75
+ * @param {{
76
+ * namespace: string,
77
+ * log: { info: Function, warn: Function, success: Function, error?: Function },
78
+ * kubectlArgs?: (args: string[]) => string[],
79
+ * getKubectlEnv?: () => NodeJS.ProcessEnv,
80
+ * execaFn?: typeof execa,
81
+ * confirmFn?: (namespace: string) => Promise<boolean>,
82
+ * interactive?: boolean,
83
+ * }} options
84
+ * @returns {Promise<{ existed: boolean, created: boolean }>}
85
+ */
86
+ export async function ensureKubernetesNamespace(options) {
87
+ const {
88
+ namespace,
89
+ log,
90
+ kubectlArgs = (args) => args,
91
+ getKubectlEnv = () => process.env,
92
+ execaFn = execa,
93
+ confirmFn = defaultConfirmCreate,
94
+ interactive = isInteractive(),
95
+ } = options;
96
+
97
+ if (!namespace) {
98
+ throw new Error('Kubernetes namespace is empty — set KUBE_NAMESPACE or config.project.');
99
+ }
100
+
101
+ // Throws on auth/connectivity/kubeconfig errors — only false means genuine NotFound.
102
+ if (await namespaceExists(namespace, { kubectlArgs, getKubectlEnv, execaFn })) {
103
+ return { existed: true, created: false };
104
+ }
105
+
106
+ log.warn(`Namespace '${namespace}' was not found on the cluster.`);
107
+
108
+ let shouldCreate = true;
109
+ if (interactive) {
110
+ shouldCreate = await confirmFn(namespace);
111
+ if (!shouldCreate) {
112
+ throw new Error(
113
+ `Namespace '${namespace}' does not exist. Create it manually with: kubectl create namespace ${namespace}`
114
+ );
115
+ }
116
+ } else {
117
+ log.info(
118
+ `Non-interactive session detected — creating namespace '${namespace}' automatically.`
119
+ );
120
+ }
121
+
122
+ await execaFn('kubectl', kubectlArgs(['create', 'namespace', namespace]), {
123
+ stdio: 'inherit',
124
+ env: getKubectlEnv(),
125
+ });
126
+ log.success(`Created namespace '${namespace}'`);
127
+ return { existed: false, created: true };
128
+ }
129
+
130
+ export default { namespaceExists, ensureKubernetesNamespace };