@akash-chowdhury-24/deployhub 2.0.8 → 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.8",
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';
@@ -20,6 +21,7 @@ import {
20
21
  } from '../utils/shell-quote.js';
21
22
  import { formatPasswordlessSudoGuidance } from '../utils/nginx.js';
22
23
  import { checkImagePullability } from '../utils/docker-image-deploy.js';
24
+ import { namespaceExists } from '../utils/kubernetes-namespace.js';
23
25
 
24
26
  /**
25
27
  * @typedef {{ name: string, pass: boolean, message: string }} CheckResult
@@ -412,6 +414,42 @@ async function runDeploymentChecks(config, envName, envConfig) {
412
414
  })
413
415
  );
414
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
+
415
453
  checks.push(
416
454
  await runCheck('Container image pullable', async () => {
417
455
  const result = await checkImagePullability(config, process.env);
@@ -309,7 +309,7 @@ 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',
@@ -720,7 +720,7 @@ export const DEPLOYMENT_GUIDE = {
720
720
  ],
721
721
  after: [
722
722
  'Ensure your kubeconfig context points to the correct cluster.',
723
- 'Create namespace if needed: kubectl create namespace YOUR_NAMESPACE',
723
+ 'Namespace is created on first deploy if missing (prompt locally; auto-create in CI). Or: kubectl create namespace YOUR_NAMESPACE',
724
724
  'Copy .env.example to .env and set DOCKER_IMAGE_NAME, DOCKER_REGISTRY_USERNAME, and DOCKER_REGISTRY_TOKEN.',
725
725
  'Skipping registry credentials will very likely cause ImagePullBackOff — the cluster cannot see local Docker images.',
726
726
  'For private registries: also create kubectl create secret docker-registry ... and set KUBE_IMAGE_PULL_SECRET.',
@@ -5,6 +5,7 @@ import os from 'os';
5
5
  import { createLogger } from '../../logger/index.js';
6
6
  import { sanitizeK8sName } from '../../utils/kubernetes-manifests.js';
7
7
  import { createDockerImageDeployContext } from '../../utils/docker-image-deploy.js';
8
+ import { ensureKubernetesNamespace } from '../../utils/kubernetes-namespace.js';
8
9
 
9
10
  /**
10
11
  * @param {import('../../core/config.js').DeployHubConfig} config
@@ -26,12 +27,22 @@ export function createKubernetesProvider(config, envName, env = process.env) {
26
27
  }
27
28
 
28
29
  /**
30
+ * Cluster-scoped kubectl args (context only). Used for Namespace get/create.
29
31
  * @param {string[]} baseArgs
30
32
  */
31
- function kubectlArgs(baseArgs) {
33
+ function kubectlClusterArgs(baseArgs) {
32
34
  /** @type {string[]} */
33
35
  const args = [...baseArgs];
34
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);
35
46
  if (namespace) args.push('--namespace', namespace);
36
47
  return args;
37
48
  }
@@ -61,6 +72,13 @@ export function createKubernetesProvider(config, envName, env = process.env) {
61
72
  );
62
73
  }
63
74
 
75
+ await ensureKubernetesNamespace({
76
+ namespace,
77
+ log,
78
+ kubectlArgs: kubectlClusterArgs,
79
+ getKubectlEnv,
80
+ });
81
+
64
82
  const applyTarget = (await fs.pathExists(path.join(manifestDir, 'k8s')))
65
83
  ? path.join(manifestDir, 'k8s')
66
84
  : manifestDir;
@@ -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 };