@husar.ai/cli 0.4.0 → 0.4.2

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/src/cli.ts CHANGED
@@ -7,7 +7,10 @@ import { ConfigMaker } from 'config-maker';
7
7
  import { parser } from './functions/parser.js';
8
8
  import { generateCms } from './functions/generate.js';
9
9
  import { startMcpServer } from './mcp.js';
10
- import { HusarConfigType } from '@/types/config.js';
10
+ import { HusarConfigType, getAdminToken } from '@/types/config.js';
11
+ import { startLoginFlow } from './auth/login.js';
12
+ import { clearAuth, getCurrentUser, getConfigPath, isLoggedIn, readAuthConfig } from './auth/config.js';
13
+ import { runCreateCommand } from './functions/create.js';
11
14
 
12
15
  const config = new ConfigMaker<HusarConfigType>('husar', { decoders: {} });
13
16
 
@@ -40,13 +43,14 @@ program
40
43
  if (!conf.host) {
41
44
  throw new Error('Host is not configured. Please run "husar generate" first.');
42
45
  }
43
- if (!conf.adminToken) {
44
- throw new Error('Admin token is not configured. Please set it in the config or use environment variable.');
46
+ const adminToken = getAdminToken(conf);
47
+ if (!adminToken) {
48
+ throw new Error('Admin token not configured. Set HUSAR_ADMIN_TOKEN env var, or adminTokenEnv in husar.json');
45
49
  }
46
50
  const result = await parser({
47
51
  inputFile,
48
52
  opts: { name, type: opts?.type },
49
- authentication: { HUSAR_MCP_HOST: conf.host, HUSAR_MCP_ADMIN_TOKEN: conf.adminToken },
53
+ authentication: { HUSAR_MCP_HOST: conf.host, HUSAR_MCP_ADMIN_TOKEN: adminToken },
50
54
  });
51
55
  console.log(result ? 'File parsed and upserted successfully.' : 'Failed to parse and upsert file.');
52
56
  });
@@ -61,4 +65,92 @@ program
61
65
  await new Promise<void>(() => {});
62
66
  });
63
67
 
68
+ // ============ Auth Commands ============
69
+
70
+ program
71
+ .command('login')
72
+ .description('Log in to Husar.ai via browser authentication')
73
+ .action(async () => {
74
+ const loggedIn = await isLoggedIn();
75
+ if (loggedIn) {
76
+ const user = await getCurrentUser();
77
+ console.log(`Already logged in as ${user?.email}`);
78
+ console.log('Run "husar logout" to switch accounts.');
79
+ return;
80
+ }
81
+
82
+ console.log('Starting Husar.ai authentication...');
83
+ const result = await startLoginFlow();
84
+
85
+ if (result.success) {
86
+ console.log(`\n✅ Successfully logged in as ${result.email}`);
87
+ if (result.project) {
88
+ console.log(` Connected to project: ${result.project.projectName}`);
89
+ console.log(` Host: ${result.project.host}`);
90
+ }
91
+ } else {
92
+ console.error(`\n❌ Login failed: ${result.error}`);
93
+ process.exit(1);
94
+ }
95
+ });
96
+
97
+ program
98
+ .command('logout')
99
+ .description('Log out and clear stored credentials')
100
+ .action(async () => {
101
+ const loggedIn = await isLoggedIn();
102
+ if (!loggedIn) {
103
+ console.log('Not logged in.');
104
+ return;
105
+ }
106
+
107
+ const user = await getCurrentUser();
108
+ await clearAuth();
109
+ console.log(`✅ Logged out${user?.email ? ` (was: ${user.email})` : ''}`);
110
+ console.log(` Cleared credentials from ${getConfigPath()}`);
111
+ });
112
+
113
+ program
114
+ .command('whoami')
115
+ .description('Display current logged-in user and projects')
116
+ .action(async () => {
117
+ const loggedIn = await isLoggedIn();
118
+ if (!loggedIn) {
119
+ console.log('Not logged in. Run "husar login" to authenticate.');
120
+ return;
121
+ }
122
+
123
+ const user = await getCurrentUser();
124
+ const authConfig = await readAuthConfig();
125
+
126
+ console.log(`\n👤 Logged in as: ${user?.email}`);
127
+
128
+ if (authConfig.projects && Object.keys(authConfig.projects).length > 0) {
129
+ console.log('\n📦 Connected projects:');
130
+ for (const [name, project] of Object.entries(authConfig.projects)) {
131
+ console.log(` • ${name}`);
132
+ console.log(` Host: ${project.host}`);
133
+ }
134
+ } else {
135
+ console.log('\nNo projects connected. Run "husar create" to set up a new project.');
136
+ }
137
+
138
+ console.log(`\n📁 Config: ${getConfigPath()}`);
139
+ });
140
+
141
+ // ============ Create Command ============
142
+
143
+ program
144
+ .command('create [projectName]')
145
+ .description('Create a new project with Husar CMS integration')
146
+ .option('-f, --framework <framework>', 'Framework to use: nextjs or vite', 'nextjs')
147
+ .option('--skip-install', 'Skip package installation after scaffolding')
148
+ .action(async (projectName: string | undefined, opts: { framework: string; skipInstall?: boolean }) => {
149
+ await runCreateCommand({
150
+ projectName,
151
+ framework: opts.framework as 'nextjs' | 'vite',
152
+ skipInstall: opts.skipInstall,
153
+ });
154
+ });
155
+
64
156
  program.parse(process.argv);
