@hexabot-ai/cli 3.0.0-alpha.3 → 3.1.1-alpha.0

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.
@@ -0,0 +1,158 @@
1
+ /*
2
+ * Hexabot — Fair Core License (FCL-1.0-ALv2)
3
+ * Copyright (c) 2025 Hexastack.
4
+ * Full terms: see LICENSE.md.
5
+ */
6
+ import fs from 'fs';
7
+ import * as os from 'os';
8
+ import * as path from 'path';
9
+ import { jest } from '@jest/globals';
10
+ import { Command } from 'commander';
11
+ const ensureProjectConfig = jest.fn();
12
+ const loadProjectConfig = jest.fn();
13
+ const bootstrapEnvFile = jest.fn();
14
+ const upsertEnvVariables = jest.fn();
15
+ const detectPackageManager = jest.fn();
16
+ const installDependencies = jest.fn();
17
+ const normalizePackageManager = jest.fn();
18
+ const downloadAndExtractTemplate = jest.fn();
19
+ const validateProjectName = jest.fn();
20
+ const runDev = jest.fn();
21
+ const input = jest.fn();
22
+ const password = jest.fn();
23
+ jest.unstable_mockModule('../../core/config.js', () => ({
24
+ ensureProjectConfig,
25
+ loadProjectConfig,
26
+ }));
27
+ jest.unstable_mockModule('../../core/env.js', () => ({
28
+ bootstrapEnvFile,
29
+ upsertEnvVariables,
30
+ }));
31
+ jest.unstable_mockModule('../../core/package-manager.js', () => ({
32
+ detectPackageManager,
33
+ installDependencies,
34
+ normalizePackageManager,
35
+ }));
36
+ jest.unstable_mockModule('../../services/templates.js', () => ({
37
+ downloadAndExtractTemplate,
38
+ }));
39
+ jest.unstable_mockModule('../../utils/validation.js', () => ({
40
+ validateProjectName,
41
+ }));
42
+ jest.unstable_mockModule('../dev.js', () => ({
43
+ runDev,
44
+ }));
45
+ jest.unstable_mockModule('@inquirer/prompts', () => ({
46
+ input,
47
+ password,
48
+ }));
49
+ let registerCreateCommand;
50
+ const initialCwd = process.cwd();
51
+ const initialStdinTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY');
52
+ const initialStdoutTTY = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY');
53
+ let tempDir;
54
+ let exitSpy;
55
+ const restoreTTY = () => {
56
+ if (initialStdinTTY) {
57
+ Object.defineProperty(process.stdin, 'isTTY', initialStdinTTY);
58
+ }
59
+ else {
60
+ delete process.stdin.isTTY;
61
+ }
62
+ if (initialStdoutTTY) {
63
+ Object.defineProperty(process.stdout, 'isTTY', initialStdoutTTY);
64
+ }
65
+ else {
66
+ delete process.stdout.isTTY;
67
+ }
68
+ };
69
+ const setTTY = (enabled) => {
70
+ Object.defineProperty(process.stdin, 'isTTY', {
71
+ configurable: true,
72
+ value: enabled,
73
+ });
74
+ Object.defineProperty(process.stdout, 'isTTY', {
75
+ configurable: true,
76
+ value: enabled,
77
+ });
78
+ };
79
+ beforeAll(async () => {
80
+ ({ registerCreateCommand } = await import('../create.js'));
81
+ });
82
+ beforeEach(() => {
83
+ jest.resetAllMocks();
84
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hexabot-create-'));
85
+ process.chdir(tempDir);
86
+ setTTY(true);
87
+ validateProjectName.mockReturnValue(true);
88
+ detectPackageManager.mockReturnValue('pnpm');
89
+ normalizePackageManager.mockImplementation((value) => {
90
+ return typeof value === 'string' ? value.toLowerCase() : undefined;
91
+ });
92
+ loadProjectConfig.mockReturnValue({
93
+ packageManager: 'pnpm',
94
+ env: {
95
+ local: '.env',
96
+ localExample: '.env.example',
97
+ docker: '.env.docker',
98
+ dockerExample: '.env.docker.example',
99
+ },
100
+ });
101
+ globalThis.fetch = jest.fn(async () => ({
102
+ ok: true,
103
+ json: async () => ({ tag_name: 'v1.0.0' }),
104
+ }));
105
+ exitSpy = jest.spyOn(process, 'exit').mockImplementation((code) => {
106
+ throw new Error(`process.exit:${code ?? ''}`);
107
+ });
108
+ });
109
+ afterEach(() => {
110
+ process.chdir(initialCwd);
111
+ fs.rmSync(tempDir, { recursive: true, force: true });
112
+ restoreTTY();
113
+ jest.restoreAllMocks();
114
+ });
115
+ describe('registerCreateCommand', () => {
116
+ it('prompts admin credentials and persists them to local env values', async () => {
117
+ input
118
+ .mockResolvedValueOnce('Anis')
119
+ .mockResolvedValueOnce('Bot')
120
+ .mockResolvedValueOnce('anis@example.com');
121
+ password.mockResolvedValueOnce('Admin#123');
122
+ const program = new Command();
123
+ registerCreateCommand(program);
124
+ await program.parseAsync([
125
+ 'node',
126
+ 'test',
127
+ 'create',
128
+ 'anisbot',
129
+ '--template',
130
+ 'marrouchi/hexabot-v3-template',
131
+ ]);
132
+ const [templateUrl, projectPath] = downloadAndExtractTemplate.mock
133
+ .calls[0];
134
+ expect(templateUrl).toBe('https://github.com/marrouchi/hexabot-v3-template/archive/refs/tags/v1.0.0.zip');
135
+ expect(projectPath.endsWith(`${path.sep}anisbot`)).toBe(true);
136
+ expect(bootstrapEnvFile).toHaveBeenCalledWith(projectPath, '.env.example', '.env', { quiet: true });
137
+ expect(upsertEnvVariables).toHaveBeenCalledWith(projectPath, '.env', {
138
+ SEED_ADMIN_FIRST_NAME: 'Anis',
139
+ SEED_ADMIN_LAST_NAME: 'Bot',
140
+ SEED_ADMIN_EMAIL: 'anis@example.com',
141
+ SEED_ADMIN_PASSWORD: 'Admin#123',
142
+ });
143
+ expect(exitSpy).not.toHaveBeenCalled();
144
+ expect(input).toHaveBeenCalledTimes(3);
145
+ expect(password).toHaveBeenCalledTimes(1);
146
+ });
147
+ it('fails cleanly when create runs in a non-interactive terminal', async () => {
148
+ setTTY(false);
149
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
150
+ const program = new Command();
151
+ registerCreateCommand(program);
152
+ await expect(program.parseAsync(['node', 'test', 'create', 'anisbot'])).rejects.toThrow('process.exit:1');
153
+ expect(errorSpy).toHaveBeenCalled();
154
+ expect(input).not.toHaveBeenCalled();
155
+ expect(password).not.toHaveBeenCalled();
156
+ expect(upsertEnvVariables).not.toHaveBeenCalled();
157
+ });
158
+ });
@@ -5,9 +5,10 @@
5
5
  */
