@hexabot-ai/cli 3.0.0-alpha.3 → 3.1.0-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.
- package/dist/commands/__tests__/create.test.js +158 -0
- package/dist/commands/create.js +60 -3
- package/dist/core/__tests__/env.test.js +21 -1
- package/dist/core/env.js +47 -0
- package/dist/index.js +5 -1
- package/eslint.config.cjs +15 -3
- package/package.json +2 -1
- package/src/commands/__tests__/create.test.ts +192 -0
- package/src/commands/create.ts +81 -7
- package/src/core/__tests__/env.test.ts +35 -1
- package/src/core/env.ts +62 -0
- package/src/index.ts +7 -1
|
@@ -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
|
+
});
|
package/dist/commands/create.js
CHANGED
|
@@ -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
|
|
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
|
|
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,14 @@
|
|
|
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);
|
|
11
|
+
if (process.env.HEXABOT_CLI !== '1') {
|
|
12
|
+
process.exit(0);
|
|
13
|
+
}
|
|
10
14
|
printBanner();
|
|
11
15
|
checkPrerequisites({ silent: true });
|
|
12
16
|
const program = createCliProgram();
|
|
13
17
|
program.parse(process.argv);
|
|
14
|
-
if (!
|
|
18
|
+
if (!cliArgs.length) {
|
|
15
19
|
program.outputHelp();
|
|
16
20
|
}
|
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(
|
|
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':
|
|
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: [
|
|
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.
|
|
3
|
+
"version": "3.1.0-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",
|
|
@@ -29,6 +29,7 @@
|
|
|
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
|
+
});
|
package/src/commands/create.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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,12 @@ 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
|
+
|
|
14
|
+
if (process.env.HEXABOT_CLI !== '1') {
|
|
15
|
+
process.exit(0);
|
|
16
|
+
}
|
|
17
|
+
|
|
12
18
|
printBanner();
|
|
13
19
|
checkPrerequisites({ silent: true });
|
|
14
20
|
|
|
@@ -16,6 +22,6 @@ const program = createCliProgram();
|
|
|
16
22
|
|
|
17
23
|
program.parse(process.argv);
|
|
18
24
|
|
|
19
|
-
if (!
|
|
25
|
+
if (!cliArgs.length) {
|
|
20
26
|
program.outputHelp();
|
|
21
27
|
}
|