@husar.ai/cli 0.4.2 ā 0.4.4
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/auth/login.js +1 -1
- package/dist/auth/login.js.map +1 -1
- package/dist/cli.js +132 -54
- package/dist/cli.js.map +1 -1
- package/dist/functions/create.d.ts +1 -1
- package/dist/functions/create.js +286 -152
- package/dist/functions/create.js.map +1 -1
- package/dist/ui.d.ts +51 -0
- package/dist/ui.js +181 -0
- package/dist/ui.js.map +1 -0
- package/package.json +9 -5
- package/src/auth/login.ts +3 -3
- package/src/cli.ts +182 -60
- package/src/functions/create.ts +354 -190
- package/src/ui.ts +260 -0
package/src/functions/create.ts
CHANGED
|
@@ -12,17 +12,17 @@
|
|
|
12
12
|
import { spawn } from 'node:child_process';
|
|
13
13
|
import { promises as fs } from 'node:fs';
|
|
14
14
|
import { join, resolve } from 'node:path';
|
|
15
|
-
import * as readline from 'node:readline';
|
|
16
15
|
|
|
17
16
|
import { getCloudAuth, ProjectAuth } from '../auth/config.js';
|
|
18
17
|
import { startCloudLoginFlow, startPanelLoginFlow } from '../auth/login.js';
|
|
19
18
|
import { getProjects, CloudProject, verifyAccessToken } from '../auth/api.js';
|
|
19
|
+
import { log, spinner, select, input, successBox, errorBox, nextSteps, header, theme, chalk } from '../ui.js';
|
|
20
20
|
|
|
21
21
|
export interface CreateOptions {
|
|
22
22
|
/** Project directory name */
|
|
23
23
|
projectName?: string;
|
|
24
|
-
/** Framework to use: nextjs or
|
|
25
|
-
framework
|
|
24
|
+
/** Framework to use: nextjs, vite, or clean */
|
|
25
|
+
framework?: 'nextjs' | 'vite' | 'clean';
|
|
26
26
|
/** Skip npm install after scaffolding */
|
|
27
27
|
skipInstall?: boolean;
|
|
28
28
|
}
|
|
@@ -35,235 +35,225 @@ export interface CreateOptions {
|
|
|
35
35
|
* Run the create command
|
|
36
36
|
*/
|
|
37
37
|
export async function runCreateCommand(options: CreateOptions): Promise<void> {
|
|
38
|
-
console.log('\nš Husar.ai Project Creator\n');
|
|
39
|
-
|
|
40
38
|
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
41
39
|
// STEP 1: How to connect to CMS?
|
|
42
40
|
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
43
41
|
|
|
44
|
-
|
|
42
|
+
header('Step 1: Connect to CMS');
|
|
43
|
+
|
|
44
|
+
const connectionMethod = await select<'manual' | 'cloud'>('How do you want to connect?', [
|
|
45
|
+
{
|
|
46
|
+
name: 'cloud',
|
|
47
|
+
message: 'Login to husar.ai',
|
|
48
|
+
hint: 'Recommended - select from your projects',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'manual',
|
|
52
|
+
message: 'Enter URL manually',
|
|
53
|
+
hint: 'For self-hosted or custom instances',
|
|
54
|
+
},
|
|
55
|
+
]);
|
|
56
|
+
|
|
45
57
|
let panelHost: string;
|
|
46
58
|
|
|
47
59
|
if (connectionMethod === 'manual') {
|
|
48
|
-
|
|
49
|
-
|
|
60
|
+
panelHost = await input('Enter your CMS panel URL', 'https://');
|
|
61
|
+
|
|
62
|
+
// Basic validation
|
|
63
|
+
if (!panelHost.startsWith('http://') && !panelHost.startsWith('https://')) {
|
|
64
|
+
errorBox('URL must start with http:// or https://');
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
50
67
|
} else {
|
|
51
68
|
// Cloud login flow ā select project
|
|
52
69
|
const cloudAuth = await ensureCloudLogin();
|
|
53
70
|
if (!cloudAuth) {
|
|
54
|
-
|
|
71
|
+
errorBox('Cloud authentication failed. Please try again.');
|
|
55
72
|
process.exit(1);
|
|
56
73
|
}
|
|
57
74
|
|
|
58
75
|
// Fetch user's projects
|
|
59
|
-
|
|
76
|
+
const spin = spinner('Fetching your projects...');
|
|
60
77
|
const projects = await getProjects(cloudAuth.accessToken);
|
|
78
|
+
spin.stop();
|
|
61
79
|
|
|
62
80
|
if (projects.length === 0) {
|
|
63
|
-
|
|
64
|
-
|
|
81
|
+
errorBox(
|
|
82
|
+
'No projects found in your account.\n\n' + `Create a project at ${theme.link('https://admin.husar.ai')} first.`,
|
|
83
|
+
'No Projects',
|
|
84
|
+
);
|
|
65
85
|
process.exit(1);
|
|
66
86
|
}
|
|
67
87
|
|
|
68
88
|
// Let user select a project
|
|
69
89
|
const selectedProject = await promptSelectProject(projects);
|
|
70
90
|
panelHost = selectedProject.adminURL;
|
|
71
|
-
|
|
91
|
+
log.done(`Selected: ${theme.info(selectedProject.name)}`);
|
|
72
92
|
}
|
|
73
93
|
|
|
74
94
|
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
75
95
|
// STEP 2: Panel CMS login (get adminToken)
|
|
76
96
|
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
77
97
|
|
|
78
|
-
|
|
79
|
-
|
|
98
|
+
header('Step 2: CMS Authentication');
|
|
99
|
+
|
|
100
|
+
log.info('Opening browser for CMS authentication...');
|
|
101
|
+
log.dim("You'll need your SUPERADMIN credentials (from the welcome email)");
|
|
102
|
+
log.blank();
|
|
80
103
|
|
|
81
104
|
const panelAuth = await startPanelLoginFlow(panelHost);
|
|
82
105
|
|
|
83
106
|
if (!panelAuth.success || !panelAuth.project) {
|
|
84
|
-
|
|
107
|
+
errorBox(panelAuth.error ?? 'Unknown error', 'CMS Authentication Failed');
|
|
85
108
|
process.exit(1);
|
|
86
109
|
}
|
|
87
110
|
|
|
88
111
|
const project = panelAuth.project;
|
|
89
|
-
|
|
90
|
-
|
|
112
|
+
log.success(`Connected to: ${theme.info(project.projectName)}`);
|
|
113
|
+
log.dim(`Host: ${project.host}`);
|
|
91
114
|
|
|
92
115
|
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
93
116
|
// STEP 3: Get project name (directory)
|
|
94
117
|
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
95
118
|
|
|
96
|
-
|
|
119
|
+
header('Step 3: Project Setup');
|
|
120
|
+
|
|
121
|
+
const defaultName = project.projectName.replace(/[^a-zA-Z0-9-_]/g, '-').toLowerCase();
|
|
122
|
+
const projectName = options.projectName || (await input('Project directory name', defaultName));
|
|
123
|
+
|
|
97
124
|
if (!projectName) {
|
|
98
|
-
|
|
125
|
+
errorBox('Project name is required.');
|
|
99
126
|
process.exit(1);
|
|
100
127
|
}
|
|
101
128
|
|
|
102
129
|
const projectPath = resolve(process.cwd(), projectName);
|
|
103
130
|
|
|
104
131
|
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
105
|
-
// STEP 4:
|
|
132
|
+
// STEP 4: Select framework
|
|
106
133
|
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
107
134
|
|
|
108
|
-
|
|
135
|
+
const framework =
|
|
136
|
+
options.framework ||
|
|
137
|
+
(await select<'nextjs' | 'vite' | 'clean'>('Which template would you like?', [
|
|
138
|
+
{
|
|
139
|
+
name: 'nextjs',
|
|
140
|
+
message: 'Next.js',
|
|
141
|
+
hint: 'Recommended - Full-stack React with SSR',
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: 'vite',
|
|
145
|
+
message: 'Vite + React',
|
|
146
|
+
hint: 'Fast build tool with React & TypeScript',
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: 'clean',
|
|
150
|
+
message: 'Clean (no framework)',
|
|
151
|
+
hint: 'Just npm init - bring your own setup',
|
|
152
|
+
},
|
|
153
|
+
]));
|
|
109
154
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
155
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
156
|
+
// STEP 5: Run framework CLI or init clean project
|
|
157
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
158
|
+
|
|
159
|
+
header('Step 4: Creating Project');
|
|
160
|
+
|
|
161
|
+
if (framework === 'clean') {
|
|
162
|
+
const spin = spinner('Initializing clean project...');
|
|
163
|
+
const initSuccess = await initCleanProject(projectName);
|
|
164
|
+
if (!initSuccess) {
|
|
165
|
+
spin.fail('Project initialization failed');
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
spin.succeed('Project initialized');
|
|
169
|
+
} else {
|
|
170
|
+
const frameworkLabel = framework === 'nextjs' ? 'Next.js' : 'Vite + React';
|
|
171
|
+
log.step(`Creating ${frameworkLabel} project...`);
|
|
172
|
+
log.blank();
|
|
173
|
+
|
|
174
|
+
const frameworkSuccess = await runFrameworkCli(framework, projectName);
|
|
175
|
+
if (!frameworkSuccess) {
|
|
176
|
+
errorBox('Framework CLI failed. Please check the output above.');
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
114
179
|
}
|
|
115
180
|
|
|
116
181
|
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
117
|
-
// STEP
|
|
182
|
+
// STEP 6: Add Husar configuration (with hybrid storage)
|
|
118
183
|
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
119
184
|
|
|
120
|
-
|
|
121
|
-
|
|
185
|
+
header('Step 5: Configuring Husar');
|
|
186
|
+
|
|
187
|
+
const configSpin = spinner('Adding configuration...');
|
|
188
|
+
await addHusarConfig(projectPath, project, framework);
|
|
189
|
+
configSpin.succeed('Configuration added');
|
|
190
|
+
|
|
191
|
+
log.substep('husar.json - CMS configuration');
|
|
192
|
+
log.substep('.env.local - Credentials (keep secret!)');
|
|
193
|
+
log.substep('opencode.jsonc - MCP integration');
|
|
194
|
+
log.substep('.opencode/command/husar.md - MCP docs');
|
|
122
195
|
|
|
123
196
|
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
124
|
-
// STEP
|
|
197
|
+
// STEP 7: Install Husar packages
|
|
125
198
|
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
126
199
|
|
|
127
200
|
if (!options.skipInstall) {
|
|
128
|
-
|
|
201
|
+
log.blank();
|
|
202
|
+
const installSpin = spinner('Installing Husar packages...');
|
|
129
203
|
await installHusarPackages(projectPath);
|
|
204
|
+
installSpin.succeed('Packages installed');
|
|
205
|
+
log.substep('@husar.ai/ssr');
|
|
206
|
+
log.substep('@husar.ai/render');
|
|
130
207
|
}
|
|
131
208
|
|
|
132
209
|
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
133
|
-
// STEP
|
|
210
|
+
// STEP 8: Generate CMS files
|
|
134
211
|
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
135
212
|
|
|
136
|
-
|
|
137
|
-
|
|
213
|
+
log.blank();
|
|
214
|
+
const genSpin = spinner('Generating CMS client...');
|
|
215
|
+
await generateCmsClient(projectPath, framework);
|
|
216
|
+
genSpin.succeed('CMS client generated');
|
|
217
|
+
log.substep('cms/zeus/ - GraphQL client');
|
|
218
|
+
log.substep('cms/ssr.ts - Server client');
|
|
219
|
+
log.substep('cms/react.ts - React components');
|
|
138
220
|
|
|
139
221
|
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
140
222
|
// SUCCESS!
|
|
141
223
|
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
142
224
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
225
|
+
successBox(
|
|
226
|
+
`Project created at ${theme.info(projectPath)}\n\n` +
|
|
227
|
+
`Framework: ${theme.muted(framework)}\n` +
|
|
228
|
+
`CMS Host: ${theme.muted(project.host)}`,
|
|
229
|
+
'Project Ready!',
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
nextSteps([
|
|
233
|
+
`cd ${chalk.cyan(projectName)}`,
|
|
234
|
+
`${chalk.cyan('npm run dev')} to start development`,
|
|
235
|
+
`Open ${theme.link('https://docs.husar.ai')} for documentation`,
|
|
236
|
+
]);
|
|
152
237
|
}
|
|
153
238
|
|
|
154
239
|
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
155
240
|
// PROMPTS
|
|
156
241
|
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
157
242
|
|
|
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
243
|
/**
|
|
207
244
|
* Prompt for project selection from list
|
|
208
245
|
*/
|
|
209
246
|
async function promptSelectProject(projects: CloudProject[]): Promise<CloudProject> {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
});
|
|
247
|
+
const choice = await select<string>(
|
|
248
|
+
'Select a project',
|
|
249
|
+
projects.map((p) => ({
|
|
250
|
+
name: p.name,
|
|
251
|
+
message: p.name,
|
|
252
|
+
hint: p.adminURL,
|
|
253
|
+
})),
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
return projects.find((p) => p.name === choice)!;
|
|
267
257
|
}
|
|
268
258
|
|
|
269
259
|
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
@@ -279,23 +269,25 @@ async function ensureCloudLogin(): Promise<{ accessToken: string; email?: string
|
|
|
279
269
|
|
|
280
270
|
if (existingAuth) {
|
|
281
271
|
// Verify the token is still valid
|
|
282
|
-
|
|
272
|
+
const spin = spinner('Checking existing session...');
|
|
283
273
|
const isValid = await verifyAccessToken(existingAuth.accessToken);
|
|
284
274
|
|
|
285
275
|
if (isValid) {
|
|
286
|
-
|
|
276
|
+
spin.succeed(`Logged in as ${theme.info(existingAuth.email ?? 'user')}`);
|
|
287
277
|
return existingAuth;
|
|
288
278
|
}
|
|
289
279
|
|
|
290
|
-
|
|
280
|
+
spin.warn('Session expired, need to re-authenticate');
|
|
291
281
|
}
|
|
292
282
|
|
|
293
283
|
// Need to login
|
|
294
|
-
|
|
284
|
+
log.info('Please log in to Husar.ai...');
|
|
285
|
+
log.blank();
|
|
286
|
+
|
|
295
287
|
const result = await startCloudLoginFlow();
|
|
296
288
|
|
|
297
289
|
if (result.success && result.accessToken) {
|
|
298
|
-
|
|
290
|
+
log.success(`Logged in as ${theme.info(result.email ?? 'user')}`);
|
|
299
291
|
return {
|
|
300
292
|
accessToken: result.accessToken,
|
|
301
293
|
email: result.email,
|
|
@@ -309,11 +301,63 @@ async function ensureCloudLogin(): Promise<{ accessToken: string; email?: string
|
|
|
309
301
|
// FRAMEWORK CLI
|
|
310
302
|
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
311
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Initialize a clean project (npm init only)
|
|
306
|
+
*/
|
|
307
|
+
async function initCleanProject(projectName: string): Promise<boolean> {
|
|
308
|
+
const projectPath = resolve(process.cwd(), projectName);
|
|
309
|
+
|
|
310
|
+
return new Promise((done) => {
|
|
311
|
+
// Create directory
|
|
312
|
+
const mkdirChild = spawn('mkdir', ['-p', projectName], {
|
|
313
|
+
stdio: 'pipe',
|
|
314
|
+
shell: true,
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
mkdirChild.on('close', (mkdirCode) => {
|
|
318
|
+
if (mkdirCode !== 0) {
|
|
319
|
+
done(false);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Run npm init -y
|
|
324
|
+
const initChild = spawn('npm', ['init', '-y'], {
|
|
325
|
+
cwd: projectPath,
|
|
326
|
+
stdio: 'pipe',
|
|
327
|
+
shell: true,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
initChild.on('close', async (initCode: number | null) => {
|
|
331
|
+
if (initCode !== 0) {
|
|
332
|
+
done(false);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Create src directory
|
|
337
|
+
try {
|
|
338
|
+
await fs.mkdir(join(projectPath, 'src'), { recursive: true });
|
|
339
|
+
done(true);
|
|
340
|
+
} catch {
|
|
341
|
+
done(false);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
initChild.on('error', () => {
|
|
346
|
+
done(false);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
mkdirChild.on('error', () => {
|
|
351
|
+
done(false);
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
312
356
|
/**
|
|
313
357
|
* Run the framework-specific CLI
|
|
314
358
|
*/
|
|
315
359
|
async function runFrameworkCli(framework: 'nextjs' | 'vite', projectName: string): Promise<boolean> {
|
|
316
|
-
return new Promise((
|
|
360
|
+
return new Promise((resolvePromise) => {
|
|
317
361
|
let command: string;
|
|
318
362
|
let args: string[];
|
|
319
363
|
|
|
@@ -337,7 +381,8 @@ async function runFrameworkCli(framework: 'nextjs' | 'vite', projectName: string
|
|
|
337
381
|
args = ['create', 'vite@latest', projectName, '--', '--template', 'react-ts'];
|
|
338
382
|
}
|
|
339
383
|
|
|
340
|
-
|
|
384
|
+
log.dim(`Running: ${command} ${args.join(' ')}`);
|
|
385
|
+
log.blank();
|
|
341
386
|
|
|
342
387
|
const child = spawn(command, args, {
|
|
343
388
|
stdio: 'inherit',
|
|
@@ -345,12 +390,11 @@ async function runFrameworkCli(framework: 'nextjs' | 'vite', projectName: string
|
|
|
345
390
|
});
|
|
346
391
|
|
|
347
392
|
child.on('close', (code) => {
|
|
348
|
-
|
|
393
|
+
resolvePromise(code === 0);
|
|
349
394
|
});
|
|
350
395
|
|
|
351
|
-
child.on('error', (
|
|
352
|
-
|
|
353
|
-
resolve(false);
|
|
396
|
+
child.on('error', () => {
|
|
397
|
+
resolvePromise(false);
|
|
354
398
|
});
|
|
355
399
|
});
|
|
356
400
|
}
|
|
@@ -359,23 +403,153 @@ async function runFrameworkCli(framework: 'nextjs' | 'vite', projectName: string
|
|
|
359
403
|
// CONFIGURATION
|
|
360
404
|
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
361
405
|
|
|
406
|
+
/**
|
|
407
|
+
* MCP command template for .opencode/command/husar.md
|
|
408
|
+
*/
|
|
409
|
+
const HUSAR_COMMAND_TEMPLATE = `# /husar - Husar.ai CMS Operations
|
|
410
|
+
|
|
411
|
+
Use the **husar.ai** MCP server (configured in \`opencode.jsonc\`) for all CMS operations.
|
|
412
|
+
|
|
413
|
+
## Available MCP Tools
|
|
414
|
+
|
|
415
|
+
### Discovery & Schema
|
|
416
|
+
|
|
417
|
+
- \`listModels\` ā List all model definitions with their field structures
|
|
418
|
+
- \`listViews\` ā List all view definitions with their field structures
|
|
419
|
+
- \`listShapes\` ā List all shape definitions
|
|
420
|
+
- \`graphQLTypes\` ā Get the generated GraphQL SDL schema (understand exact input/output types)
|
|
421
|
+
- \`rootParams\` ā Get root parameters (e.g. available \`_language\` options)
|
|
422
|
+
- \`links\` ā List internal links
|
|
423
|
+
|
|
424
|
+
### Shape Inspection
|
|
425
|
+
|
|
426
|
+
- \`shape.getWithDefinition\` ā Get complete shape info including definition, fieldSet, and GraphQL types
|
|
427
|
+
- \`shape.model\` ā Get full shape configuration
|
|
428
|
+
- \`shape.previewFields\` ā Get expanded fields for a shape
|
|
429
|
+
- \`shape.fieldSet\` ā Get admin page fieldset for a shape
|
|
430
|
+
|
|
431
|
+
### Model Operations
|
|
432
|
+
|
|
433
|
+
- \`model.getWithContent\` ā **Use before upsert** to understand exact structure and get current content
|
|
434
|
+
- \`model.list\` ā List all documents for a model
|
|
435
|
+
- \`model.one\` ā Get a single document by slug
|
|
436
|
+
- \`model.upsert\` ā Create or update a model document (provide slug + args matching field definitions)
|
|
437
|
+
- \`model.remove\` ā Remove a document by slug
|
|
438
|
+
|
|
439
|
+
### View Operations (singletons)
|
|
440
|
+
|
|
441
|
+
- \`view.getWithContent\` ā **Use before upsert** to understand exact structure and get current content
|
|
442
|
+
- \`view.one\` ā Get view content
|
|
443
|
+
- \`view.upsert\` ā Create or update view content
|
|
444
|
+
- \`view.remove\` ā Remove a view entry
|
|
445
|
+
|
|
446
|
+
### Definition Management
|
|
447
|
+
|
|
448
|
+
- \`upsertModel\` / \`removeModel\` ā Manage model definitions (schema)
|
|
449
|
+
- \`upsertView\` / \`removeView\` ā Manage view definitions (schema)
|
|
450
|
+
- \`upsertShape\` / \`removeShape\` ā Manage shape definitions (schema)
|
|
451
|
+
- \`removeModelWithDocuments\` ā Remove a model and all its documents
|
|
452
|
+
|
|
453
|
+
### Styling
|
|
454
|
+
|
|
455
|
+
- \`style.tailwind.css.get\` ā Fetch current Tailwind CSS source and compiled variants
|
|
456
|
+
- \`style.tailwind.css.set\` ā Set Tailwind CSS source content (Tailwind v4 compiles it after save)
|
|
457
|
+
|
|
458
|
+
### AI Generation
|
|
459
|
+
|
|
460
|
+
- \`generateContent\` ā Generate content using AI
|
|
461
|
+
- \`generateImage\` ā Generate an image using AI
|
|
462
|
+
- \`translateDocument\` ā Translate a model document to another language
|
|
463
|
+
- \`translateView\` ā Translate a view to another language
|
|
464
|
+
|
|
465
|
+
### Links
|
|
466
|
+
|
|
467
|
+
- \`upsertLink\` ā Create or update an internal link
|
|
468
|
+
- \`removeLink\` ā Remove an internal link
|
|
469
|
+
|
|
470
|
+
### Files
|
|
471
|
+
|
|
472
|
+
- \`uploadFile\` ā Get a signed S3 PUT URL for uploading a file
|
|
473
|
+
|
|
474
|
+
## Workflow Guidelines
|
|
475
|
+
|
|
476
|
+
1. **Always call \`getWithContent\` before any upsert** ā This gives you the exact field structure and current data so you can construct valid input.
|
|
477
|
+
2. **Use \`graphQLTypes\`** when you need to understand the precise GraphQL input/output types expected by upsert operations.
|
|
478
|
+
3. **Use \`listModels\` / \`listViews\` / \`listShapes\`** for discovery ā find out what content types exist before operating on them.
|
|
479
|
+
4. **For SHAPE fields**, provide nested objects matching the shape's field structure.
|
|
480
|
+
5. **For RELATION fields**, provide \`_id\` strings.
|
|
481
|
+
6. **Root params** (like \`_language\`) can be passed as a \`filter\` parameter to scope operations by locale.
|
|
482
|
+
7. **Tailwind styles** are managed through the CMS ā fetch with \`style.tailwind.css.get\` and update with \`style.tailwind.css.set\`.
|
|
483
|
+
8. **Draft versions** can be saved by setting \`draft_version: true\` on upsert calls.
|
|
484
|
+
|
|
485
|
+
## Example Workflows
|
|
486
|
+
|
|
487
|
+
### Update view content
|
|
488
|
+
|
|
489
|
+
\`\`\`
|
|
490
|
+
1. view.getWithContent (understand structure + current data)
|
|
491
|
+
2. view.upsert (provide updated args)
|
|
492
|
+
\`\`\`
|
|
493
|
+
|
|
494
|
+
### Create a new model document
|
|
495
|
+
|
|
496
|
+
\`\`\`
|
|
497
|
+
1. model.getWithContent (understand field structure)
|
|
498
|
+
2. model.upsert (provide slug + args)
|
|
499
|
+
\`\`\`
|
|
500
|
+
|
|
501
|
+
### Modify CMS schema
|
|
502
|
+
|
|
503
|
+
\`\`\`
|
|
504
|
+
1. listModels / listViews / listShapes (see what exists)
|
|
505
|
+
2. graphQLTypes (understand current schema)
|
|
506
|
+
3. upsertModel / upsertView / upsertShape (modify definitions)
|
|
507
|
+
\`\`\`
|
|
508
|
+
|
|
509
|
+
### Update Tailwind styles
|
|
510
|
+
|
|
511
|
+
\`\`\`
|
|
512
|
+
1. style.tailwind.css.get (see current styles)
|
|
513
|
+
2. style.tailwind.css.set (update with new content)
|
|
514
|
+
\`\`\`
|
|
515
|
+
`;
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Create .opencode/command/husar.md with MCP documentation
|
|
519
|
+
*/
|
|
520
|
+
async function createOpencodeCommandFolder(projectPath: string): Promise<void> {
|
|
521
|
+
const commandDir = join(projectPath, '.opencode', 'command');
|
|
522
|
+
|
|
523
|
+
try {
|
|
524
|
+
await fs.mkdir(commandDir, { recursive: true });
|
|
525
|
+
await fs.writeFile(join(commandDir, 'husar.md'), HUSAR_COMMAND_TEMPLATE);
|
|
526
|
+
} catch {
|
|
527
|
+
// Silent fail - not critical
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
362
531
|
/**
|
|
363
532
|
* Add Husar configuration files to the project
|
|
364
533
|
* Uses HYBRID storage: husar.json (committal) + .env.local (secrets)
|
|
365
534
|
*/
|
|
366
|
-
async function addHusarConfig(
|
|
367
|
-
|
|
535
|
+
async function addHusarConfig(
|
|
536
|
+
projectPath: string,
|
|
537
|
+
project: ProjectAuth,
|
|
538
|
+
framework: 'nextjs' | 'vite' | 'clean',
|
|
539
|
+
): Promise<void> {
|
|
540
|
+
// Use appropriate env var prefix based on framework
|
|
541
|
+
const hostEnvVar =
|
|
542
|
+
framework === 'nextjs' ? 'NEXT_PUBLIC_HUSAR_HOST' : framework === 'vite' ? 'VITE_HUSAR_HOST' : 'HUSAR_HOST';
|
|
368
543
|
|
|
369
544
|
// 1. Create husar.json (committal - NO adminToken!)
|
|
370
545
|
const husarConfig = {
|
|
371
546
|
host: project.host,
|
|
372
547
|
hostEnvironmentVariable: hostEnvVar,
|
|
373
548
|
authenticationEnvironmentVariable: 'HUSAR_API_KEY',
|
|
374
|
-
adminTokenEnv: 'HUSAR_ADMIN_TOKEN',
|
|
549
|
+
adminTokenEnv: 'HUSAR_ADMIN_TOKEN',
|
|
375
550
|
overrideHost: false,
|
|
376
551
|
};
|
|
377
552
|
await fs.writeFile(join(projectPath, 'husar.json'), JSON.stringify(husarConfig, null, 2));
|
|
378
|
-
console.log(' ā Created husar.json');
|
|
379
553
|
|
|
380
554
|
// 2. Create .env.local (secrets - NOT committed)
|
|
381
555
|
const envContent = `# Husar CMS Configuration
|
|
@@ -391,7 +565,6 @@ HUSAR_API_KEY=
|
|
|
391
565
|
HUSAR_ADMIN_TOKEN=${project.adminToken}
|
|
392
566
|
`;
|
|
393
567
|
await fs.writeFile(join(projectPath, '.env.local'), envContent);
|
|
394
|
-
console.log(' ā Created .env.local (contains admin credentials)');
|
|
395
568
|
|
|
396
569
|
// 3. Create opencode.jsonc for MCP integration
|
|
397
570
|
const opencodeConfig = {
|
|
@@ -405,9 +578,11 @@ HUSAR_ADMIN_TOKEN=${project.adminToken}
|
|
|
405
578
|
},
|
|
406
579
|
};
|
|
407
580
|
await fs.writeFile(join(projectPath, 'opencode.jsonc'), JSON.stringify(opencodeConfig, null, 2));
|
|
408
|
-
console.log(' ā Created opencode.jsonc (MCP configuration)');
|
|
409
581
|
|
|
410
|
-
// 4.
|
|
582
|
+
// 4. Create .opencode/command/husar.md (MCP command documentation)
|
|
583
|
+
await createOpencodeCommandFolder(projectPath);
|
|
584
|
+
|
|
585
|
+
// 5. Update .gitignore
|
|
411
586
|
const gitignorePath = join(projectPath, '.gitignore');
|
|
412
587
|
try {
|
|
413
588
|
let gitignore = await fs.readFile(gitignorePath, 'utf-8');
|
|
@@ -423,12 +598,10 @@ HUSAR_ADMIN_TOKEN=${project.adminToken}
|
|
|
423
598
|
if (additions.length > 0) {
|
|
424
599
|
gitignore += '\n# Local environment files (contain secrets)\n' + additions.join('\n') + '\n';
|
|
425
600
|
await fs.writeFile(gitignorePath, gitignore);
|
|
426
|
-
console.log(' ā Updated .gitignore');
|
|
427
601
|
}
|
|
428
602
|
} catch {
|
|
429
603
|
// .gitignore doesn't exist, create it
|
|
430
604
|
await fs.writeFile(gitignorePath, '# Local environment files (contain secrets)\n.env.local\n.env*.local\n');
|
|
431
|
-
console.log(' ā Created .gitignore');
|
|
432
605
|
}
|
|
433
606
|
}
|
|
434
607
|
|
|
@@ -436,24 +609,21 @@ HUSAR_ADMIN_TOKEN=${project.adminToken}
|
|
|
436
609
|
* Install Husar packages
|
|
437
610
|
*/
|
|
438
611
|
async function installHusarPackages(projectPath: string): Promise<void> {
|
|
439
|
-
return new Promise((
|
|
612
|
+
return new Promise((resolvePromise) => {
|
|
440
613
|
const packages = ['@husar.ai/ssr', '@husar.ai/render'];
|
|
441
614
|
|
|
442
615
|
const child = spawn('npm', ['install', ...packages], {
|
|
443
616
|
cwd: projectPath,
|
|
444
|
-
stdio: '
|
|
617
|
+
stdio: 'pipe',
|
|
445
618
|
shell: true,
|
|
446
619
|
});
|
|
447
620
|
|
|
448
621
|
child.on('close', () => {
|
|
449
|
-
|
|
450
|
-
resolve();
|
|
622
|
+
resolvePromise();
|
|
451
623
|
});
|
|
452
624
|
|
|
453
|
-
child.on('error', (
|
|
454
|
-
|
|
455
|
-
console.log(' Run manually: npm install @husar.ai/ssr @husar.ai/render');
|
|
456
|
-
resolve();
|
|
625
|
+
child.on('error', () => {
|
|
626
|
+
resolvePromise();
|
|
457
627
|
});
|
|
458
628
|
});
|
|
459
629
|
}
|
|
@@ -461,29 +631,23 @@ async function installHusarPackages(projectPath: string): Promise<void> {
|
|
|
461
631
|
/**
|
|
462
632
|
* Generate CMS client files using husar generate
|
|
463
633
|
*/
|
|
464
|
-
async function generateCmsClient(projectPath: string, framework: 'nextjs' | 'vite'): Promise<void> {
|
|
465
|
-
//
|
|
466
|
-
const srcFolder =
|
|
634
|
+
async function generateCmsClient(projectPath: string, framework: 'nextjs' | 'vite' | 'clean'): Promise<void> {
|
|
635
|
+
// All frameworks use ./src
|
|
636
|
+
const srcFolder = './src';
|
|
467
637
|
|
|
468
|
-
return new Promise((
|
|
638
|
+
return new Promise((resolvePromise) => {
|
|
469
639
|
const child = spawn('npx', ['@husar.ai/cli', 'generate', srcFolder], {
|
|
470
640
|
cwd: projectPath,
|
|
471
|
-
stdio: '
|
|
641
|
+
stdio: 'pipe',
|
|
472
642
|
shell: true,
|
|
473
643
|
});
|
|
474
644
|
|
|
475
|
-
child.on('close', (
|
|
476
|
-
|
|
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();
|
|
645
|
+
child.on('close', () => {
|
|
646
|
+
resolvePromise();
|
|
482
647
|
});
|
|
483
648
|
|
|
484
649
|
child.on('error', () => {
|
|
485
|
-
|
|
486
|
-
resolve();
|
|
650
|
+
resolvePromise();
|
|
487
651
|
});
|
|
488
652
|
});
|
|
489
653
|
}
|