@olonjs/cli 3.0.154 → 3.0.155

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": "@olonjs/cli",
3
- "version": "3.0.154",
3
+ "version": "3.0.155",
4
4
  "description": "The Sovereign CLI Engine for OlonJS.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,6 +28,7 @@
28
28
  },
29
29
  "scripts": {
30
30
  "build": "tsc -p .",
31
+ "test": "node --test src/**/*.test.js",
31
32
  "check:templates": "node ../../scripts/check-cli-templates.mjs"
32
33
  },
33
34
  "dependencies": {
package/src/index.js CHANGED
@@ -7,6 +7,12 @@ import { execa } from 'execa';
7
7
  import ora from 'ora';
8
8
  import { fileURLToPath } from 'url';
9
9
  import os from 'os';
10
+ import {
11
+ resolveTemplateId,
12
+ shouldPromptForTemplate,
13
+ UI_TEMPLATE_CHOICES,
14
+ } from './templateChoice.js';
15
+ import { selectWithArrows } from './selectWithArrows.js';
10
16
 
11
17
  const __filename = fileURLToPath(import.meta.url);
12
18
  const __dirname = path.dirname(__filename);
@@ -86,11 +92,34 @@ function resolveTemplateScriptPath(templateName) {
86
92
  return templatePath;
87
93
  }
88
94
 
95
+ async function resolveDnaTemplateId(templateOption) {
96
+ const isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY);
97
+ if (shouldPromptForTemplate({ templateOption, isTTY })) {
98
+ const dnaId = await selectWithArrows(
99
+ chalk.bold('Select stack:'),
100
+ UI_TEMPLATE_CHOICES.map((c) => ({ label: c.label, value: c.dnaId })),
101
+ );
102
+ return dnaId;
103
+ }
104
+
105
+ if (templateOption == null || String(templateOption).trim() === '') {
106
+ throw new Error(
107
+ 'Non-interactive mode requires --template next|vite|alpha (for agents/CI).',
108
+ );
109
+ }
110
+
111
+ const dnaId = resolveTemplateId(templateOption);
112
+ if (!dnaId) {
113
+ throw new Error(`Unknown template "${templateOption}". Use next | vite | alpha.`);
114
+ }
115
+ return dnaId;
116
+ }
117
+
89
118
  program
90
119
  .command('new')
91
120
  .argument('<type>', 'Type of artifact (tenant)')
92
121
  .argument('<name>', 'Name of the new tenant')
93
- .option('--template <name>', 'Template profile (default: alpha)', 'alpha')
122
+ .option('--template <name>', 'Template for agents/CI: next | vite | alpha (skips interactive picker)')
94
123
  .option('--script <path>', 'Override default deterministic script path')