6
6
  import * as fs from 'fs';
7
7
  import * as path from 'path';
8
+ import { input, password } from '@inquirer/prompts';
8
9
  import chalk from 'chalk';
9
10
  import { ensureProjectConfig, loadProjectConfig } from '../core/config.js';
10
- import { bootstrapEnvFile } from '../core/env.js';
11
+ import { bootstrapEnvFile, upsertEnvVariables } from '../core/env.js';
11
12
  import { detectPackageManager, installDependencies, normalizePackageManager, } from '../core/package-manager.js';
12
13
  import { downloadAndExtractTemplate } from '../services/templates.js';
13
14
  import { validateProjectName } from '../utils/validation.js';
@@ -54,6 +55,13 @@ const createProject = async (projectName, options) => {
54
55
  if (options.docker) {
55
56
  bootstrapEnvFile(projectPath, config.env.dockerExample, config.env.docker, { quiet: true });
56
57
  }
58
+ const adminCredentials = await promptSeedAdminCredentials();
59
+ upsertEnvVariables(projectPath, config.env.local, {
60
+ SEED_ADMIN_FIRST_NAME: adminCredentials.firstName,
61
+ SEED_ADMIN_LAST_NAME: adminCredentials.lastName,
62
+ SEED_ADMIN_EMAIL: adminCredentials.email,
63
+ SEED_ADMIN_PASSWORD: adminCredentials.password,
64
+ });
57
65
  if (options.noInstall) {
58
66
  console.log(chalk.yellow('Skipping dependency installation (--no-install).'));
59
67
  }
@@ -109,11 +117,60 @@ const fetchLatestReleaseTag = async (templateRepo) => {
109
117
  }
110
118
  return data.tag_name;
111
119
  };