@@ -0,0 +1,489 @@
1
+ /**
2
+ * Create command for Husar CLI
3
+ *
4
+ * Onboarding flow for new users:
5
+ * 1. Choose connection method (manual URL or cloud login)
6
+ * 2. If cloud login: Get JWT → List projects → Select one
7
+ * 3. Panel CMS login: Get adminToken via browser auth
8
+ * 4. Delegate to framework CLI (create-next-app / create-vite)
9
+ * 5. Add Husar configuration and install packages
10
+ */
11
+
12
+ import { spawn } from 'node:child_process';
13
+ import { promises as fs } from 'node:fs';
14
+ import { join, resolve } from 'node:path';
15
+ import * as readline from 'node:readline';
16
+
17
+ import { getCloudAuth, ProjectAuth } from '../auth/config.js';
18
+ import { startCloudLoginFlow, startPanelLoginFlow } from '../auth/login.js';
19
+ import { getProjects, CloudProject, verifyAccessToken } from '../auth/api.js';
20
+
21
+ export interface CreateOptions {
22
+ /** Project directory name */
23
+ projectName?: string;
24
+ /** Framework to use: nextjs or vite */
25
+ framework: 'nextjs' | 'vite';
26
+ /** Skip npm install after scaffolding */
27
+ skipInstall?: boolean;
28
+ }
29
+
30
+ // ═══════════════════════════════════════════════════════════════════════════
31
+ // MAIN CREATE COMMAND
32
+ // ═══════════════════════════════════════════════════════════════════════════
33
+
34
+ /**
35
+ * Run the create command
36
+ */
37
+ export async function runCreateCommand(options: CreateOptions): Promise<void> {
38
+ console.log('\n🚀 Husar.ai Project Creator\n');
39
+
40
+ // ═══════════════════════════════════════════════════════════════════════
41
+ // STEP 1: How to connect to CMS?
42
+ // ═══════════════════════════════════════════════════════════════════════
43
+
44
+ const connectionMethod = await promptConnectionMethod();
45
+ let panelHost: string;
46
+
47
+ if (connectionMethod === 'manual') {
48
+ // User enters URL manually
49
+ panelHost = await promptManualUrl();
50
+ } else {
51
+ // Cloud login flow → select project
52
+ const cloudAuth = await ensureCloudLogin();
53
+ if (!cloudAuth) {
54
+ console.error('\n❌ Cloud authentication failed. Please try again.');
55
+ process.exit(1);
56
+ }
57
+
58
+ // Fetch user's projects
59
+ console.log('\n📦 Fetching your projects...\n');
60
+ const projects = await getProjects(cloudAuth.accessToken);
61
+
62
+ if (projects.length === 0) {
63
+ console.log('❌ No projects found in your account.');
64
+ console.log(' Create a project at https://admin.husar.ai first.\n');
65
+ process.exit(1);
66
+ }
67
+
68
+ // Let user select a project
69
+ const selectedProject = await promptSelectProject(projects);
70
+ panelHost = selectedProject.adminURL;
71
+ console.log(`\n📌 Selected: ${selectedProject.name}`);
72
+ }
73
+
74
+ // ═══════════════════════════════════════════════════════════════════════
75
+ // STEP 2: Panel CMS login (get adminToken)
76
+ // ═══════════════════════════════════════════════════════════════════════
77
+
78
+ console.log('\n🔐 Opening browser for CMS authentication...');
79
+ console.log(" You'll need your SUPERADMIN credentials (from the welcome email)\n");
80
+
81
+ const panelAuth = await startPanelLoginFlow(panelHost);
82
+
83
+ if (!panelAuth.success || !panelAuth.project) {
84
+ console.error('\n❌ CMS authentication failed:', panelAuth.error ?? 'Unknown error');
85
+ process.exit(1);
86
+ }
87
+
88
+ const project = panelAuth.project;
89
+ console.log(`\n✅ Connected to: ${project.projectName}`);
90
+ console.log(` Host: ${project.host}\n`);
91
+
92
+ // ═══════════════════════════════════════════════════════════════════════
93
+ // STEP 3: Get project name (directory)
94
+ // ═══════════════════════════════════════════════════════════════════════
95
+
96
+ const projectName = options.projectName || (await promptProjectName(project.projectName));
97
+ if (!projectName) {
98
+ console.error('❌ Project name is required.');
99
+ process.exit(1);
100
+ }
101
+
102
+ const projectPath = resolve(process.cwd(), projectName);
103
+
104
+ // ═══════════════════════════════════════════════════════════════════════
105
+ // STEP 4: Run framework CLI
106
+ // ═══════════════════════════════════════════════════════════════════════
107
+
108
+ console.log(`\n📦 Creating ${options.framework === 'nextjs' ? 'Next.js' : 'Vite + React'} project...\n`);
109
+
110
+ const frameworkSuccess = await runFrameworkCli(options.framework, projectName);
111
+ if (!frameworkSuccess) {
112
+ console.error('\n❌ Framework CLI failed. Please check the output above.');
113
+ process.exit(1);
114
+ }
115
+
116
+ // ═══════════════════════════════════════════════════════════════════════
117
+ // STEP 5: Add Husar configuration (with hybrid storage)
118
+ // ═══════════════════════════════════════════════════════════════════════
119
+
120
+ console.log('\n⚙️ Adding Husar configuration...\n');
121
+ await addHusarConfig(projectPath, project, options.framework);
122
+
123
+ // ═══════════════════════════════════════════════════════════════════════
124
+ // STEP 6: Install Husar packages
125
+ // ═══════════════════════════════════════════════════════════════════════
126
+
127
+ if (!options.skipInstall) {
128
+ console.log('\n📥 Installing Husar packages...\n');
129
+ await installHusarPackages(projectPath);
130
+ }
131
+
132
+ // ═══════════════════════════════════════════════════════════════════════
133
+ // STEP 7: Generate CMS files
134
+ // ═══════════════════════════════════════════════════════════════════════
135
+
136
+ console.log('\n🔧 Generating CMS client...\n');
137
+ await generateCmsClient(projectPath, options.framework);
138
+
139
+ // ═══════════════════════════════════════════════════════════════════════
140
+ // SUCCESS!
141
+ // ═══════════════════════════════════════════════════════════════════════
142
+
143
+ console.log('\n' + '═'.repeat(50));
144
+ console.log('✨ Project created successfully!');
145
+ console.log('═'.repeat(50));
146
+ console.log(`\n📁 Project location: ${projectPath}`);
147
+ console.log('\n🎯 Next steps:');
148
+ console.log(` cd ${projectName}`);
149
+ console.log(' npm run dev\n');
150
+ console.log('📚 Documentation: https://docs.husar.ai');
151
+ console.log('💬 Need help? Join our Discord: https://discord.gg/husar\n');
152
+ }
153
+
154
+ // ═══════════════════════════════════════════════════════════════════════════
155
+ // PROMPTS
156
+ // ═══════════════════════════════════════════════════════════════════════════
157
+
158
+ /**
159
+ * Prompt for connection method
160
+ */
161
+ async function promptConnectionMethod(): Promise<'manual' | 'cloud'> {
162
+ console.log('How do you want to connect to your CMS?\n');
163
+ console.log(' 1. Enter project URL manually');
164
+ console.log(' 2. Login to husar.ai and select from list\n');
165
+
166
+ const choice = await promptChoice(2);
167
+ return choice === 1 ? 'manual' : 'cloud';
168
+ }
169
+
170
+ /**
171
+ * Prompt for manual URL entry
172
+ */
173
+ async function promptManualUrl(): Promise<string> {
174
+ return new Promise((resolve) => {
175
+ const rl = readline.createInterface({
176
+ input: process.stdin,
177
+ output: process.stdout,
178
+ });
179
+
180
+ const ask = () => {
181
+ rl.question('\n🌐 Enter your CMS panel URL: ', (answer) => {
182
+ const url = answer.trim();
183
+
184
+ // Basic validation
185
+ if (!url) {
186
+ console.log(' URL is required.');
187
+ ask();
188
+ return;
189
+ }
190
+
191
+ if (!url.startsWith('http://') && !url.startsWith('https://')) {
192
+ console.log(' URL must start with http:// or https://');
193
+ ask();
194
+ return;
195
+ }
196
+
197
+ rl.close();
198
+ resolve(url);
199
+ });
200
+ };
201
+
202
+ ask();
203
+ });
204
+ }
205
+
206
+ /**
207
+ * Prompt for project selection from list
208
+ */
209
+ async function promptSelectProject(projects: CloudProject[]): Promise<CloudProject> {
210
+ console.log('Select a project:\n');
211
+ projects.forEach((p, i) => {
212
+ console.log(` ${i + 1}. ${p.name}`);
213
+ console.log(` ${p.adminURL}\n`);
214
+ });
215
+
216
+ const choice = await promptChoice(projects.length);
217
+ return projects[choice - 1]!;
218
+ }
219
+
220
+ /**
221
+ * Prompt for project directory name
222
+ */
223
+ async function promptProjectName(suggestedName?: string): Promise<string> {
224
+ return new Promise((resolve) => {
225
+ const rl = readline.createInterface({
226
+ input: process.stdin,
227
+ output: process.stdout,
228
+ });
229
+
230
+ const defaultName = suggestedName ? suggestedName.replace(/[^a-zA-Z0-9-_]/g, '-') : '';
231
+ const prompt = defaultName
232
+ ? `📝 Enter project directory name [${defaultName}]: `
233
+ : '📝 Enter project directory name: ';
234
+
235
+ rl.question(prompt, (answer) => {
236
+ rl.close();
237
+ const name = answer.trim() || defaultName;
238
+ resolve(name);
239
+ });
240
+ });
241
+ }
242
+
243
+ /**
244
+ * Prompt for numeric choice
245
+ */
246
+ async function promptChoice(max: number): Promise<number> {
247
+ return new Promise((resolvePrompt) => {
248
+ const rl = readline.createInterface({
249
+ input: process.stdin,
250
+ output: process.stdout,
251
+ });
252
+
253
+ const ask = () => {
254
+ rl.question(`Enter choice (1-${max}): `, (answer) => {
255
+ const num = parseInt(answer, 10);
256
+ if (num >= 1 && num <= max) {
257
+ rl.close();
258
+ resolvePrompt(num);
259
+ } else {
260
+ console.log(`Please enter a number between 1 and ${max}`);
261
+ ask();
262
+ }
263
+ });
264
+ };
265
+ ask();
266
+ });
267
+ }
268
+
269
+ // ═══════════════════════════════════════════════════════════════════════════
270
+ // CLOUD LOGIN
271
+ // ═══════════════════════════════════════════════════════════════════════════
272
+
273
+ /**
274
+ * Ensure user has valid cloud login (JWT)
275
+ */
276
+ async function ensureCloudLogin(): Promise<{ accessToken: string; email?: string } | null> {
277
+ // Check if we already have a valid cloud auth
278
+ const existingAuth = await getCloudAuth();
279
+
280
+ if (existingAuth) {
281
+ // Verify the token is still valid
282
+ console.log('🔍 Checking existing cloud session...');
283
+ const isValid = await verifyAccessToken(existingAuth.accessToken);
284
+
285
+ if (isValid) {
286
+ console.log(`✅ Logged in as: ${existingAuth.email ?? 'user'}`);
287
+ return existingAuth;
288
+ }
289
+
290
+ console.log('⚠️ Session expired, need to re-authenticate...');
291
+ }
292
+
293
+ // Need to login
294
+ console.log('\n🔐 Please log in to Husar.ai...\n');
295
+ const result = await startCloudLoginFlow();
296
+
297
+ if (result.success && result.accessToken) {
298
+ console.log(`\n✅ Logged in as: ${result.email ?? 'user'}`);
299
+ return {
300
+ accessToken: result.accessToken,
301
+ email: result.email,
302
+ };
303
+ }
304
+
305
+ return null;
306
+ }
307
+
308
+ // ═══════════════════════════════════════════════════════════════════════════
309
+ // FRAMEWORK CLI
310
+ // ═══════════════════════════════════════════════════════════════════════════
311
+
312
+ /**
313
+ * Run the framework-specific CLI
314
+ */
315
+ async function runFrameworkCli(framework: 'nextjs' | 'vite', projectName: string): Promise<boolean> {
316
+ return new Promise((resolve) => {
317
+ let command: string;
318
+ let args: string[];
319
+
320
+ if (framework === 'nextjs') {
321
+ // Using npx create-next-app with recommended options
322
+ command = 'npx';
323
+ args = [
324
+ 'create-next-app@latest',
325
+ projectName,
326
+ '--typescript',
327
+ '--tailwind',
328
+ '--eslint',
329
+ '--app',
330
+ '--src-dir',
331
+ '--import-alias',
332
+ '@/*',
333
+ ];
334
+ } else {
335
+ // Using npm create vite with React + TypeScript
336
+ command = 'npm';
337
+ args = ['create', 'vite@latest', projectName, '--', '--template', 'react-ts'];
338
+ }
339
+
340
+ console.log(`Running: ${command} ${args.join(' ')}\n`);
341
+
342
+ const child = spawn(command, args, {
343
+ stdio: 'inherit',
344
+ shell: true,
345
+ });
346
+
347
+ child.on('close', (code) => {
348
+ resolve(code === 0);
349
+ });
350
+
351
+ child.on('error', (err) => {
352
+ console.error('Failed to start framework CLI:', err.message);
353
+ resolve(false);
354
+ });
355
+ });
356
+ }
357
+
358
+ // ═══════════════════════════════════════════════════════════════════════════
359
+ // CONFIGURATION
360
+ // ═══════════════════════════════════════════════════════════════════════════
361
+
362
+ /**
363
+ * Add Husar configuration files to the project
364
+ * Uses HYBRID storage: husar.json (committal) + .env.local (secrets)
365
+ */
366
+ async function addHusarConfig(projectPath: string, project: ProjectAuth, framework: 'nextjs' | 'vite'): Promise<void> {
367
+ const hostEnvVar = framework === 'nextjs' ? 'NEXT_PUBLIC_HUSAR_HOST' : 'VITE_HUSAR_HOST';
368
+
369
+ // 1. Create husar.json (committal - NO adminToken!)
370
+ const husarConfig = {
371
+ host: project.host,
372
+ hostEnvironmentVariable: hostEnvVar,
373
+ authenticationEnvironmentVariable: 'HUSAR_API_KEY',
374
+ adminTokenEnv: 'HUSAR_ADMIN_TOKEN', // Points to env var, not actual token
375
+ overrideHost: false,
376
+ };
377
+ await fs.writeFile(join(projectPath, 'husar.json'), JSON.stringify(husarConfig, null, 2));
378
+ console.log(' ✓ Created husar.json');
379
+
380
+ // 2. Create .env.local (secrets - NOT committed)
381
+ const envContent = `# Husar CMS Configuration
382
+ # This file contains secrets - DO NOT commit to version control!
383
+
384
+ # CMS Host URL
385
+ ${hostEnvVar}=${project.host}
386
+
387
+ # Content API Key (for public content queries)
388
+ HUSAR_API_KEY=
389
+
390
+ # Admin Token (for CLI/MCP operations)
391
+ HUSAR_ADMIN_TOKEN=${project.adminToken}
392
+ `;
393
+ await fs.writeFile(join(projectPath, '.env.local'), envContent);
394
+ console.log(' ✓ Created .env.local (contains admin credentials)');
395
+
396
+ // 3. Create opencode.jsonc for MCP integration
397
+ const opencodeConfig = {
398
+ $schema: 'https://opencode.ai/schemas/opencode.json',
399
+ mcpServers: {
400
+ husar: {
401
+ command: 'npx',
402
+ args: ['@husar.ai/cli', 'mcp'],
403
+ cwd: '.',
404
+ },
405
+ },
406
+ };
407
+ await fs.writeFile(join(projectPath, 'opencode.jsonc'), JSON.stringify(opencodeConfig, null, 2));
408
+ console.log(' ✓ Created opencode.jsonc (MCP configuration)');
409
+
410
+ // 4. Update .gitignore
411
+ const gitignorePath = join(projectPath, '.gitignore');
412
+ try {
413
+ let gitignore = await fs.readFile(gitignorePath, 'utf-8');
414
+ const additions: string[] = [];
415
+
416
+ if (!gitignore.includes('.env.local')) {
417
+ additions.push('.env.local');
418
+ }
419
+ if (!gitignore.includes('.env*.local')) {
420
+ additions.push('.env*.local');
421
+ }
422
+
423
+ if (additions.length > 0) {
424
+ gitignore += '\n# Local environment files (contain secrets)\n' + additions.join('\n') + '\n';
425
+ await fs.writeFile(gitignorePath, gitignore);
426
+ console.log(' ✓ Updated .gitignore');
427
+ }
428
+ } catch {
429
+ // .gitignore doesn't exist, create it
430
+ await fs.writeFile(gitignorePath, '# Local environment files (contain secrets)\n.env.local\n.env*.local\n');
431
+ console.log(' ✓ Created .gitignore');
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Install Husar packages
437
+ */
438
+ async function installHusarPackages(projectPath: string): Promise<void> {
439
+ return new Promise((resolve) => {
440
+ const packages = ['@husar.ai/ssr', '@husar.ai/render'];
441
+
442
+ const child = spawn('npm', ['install', ...packages], {
443
+ cwd: projectPath,
444
+ stdio: 'inherit',
445
+ shell: true,
446
+ });
447
+
448
+ child.on('close', () => {
449
+ console.log(' ✓ Installed @husar.ai/ssr and @husar.ai/render');
450
+ resolve();
451
+ });
452
+
453
+ child.on('error', (err) => {
454
+ console.error('Warning: Failed to install packages:', err.message);
455
+ console.log(' Run manually: npm install @husar.ai/ssr @husar.ai/render');
456
+ resolve();
457
+ });
458
+ });
459
+ }
460
+
461
+ /**
462
+ * Generate CMS client files using husar generate
463
+ */
464
+ async function generateCmsClient(projectPath: string, framework: 'nextjs' | 'vite'): Promise<void> {
465
+ // Determine src folder based on framework
466
+ const srcFolder = framework === 'nextjs' ? './src' : './src';
467
+
468
+ return new Promise((resolve) => {
469
+ const child = spawn('npx', ['@husar.ai/cli', 'generate', srcFolder], {
470
+ cwd: projectPath,
471
+ stdio: 'inherit',
472
+ shell: true,
473
+ });
474
+
475
+ child.on('close', (code) => {
476
+ if (code === 0) {
477
+ console.log(' ✓ Generated cms/ folder with Zeus client');
478
+ } else {
479
+ console.log(' ⚠ CMS generation may have failed - you can run "husar generate" later');
480
+ }
481
+ resolve();
482
+ });
483
+
484
+ child.on('error', () => {
485
+ console.log(' ⚠ Could not generate CMS client - run "husar generate" manually');
486
+ resolve();
487
+ });
488
+ });
489
+ }