95
124
  .action(async (type, name, options) => {
96
125
  if (type !== 'tenant') {
@@ -100,46 +129,70 @@ program
100
129
 
101
130
  const targetDir = path.join(process.cwd(), name);
102
131
  const availableTemplates = getAvailableTemplates();
103
- const template = options.template;
132
+
133
+ let dnaId;
134
+ try {
135
+ dnaId = await resolveDnaTemplateId(options.template);
136
+ } catch (error) {
137
+ console.log(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
138
+ return;
139
+ }
140
+
104
141
  const scriptPath = options.script
105
142
  ? path.resolve(process.cwd(), options.script)
106
- : resolveTemplateScriptPath(template);
107
-
108
- if (!options.script && !availableTemplates.includes(template) && !(template === 'alpha' && fs.existsSync(LEGACY_ALPHA_DNA_PATH))) {
109
- console.log(chalk.red(`Error: Unknown template "${template}".`));
110
- console.log(chalk.yellow(`Available templates: ${availableTemplates.length ? availableTemplates.join(', ') : '(none found)'}`));
143
+ : resolveTemplateScriptPath(dnaId);
144
+
145
+ if (
146
+ !options.script &&
147
+ !availableTemplates.includes(dnaId) &&
148
+ !(dnaId === 'alpha' && fs.existsSync(LEGACY_ALPHA_DNA_PATH))
149
+ ) {
150
+ console.log(chalk.red(`Error: Unknown template "${dnaId}".`));
151
+ console.log(
152
+ chalk.yellow(
153
+ `Available templates: ${availableTemplates.length ? availableTemplates.join(', ') : '(none found)'}`,
154
+ ),
155
+ );
111
156
  return;
112
157
  }
113
158
 
114
159
  if (!fs.existsSync(scriptPath)) {
115
160
  console.log(chalk.red(`Error: DNA script not found at ${scriptPath}`));
116
- console.log(chalk.yellow(`Debug info: template=${template}, assets=${CLI_ASSETS_DIR}`));
161
+ console.log(chalk.yellow(`Debug info: template=${dnaId}, assets=${CLI_ASSETS_DIR}`));
117
162
  return;
118
163
  }
119
164
 
120
- console.log(chalk.blue.bold(`\nProjecting Sovereign Tenant: ${name} (template: ${template})\n`));
165
+ const stackLabel = dnaId === 'next' ? 'next' : 'vite';
166
+ console.log(chalk.blue.bold(`\nProjecting Sovereign Tenant: ${name} (stack: ${stackLabel}, dna: ${dnaId})\n`));
121
167
  const spinner = ora();
168
+ const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
122
169
 
123
170
  try {
124
- spinner.start('Setting up environment (Vite + TS)...');
125
171
  await fs.ensureDir(targetDir);
126
172
 
127
- const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
128
- await execa(npmCmd, ['create', 'vite@latest', '.', '--', '--template', 'react-ts'], { cwd: targetDir });
129
- spinner.succeed('Environment scaffolded.');
173
+ if (dnaId === 'next') {
174
+ spinner.start('Preparing Next App Router workspace...');
175
+ spinner.succeed('Workspace ready.');
176
+ } else {
177
+ spinner.start('Setting up environment (Vite + TS)...');
178
+ await execa(npmCmd, ['create', 'vite@latest', '.', '--', '--template', 'react-ts'], {
179
+ cwd: targetDir,
180
+ });
181
+ spinner.succeed('Environment scaffolded.');
182
+
183
+ spinner.start('Wiping default boilerplate...');
184
+ await fs.emptyDir(path.join(targetDir, 'src'));
185
+ const junk = ['App.css', 'App.tsx', 'main.tsx', 'vite-env.d.ts', 'favicon.ico', 'index.html'];
186
+ for (const file of junk) {
187
+ await fs.remove(path.join(targetDir, file)).catch(() => {});
188
+ await fs.remove(path.join(targetDir, 'src', file)).catch(() => {});
189
+ }
190
+ spinner.succeed('Clean slate achieved.');
130
191
 
131
- spinner.start('Wiping default boilerplate...');
132
- await fs.emptyDir(path.join(targetDir, 'src'));
133
- const junk = ['App.css', 'App.tsx', 'main.tsx', 'vite-env.d.ts', 'favicon.ico', 'index.html'];
134
- for (const file of junk) {
135
- await fs.remove(path.join(targetDir, file)).catch(() => {});
136
- await fs.remove(path.join(targetDir, 'src', file)).catch(() => {});
192
+ spinner.start('Injecting Sovereign Configurations...');
193
+ await injectInfraFiles(targetDir, name);
194
+ spinner.succeed('Infrastructure configured.');
137
195
  }
138
- spinner.succeed('Clean slate achieved.');
139
-
140
- spinner.start('Injecting Sovereign Configurations...');
141
- await injectInfraFiles(targetDir, name);
142
- spinner.succeed('Infrastructure configured.');
143
196
 
144
197
  spinner.start('Executing deterministic src projection...');
145
198
  await processScriptInNode(scriptPath, targetDir);
@@ -153,7 +206,7 @@ program
153
206
  console.log(` ${chalk.cyan(`cd ${name}`)}`);
154
207
  console.log(` ${chalk.cyan('npm run dev')} <- Start development`);
155
208
  console.log(` ${chalk.cyan('npm run build')} <- Validate Green Build`);
156
- console.log(`\nTemplate used: ${template}\n`);
209
+ console.log(`\nStack: ${stackLabel} · DNA: ${dnaId}\n`);
157
210
  } catch (error) {
158
211
  spinner.fail(chalk.red('Projection failed.'));
159
212
  console.error(error);
@@ -0,0 +1,71 @@
1
+ import readline from 'node:readline';
2
+
3
+ /**
4
+ * Minimal arrow-key select (no extra deps).
5
+ * @param {string} message
6
+ * @param {Array<{ label: string, value: string }>} choices
7
+ * @returns {Promise<string>} selected value
8
+ */
9
+ export async function selectWithArrows(message, choices) {
10
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
11
+ throw new Error('Interactive template selection requires a TTY. Pass --template next|vite|alpha.');
12
+ }
13
+
14
+ let index = 0;
15
+
16
+ const render = () => {
17
+ const lines = choices.map((choice, i) => {
18
+ const mark = i === index ? '>' : ' ';
19
+ return ` ${mark} ${choice.label}`;
20
+ });
21
+ process.stdout.write(`\x1B[?25l${message}\n${lines.join('\n')}\n`);
22
+ };
23
+
24
+ const clear = () => {
25
+ const lineCount = choices.length + 1;
26
+ process.stdout.write(`\x1B[${lineCount}A\x1B[0J`);
27
+ };
28
+
29
+ render();
30
+
31
+ return new Promise((resolve, reject) => {
32
+ readline.emitKeypressEvents(process.stdin);
33
+ process.stdin.setRawMode(true);
34
+ process.stdin.resume();
35
+
36
+ const onKey = (_str, key) => {
37
+ if (!key) return;
38
+ if (key.ctrl && key.name === 'c') {
39
+ cleanup();
40
+ reject(new Error('Cancelled'));
41
+ return;
42
+ }
43
+ if (key.name === 'up' || key.name === 'k') {
44
+ index = (index - 1 + choices.length) % choices.length;
45
+ clear();
46
+ render();
47
+ return;
48
+ }
49
+ if (key.name === 'down' || key.name === 'j') {
50
+ index = (index + 1) % choices.length;
51
+ clear();
52
+ render();
53
+ return;
54
+ }
55
+ if (key.name === 'return') {
56
+ cleanup();
57
+ process.stdout.write(`\x1B[?25h`);
58
+ resolve(choices[index].value);
59
+ }
60
+ };
61
+
62
+ function cleanup() {
63
+ process.stdin.setRawMode(false);
64
+ process.stdin.pause();
65
+ process.stdin.off('keypress', onKey);
66
+ process.stdout.write('\x1B[?25h');
67
+ }
68
+
69
+ process.stdin.on('keypress', onKey);
70
+ });
71
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Template selection for `olonjs new tenant`.
3
+ * UI labels: next | vite → DNA ids: next | alpha
4
+ */
5
+
6
+ export const UI_TEMPLATE_CHOICES = [
7
+ { label: 'next', dnaId: 'next' },
8
+ { label: 'vite', dnaId: 'alpha' },
9
+ ];
10
+
11
+ /** @param {string | undefined | null} raw */
12
+ export function resolveTemplateId(raw) {
13
+ const v = String(raw ?? '')
14
+ .trim()
15
+ .toLowerCase();
16
+ if (!v) return null;
17
+ if (v === 'next') return 'next';
18
+ if (v === 'vite' || v === 'alpha') return 'alpha';
19
+ return null;
20
+ }
21
+
22
+ /**
23
+ * Prompt when humans run without --template on a TTY.
24
+ * Agents/CI must pass --template (non-TTY without flag → no prompt).
25
+ */
26
+ export function shouldPromptForTemplate({ templateOption, isTTY }) {
27
+ if (templateOption != null && String(templateOption).trim() !== '') return false;
28
+ return Boolean(isTTY);
29
+ }
@@ -0,0 +1,38 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+ import {
4
+ resolveTemplateId,
5
+ shouldPromptForTemplate,
6
+ UI_TEMPLATE_CHOICES,
7
+ } from './templateChoice.js';
8
+
9
+ describe('UI_TEMPLATE_CHOICES', () => {
10
+ it('exposes next and vite labels for the TUI', () => {
11
+ assert.deepEqual(
12
+ UI_TEMPLATE_CHOICES.map((c) => c.label),
13
+ ['next', 'vite'],
14
+ );
15
+ });
16
+ });
17
+
18
+ describe('resolveTemplateId', () => {
19
+ it('maps next → next and vite/alpha → alpha', () => {
20
+ assert.equal(resolveTemplateId('next'), 'next');
21
+ assert.equal(resolveTemplateId('vite'), 'alpha');
22
+ assert.equal(resolveTemplateId('alpha'), 'alpha');
23
+ assert.equal(resolveTemplateId('NEXT'), 'next');
24
+ });
25
+
26
+ it('returns null for unknown values', () => {
27
+ assert.equal(resolveTemplateId('spa'), null);
28
+ assert.equal(resolveTemplateId(''), null);
29
+ });
30
+ });
31
+
32
+ describe('shouldPromptForTemplate', () => {
33
+ it('prompts only when flag absent and stdin is a TTY', () => {
34
+ assert.equal(shouldPromptForTemplate({ templateOption: undefined, isTTY: true }), true);
35
+ assert.equal(shouldPromptForTemplate({ templateOption: 'next', isTTY: true }), false);
36
+ assert.equal(shouldPromptForTemplate({ templateOption: undefined, isTTY: false }), false);
37
+ });
38
+ });