120
+ const requireValue = (label) => {
121
+ return (value) => {
122
+ if (!value.trim()) {
123
+ return `${label} is required.`;
124
+ }
125
+ return true;
126
+ };
127
+ };
128
+ const validateEmail = (value) => {
129
+ if (!value.trim()) {
130
+ return 'Email is required.';
131
+ }
132
+ const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
133
+ if (!emailPattern.test(value.trim())) {
134
+ return 'Enter a valid email address.';
135
+ }
136
+ return true;
137
+ };
138
+ const assertInteractiveTerminal = () => {
139
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
140
+ throw new Error('hexabot create requires an interactive terminal to capture admin credentials.');
141
+ }
142
+ };
143
+ const promptSeedAdminCredentials = async () => {
144
+ assertInteractiveTerminal();
145
+ const firstName = (await input({
146
+ message: 'Admin first name',
147
+ validate: requireValue('First name'),
148
+ })).trim();
149
+ const lastName = (await input({
150
+ message: 'Admin last name',
151
+ validate: requireValue('Last name'),
152
+ })).trim();
153
+ const email = (await input({
154
+ message: 'Admin email',
155
+ validate: validateEmail,
156
+ })).trim();
157
+ const adminPassword = await password({
158
+ message: 'Admin password',
159
+ mask: '*',
160
+ validate: requireValue('Password'),
161
+ });
162
+ return {
163
+ firstName,
164
+ lastName,
165
+ email,
166
+ password: adminPassword,
167
+ };
168
+ };
112
169
  const logSuccessMessage = (projectName, options) => {
113
170
  console.log('\n');
114
171
  console.log(chalk.green(`🎉 Project ${projectName} created successfully.`));
115
172
  console.log('\n');
116
- console.log(chalk.bgYellow.black(`Next steps:`));
173
+ console.log(chalk.bgYellow(`Next steps:`));
117
174
  console.log(chalk.gray(`1. Navigate to the project folder:`));
118
175
  console.log(chalk.yellow(` cd ${projectName}`));
119
176
  if (options.docker) {
@@ -126,6 +183,6 @@ const logSuccessMessage = (projectName, options) => {
126
183
  }
127
184
  console.log(chalk.gray(`3. Explore docker helpers if needed:`));
128
185
  console.log(chalk.yellow(` hexabot docker up --services postgres`));
129
- console.log(chalk.gray(`Need env files? Run ${chalk.white('hexabot env init --docker')}`));
186
+ console.log(chalk.gray(`Need env files? Run hexabot env init --docker`));
130
187
  console.log('\n');
131
188
  };
@@ -7,7 +7,7 @@ import fs from 'fs';
7
7
  import * as os from 'os';
8
8
  import * as path from 'path';
9
9
  import { jest } from '@jest/globals';
10
- import { bootstrapEnvFile, listEnvStatus, resolveEnvExample } from '../env.js';
10
+ import { bootstrapEnvFile, listEnvStatus, resolveEnvExample, upsertEnvVariables, } from '../env.js';
11
11
  describe('env helpers', () => {
12
12
  let tempDir;
13
13
  beforeEach(() => {
@@ -68,4 +68,24 @@ describe('env helpers', () => {
68
68
  expect(resolveEnvExample(tempDir, envFile, defaultExample)).toBe(`${envFile}.example`);
69
69
  expect(resolveEnvExample(tempDir, '.missing', defaultExample)).toBe(defaultExample);
70
70
  });
71
+ it('upserts env variables without duplicating existing keys', () => {
72
+ fs.writeFileSync(path.join(tempDir, '.env'), [
73
+ 'PORT=3000',
74
+ 'SEED_ADMIN_EMAIL=old@example.com',
75
+ 'SEED_ADMIN_EMAIL=legacy@example.com',
76
+ ].join('\n'));
77
+ upsertEnvVariables(tempDir, '.env', {
78
+ SEED_ADMIN_EMAIL: 'new@example.com',
79
+ SEED_ADMIN_PASSWORD: 'P@ss "word"',
80
+ });
81
+ const nextEnv = fs.readFileSync(path.join(tempDir, '.env'), 'utf-8');
82
+ const emailMatches = nextEnv.match(/^SEED_ADMIN_EMAIL=/gm) || [];
83
+ expect(emailMatches).toHaveLength(1);
84
+ expect(nextEnv).toContain('SEED_ADMIN_EMAIL=new@example.com');
85
+ expect(nextEnv).toContain('SEED_ADMIN_PASSWORD="P@ss \\"word\\""');
86
+ expect(nextEnv.endsWith('\n')).toBe(true);
87
+ });
88
+ it('throws when attempting to upsert a missing env file', () => {
89
+ expect(() => upsertEnvVariables(tempDir, '.missing', { KEY: 'value' })).toThrow('Env file ".missing" is missing.');
90
+ });
71
91
  });
package/dist/core/env.js CHANGED
@@ -48,3 +48,50 @@ export const resolveEnvExample = (projectRoot, envFile, defaultExample) => {
48
48
  }
49
49
  return defaultExample;
50
50
  };
51
+ const ENV_BARE_VALUE = /^[A-Za-z0-9._/:@-]*$/;
52
+ const formatEnvValue = (value) => {
53
+ if (ENV_BARE_VALUE.test(value)) {
54
+ return value;
55
+ }
56
+ return `"${value
57
+ .replace(/\\/g, '\\\\')
58
+ .replace(/"/g, '\\"')
59
+ .replace(/\n/g, '\\n')}"`;
60
+ };
61
+ const escapeRegExp = (value) => {
62
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
63
+ };
64
+ export const upsertEnvVariables = (projectRoot, envFile, values) => {
65
+ const envPath = path.join(projectRoot, envFile);
66
+ if (!fs.existsSync(envPath)) {
67
+ throw new Error(`Env file "${envFile}" is missing.`);
68
+ }
69
+ const source = fs.readFileSync(envPath, 'utf-8');
70
+ let lines = source.length > 0 ? source.split(/\r?\n/) : [];
71
+ // Remove trailing blank line to avoid repetitive gaps after writes.
72
+ while (lines.length > 0 && lines[lines.length - 1] === '') {
73
+ lines.pop();
74
+ }
75
+ for (const [key, rawValue] of Object.entries(values)) {
76
+ const keyPattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`);
77
+ const nextLine = `${key}=${formatEnvValue(rawValue)}`;
78
+ let updated = false;
79
+ const nextLines = [];
80
+ for (const line of lines) {
81
+ if (!keyPattern.test(line)) {
82
+ nextLines.push(line);
83
+ continue;
84
+ }
85
+ if (!updated) {
86
+ nextLines.push(nextLine);
87
+ updated = true;
88
+ }
89
+ // Skip duplicate declarations for the same key.
90
+ }
91
+ if (!updated) {
92
+ nextLines.push(nextLine);
93
+ }
94
+ lines = nextLines;
95
+ }
96
+ fs.writeFileSync(envPath, `${lines.join('\n')}\n`, 'utf-8');
97
+ };
package/dist/index.js CHANGED
@@ -7,10 +7,11 @@
7
7
  import { createCliProgram } from './cli.js';
8
8
  import { checkPrerequisites } from './core/prerequisites.js';
9
9
  import { printBanner } from './ui/banner.js';
10
+ const cliArgs = process.argv.slice(2);
10
11
  printBanner();
11
12
  checkPrerequisites({ silent: true });
12
13
  const program = createCliProgram();
13
14
  program.parse(process.argv);
14
- if (!process.argv.slice(2).length) {
15
+ if (!cliArgs.length) {
15
16
  program.outputHelp();
16
17
  }
package/eslint.config.cjs CHANGED
@@ -33,7 +33,10 @@ const createConfig = ({ headerYear = '2025' } = {}) => {
33
33
  {
34
34
  ignores: ['dist', 'eslint.config.cjs', 'eslint.config-staged.cjs'],
35
35
  },
36
- ...compat.extends('plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'),
36
+ ...compat.extends(
37
+ 'plugin:@typescript-eslint/recommended',
38
+ 'plugin:prettier/recommended',
39
+ ),
37
40
  {
38
41
  files: ['**/*.ts'],
39
42
  languageOptions: {
@@ -77,12 +80,21 @@ const createConfig = ({ headerYear = '2025' } = {}) => {
77
80
  ],
78
81
  'lines-between-class-members': ['warn', 'always'],
79
82
  'no-console': 'off',
80
- 'no-duplicate-imports': 2,
83
+ 'no-duplicate-imports': 'off',
84
+ 'import/no-duplicates': 'error',
81
85
  'object-shorthand': 1,
82
86
  'import/order': [
83
87
  'error',
84
88
  {
85
- groups: ['builtin', 'external', 'unknown', 'parent', 'sibling', 'index', 'internal'],
89
+ groups: [
90
+ 'builtin',
91
+ 'external',
92
+ 'unknown',
93
+ 'parent',
94
+ 'sibling',
95
+ 'index',
96
+ 'internal',
97
+ ],
86
98
  'newlines-between': 'always',
87
99
  alphabetize: {
88
100
  order: 'asc',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hexabot-ai/cli",
3
- "version": "3.0.0-alpha.3",
3
+ "version": "3.1.1-alpha.0",
4
4
  "description": "Official Hexabot CLI for creating and managing AI chatbot/agent projects built with Hexabot.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -24,11 +24,12 @@
24
24
  },
25
25
  "keywords": [],
26
26
  "author": "Hexastack",
27
- "license": "AGPL-3.0-only",
27
+ "license": "FCL-1.0-ALv2",
28
28
  "lint-staged": {
29
29
  "*.{ts}": "eslint --fix --config eslint.config-staged.cjs"
30
30
  },
31
31
  "dependencies": {
32
+ "@inquirer/prompts": "^7.10.1",
32
33
  "axios": "^1.7.7",
33
34
  "chalk": "^5.3.0",
34
35
  "commander": "^12.1.0",
@@ -0,0 +1,192 @@
1
+ /*
2
+ * Hexabot — Fair Core License (FCL-1.0-ALv2)
3
+ * Copyright (c) 2025 Hexastack.
4
+ * Full terms: see LICENSE.md.
5
+ */
6
+
7
+ import fs from 'fs';
8
+ import * as os from 'os';
9
+ import * as path from 'path';
10
+
11
+ import { jest } from '@jest/globals';
12
+ import { Command } from 'commander';
13
+
14
+ const ensureProjectConfig = jest.fn();
15
+ const loadProjectConfig = jest.fn();
16
+ const bootstrapEnvFile = jest.fn();
17
+ const upsertEnvVariables = jest.fn();
18
+ const detectPackageManager = jest.fn();
19
+ const installDependencies = jest.fn();
20
+ const normalizePackageManager = jest.fn();
21
+ const downloadAndExtractTemplate = jest.fn();
22
+ const validateProjectName = jest.fn();
23
+ const runDev = jest.fn();
24
+ const input = jest.fn();
25
+ const password = jest.fn();
26
+
27
+ jest.unstable_mockModule('../../core/config.js', () => ({
28
+ ensureProjectConfig,
29
+ loadProjectConfig,
30
+ }));
31
+
32
+ jest.unstable_mockModule('../../core/env.js', () => ({
33
+ bootstrapEnvFile,
34
+ upsertEnvVariables,
35
+ }));
36
+
37
+ jest.unstable_mockModule('../../core/package-manager.js', () => ({
38
+ detectPackageManager,
39
+ installDependencies,
40
+ normalizePackageManager,
41
+ }));
42
+
43
+ jest.unstable_mockModule('../../services/templates.js', () => ({
44
+ downloadAndExtractTemplate,
45
+ }));
46
+
47
+ jest.unstable_mockModule('../../utils/validation.js', () => ({
48
+ validateProjectName,
49
+ }));
50
+
51
+ jest.unstable_mockModule('../dev.js', () => ({
52
+ runDev,
53
+ }));
54
+
55
+ jest.unstable_mockModule('@inquirer/prompts', () => ({
56
+ input,
57
+ password,
58
+ }));
59
+
60
+ let registerCreateCommand: (program: Command) => void;
61
+
62
+ const initialCwd = process.cwd();
63
+ const initialStdinTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY');
64
+ const initialStdoutTTY = Object.getOwnPropertyDescriptor(
65
+ process.stdout,
66
+ 'isTTY',
67
+ );
68
+ let tempDir: string;
69
+ let exitSpy: ReturnType<typeof jest.spyOn>;
70
+
71
+ const restoreTTY = () => {
72
+ if (initialStdinTTY) {
73
+ Object.defineProperty(process.stdin, 'isTTY', initialStdinTTY);
74
+ } else {
75
+ delete (process.stdin as { isTTY?: boolean }).isTTY;
76
+ }
77
+
78
+ if (initialStdoutTTY) {
79
+ Object.defineProperty(process.stdout, 'isTTY', initialStdoutTTY);
80
+ } else {
81
+ delete (process.stdout as { isTTY?: boolean }).isTTY;
82
+ }
83
+ };
84
+ const setTTY = (enabled: boolean) => {
85
+ Object.defineProperty(process.stdin, 'isTTY', {
86
+ configurable: true,
87
+ value: enabled,
88
+ });
89
+ Object.defineProperty(process.stdout, 'isTTY', {
90
+ configurable: true,
91
+ value: enabled,
92
+ });
93
+ };
94
+
95
+ beforeAll(async () => {
96
+ ({ registerCreateCommand } = await import('../create.js'));
97
+ });
98
+
99
+ beforeEach(() => {
100
+ jest.resetAllMocks();
101
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hexabot-create-'));
102
+ process.chdir(tempDir);
103
+ setTTY(true);
104
+
105
+ validateProjectName.mockReturnValue(true);
106
+ detectPackageManager.mockReturnValue('pnpm');
107
+ (normalizePackageManager as any).mockImplementation((value: unknown) => {
108
+ return typeof value === 'string' ? value.toLowerCase() : undefined;
109
+ });
110
+ loadProjectConfig.mockReturnValue({
111
+ packageManager: 'pnpm',
112
+ env: {
113
+ local: '.env',
114
+ localExample: '.env.example',
115
+ docker: '.env.docker',
116
+ dockerExample: '.env.docker.example',
117
+ },
118
+ });
119
+ (globalThis as any).fetch = jest.fn(async () => ({
120
+ ok: true,
121
+ json: async () => ({ tag_name: 'v1.0.0' }),
122
+ }));
123
+ exitSpy = jest.spyOn(process, 'exit').mockImplementation((code?: any) => {
124
+ throw new Error(`process.exit:${code ?? ''}`);
125
+ });
126
+ });
127
+
128
+ afterEach(() => {
129
+ process.chdir(initialCwd);
130
+ fs.rmSync(tempDir, { recursive: true, force: true });
131
+ restoreTTY();
132
+ jest.restoreAllMocks();
133
+ });
134
+
135
+ describe('registerCreateCommand', () => {
136
+ it('prompts admin credentials and persists them to local env values', async () => {
137
+ (input as any)
138
+ .mockResolvedValueOnce('Anis')
139
+ .mockResolvedValueOnce('Bot')
140
+ .mockResolvedValueOnce('anis@example.com');
141
+ (password as any).mockResolvedValueOnce('Admin#123');
142
+
143
+ const program = new Command();
144
+ registerCreateCommand(program);
145
+
146
+ await program.parseAsync([
147
+ 'node',
148
+ 'test',
149
+ 'create',
150
+ 'anisbot',
151
+ '--template',
152
+ 'marrouchi/hexabot-v3-template',
153
+ ]);
154
+
155
+ const [templateUrl, projectPath] = (downloadAndExtractTemplate as any).mock
156
+ .calls[0];
157
+ expect(templateUrl).toBe(
158
+ 'https://github.com/marrouchi/hexabot-v3-template/archive/refs/tags/v1.0.0.zip',
159
+ );
160
+ expect(projectPath.endsWith(`${path.sep}anisbot`)).toBe(true);
161
+ expect(bootstrapEnvFile).toHaveBeenCalledWith(
162
+ projectPath,
163
+ '.env.example',
164
+ '.env',
165
+ { quiet: true },
166
+ );
167
+ expect(upsertEnvVariables).toHaveBeenCalledWith(projectPath, '.env', {
168
+ SEED_ADMIN_FIRST_NAME: 'Anis',
169
+ SEED_ADMIN_LAST_NAME: 'Bot',
170
+ SEED_ADMIN_EMAIL: 'anis@example.com',
171
+ SEED_ADMIN_PASSWORD: 'Admin#123',
172
+ });
173
+ expect(exitSpy).not.toHaveBeenCalled();
174
+ expect(input).toHaveBeenCalledTimes(3);
175
+ expect(password).toHaveBeenCalledTimes(1);
176
+ });
177
+
178
+ it('fails cleanly when create runs in a non-interactive terminal', async () => {
179
+ setTTY(false);
180
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
181
+ const program = new Command();
182
+ registerCreateCommand(program);
183
+
184
+ await expect(
185
+ program.parseAsync(['node', 'test', 'create', 'anisbot']),
186
+ ).rejects.toThrow('process.exit:1');
187
+ expect(errorSpy).toHaveBeenCalled();
188
+ expect(input).not.toHaveBeenCalled();
189
+ expect(password).not.toHaveBeenCalled();
190
+ expect(upsertEnvVariables).not.toHaveBeenCalled();
191
+ });
192
+ });
@@ -7,11 +7,12 @@
7
7
  import * as fs from 'fs';
8
8
  import * as path from 'path';
9
9
 
10
+ import { input, password } from '@inquirer/prompts';
10
11
  import chalk from 'chalk';
11
12
  import { Command } from 'commander';
12
13
 
13
14
  import { ensureProjectConfig, loadProjectConfig } from '../core/config.js';
14
- import { bootstrapEnvFile } from '../core/env.js';
15
+ import { bootstrapEnvFile, upsertEnvVariables } from '../core/env.js';
15
16
  import {
16
17
  detectPackageManager,
17
18
  installDependencies,
@@ -33,6 +34,13 @@ interface CreateCommandOptions {
33
34
  force?: boolean;
34
35
  }
35
36
 
37
+ interface AdminSeedCredentials {
38
+ firstName: string;
39
+ lastName: string;
40
+ email: string;
41
+ password: string;
42
+ }
43
+
36
44
  export const registerCreateCommand = (program: Command) => {
37
45
  program
38
46
  .command('create <projectName>')
@@ -97,6 +105,14 @@ const createProject = async (
97
105
  );
98
106
  }
99
107
 
108
+ const adminCredentials = await promptSeedAdminCredentials();
109
+ upsertEnvVariables(projectPath, config.env.local, {
110
+ SEED_ADMIN_FIRST_NAME: adminCredentials.firstName,
111
+ SEED_ADMIN_LAST_NAME: adminCredentials.lastName,
112
+ SEED_ADMIN_EMAIL: adminCredentials.email,
113
+ SEED_ADMIN_PASSWORD: adminCredentials.password,
114
+ });
115
+
100
116
  if (options.noInstall) {
101
117
  console.log(
102
118
  chalk.yellow('Skipping dependency installation (--no-install).'),
@@ -169,6 +185,68 @@ const fetchLatestReleaseTag = async (templateRepo: string) => {
169
185
 
170
186
  return data.tag_name;
171
187
  };
188
+ const requireValue = (label: string) => {
189
+ return (value: string) => {
190
+ if (!value.trim()) {
191
+ return `${label} is required.`;
192
+ }
193
+
194
+ return true;
195
+ };
196
+ };
197
+ const validateEmail = (value: string) => {
198
+ if (!value.trim()) {
199
+ return 'Email is required.';
200
+ }
201
+
202
+ const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
203
+ if (!emailPattern.test(value.trim())) {
204
+ return 'Enter a valid email address.';
205
+ }
206
+
207
+ return true;
208
+ };
209
+ const assertInteractiveTerminal = () => {
210
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
211
+ throw new Error(
212
+ 'hexabot create requires an interactive terminal to capture admin credentials.',
213
+ );
214
+ }
215
+ };
216
+ const promptSeedAdminCredentials = async (): Promise<AdminSeedCredentials> => {
217
+ assertInteractiveTerminal();
218
+
219
+ const firstName = (
220
+ await input({
221
+ message: 'Admin first name',
222
+ validate: requireValue('First name'),
223
+ })
224
+ ).trim();
225
+ const lastName = (
226
+ await input({
227
+ message: 'Admin last name',
228
+ validate: requireValue('Last name'),
229
+ })
230
+ ).trim();
231
+ const email = (
232
+ await input({
233
+ message: 'Admin email',
234
+ validate: validateEmail,
235
+ })
236
+ ).trim();
237
+ const adminPassword = await password({
238
+ message: 'Admin password',
239
+ mask: '*',
240
+ validate: requireValue('Password'),
241
+ });
242
+
243
+ return {
244
+ firstName,
245
+ lastName,
246
+ email,
247
+ password: adminPassword,
248
+ };
249
+ };
172
250
  const logSuccessMessage = (
173
251
  projectName: string,
174
252
  options: { docker?: boolean },
@@ -176,7 +254,7 @@ const logSuccessMessage = (
176
254
  console.log('\n');
177
255
  console.log(chalk.green(`🎉 Project ${projectName} created successfully.`));
178
256
  console.log('\n');
179
- console.log(chalk.bgYellow.black(`Next steps:`));
257
+ console.log(chalk.bgYellow(`Next steps:`));
180
258
  console.log(chalk.gray(`1. Navigate to the project folder:`));
181
259
  console.log(chalk.yellow(` cd ${projectName}`));
182
260
  if (options.docker) {
@@ -192,10 +270,6 @@ const logSuccessMessage = (
192
270
  }
193
271
  console.log(chalk.gray(`3. Explore docker helpers if needed:`));
194
272
  console.log(chalk.yellow(` hexabot docker up --services postgres`));
195
- console.log(
196
- chalk.gray(
197
- `Need env files? Run ${chalk.white('hexabot env init --docker')}`,
198
- ),
199
- );
273
+ console.log(chalk.gray(`Need env files? Run hexabot env init --docker`));
200
274
  console.log('\n');
201
275
  };
@@ -10,7 +10,12 @@ import * as path from 'path';
10
10
 
11
11
  import { jest } from '@jest/globals';
12
12
 
13
- import { bootstrapEnvFile, listEnvStatus, resolveEnvExample } from '../env.js';
13
+ import {
14
+ bootstrapEnvFile,
15
+ listEnvStatus,
16
+ resolveEnvExample,
17
+ upsertEnvVariables,
18
+ } from '../env.js';
14
19
 
15
20
  describe('env helpers', () => {
16
21
  let tempDir: string;
@@ -94,4 +99,33 @@ describe('env helpers', () => {
94
99
  defaultExample,
95
100
  );
96
101
  });
102
+
103
+ it('upserts env variables without duplicating existing keys', () => {
104
+ fs.writeFileSync(
105
+ path.join(tempDir, '.env'),
106
+ [
107
+ 'PORT=3000',
108
+ 'SEED_ADMIN_EMAIL=old@example.com',
109
+ 'SEED_ADMIN_EMAIL=legacy@example.com',
110
+ ].join('\n'),
111
+ );
112
+
113
+ upsertEnvVariables(tempDir, '.env', {
114
+ SEED_ADMIN_EMAIL: 'new@example.com',
115
+ SEED_ADMIN_PASSWORD: 'P@ss "word"',
116
+ });
117
+
118
+ const nextEnv = fs.readFileSync(path.join(tempDir, '.env'), 'utf-8');
119
+ const emailMatches = nextEnv.match(/^SEED_ADMIN_EMAIL=/gm) || [];
120
+ expect(emailMatches).toHaveLength(1);
121
+ expect(nextEnv).toContain('SEED_ADMIN_EMAIL=new@example.com');
122
+ expect(nextEnv).toContain('SEED_ADMIN_PASSWORD="P@ss \\"word\\""');
123
+ expect(nextEnv.endsWith('\n')).toBe(true);
124
+ });
125
+
126
+ it('throws when attempting to upsert a missing env file', () => {
127
+ expect(() =>
128
+ upsertEnvVariables(tempDir, '.missing', { KEY: 'value' }),
129
+ ).toThrow('Env file ".missing" is missing.');
130
+ });
97
131
  });
package/src/core/env.ts CHANGED
@@ -88,3 +88,65 @@ export const resolveEnvExample = (
88
88
 
89
89
  return defaultExample;
90
90
  };
91
+
92
+ const ENV_BARE_VALUE = /^[A-Za-z0-9._/:@-]*$/;
93
+ const formatEnvValue = (value: string) => {
94
+ if (ENV_BARE_VALUE.test(value)) {
95
+ return value;
96
+ }
97
+
98
+ return `"${value
99
+ .replace(/\\/g, '\\\\')
100
+ .replace(/"/g, '\\"')
101
+ .replace(/\n/g, '\\n')}"`;
102
+ };
103
+ const escapeRegExp = (value: string) => {
104
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
105
+ };
106
+
107
+ export const upsertEnvVariables = (
108
+ projectRoot: string,
109
+ envFile: string,
110
+ values: Record<string, string>,
111
+ ) => {
112
+ const envPath = path.join(projectRoot, envFile);
113
+ if (!fs.existsSync(envPath)) {
114
+ throw new Error(`Env file "${envFile}" is missing.`);
115
+ }
116
+
117
+ const source = fs.readFileSync(envPath, 'utf-8');
118
+ let lines = source.length > 0 ? source.split(/\r?\n/) : [];
119
+
120
+ // Remove trailing blank line to avoid repetitive gaps after writes.
121
+ while (lines.length > 0 && lines[lines.length - 1] === '') {
122
+ lines.pop();
123
+ }
124
+
125
+ for (const [key, rawValue] of Object.entries(values)) {
126
+ const keyPattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`);
127
+ const nextLine = `${key}=${formatEnvValue(rawValue)}`;
128
+ let updated = false;
129
+ const nextLines: string[] = [];
130
+
131
+ for (const line of lines) {
132
+ if (!keyPattern.test(line)) {
133
+ nextLines.push(line);
134
+ continue;
135
+ }
136
+
137
+ if (!updated) {
138
+ nextLines.push(nextLine);
139
+ updated = true;
140
+ }
141
+ // Skip duplicate declarations for the same key.
142
+ }
143
+
144
+ if (!updated) {
145
+ nextLines.push(nextLine);
146
+ }
147
+
148
+ lines = nextLines;
149
+ }
150
+
151
+ fs.writeFileSync(envPath, `${lines.join('\n')}\n`, 'utf-8');
152
+ };
package/src/index.ts CHANGED
@@ -9,6 +9,8 @@ import { createCliProgram } from './cli.js';
9
9
  import { checkPrerequisites } from './core/prerequisites.js';
10
10
  import { printBanner } from './ui/banner.js';
11
11
 
12
+ const cliArgs = process.argv.slice(2);
13
+
12
14
  printBanner();
13
15
  checkPrerequisites({ silent: true });
14
16
 
@@ -16,6 +18,6 @@ const program = createCliProgram();
16
18
 
17
19
  program.parse(process.argv);
18
20
 
19
- if (!process.argv.slice(2).length) {
21
+ if (!cliArgs.length) {
20
22
  program.outputHelp();
21
23
  }