@husar.ai/cli 0.4.3 ā 0.4.5
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/cli.js +132 -54
- package/dist/cli.js.map +1 -1
- package/dist/functions/create.d.ts +1 -1
- package/dist/functions/create.js +287 -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 +10 -6
- package/src/cli.ts +183 -61
- package/src/functions/create.ts +356 -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,227 @@ 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
|
+
]);
|
|
237
|
+
|
|
238
|
+
process.exit(0);
|
|
152
239
|
}
|
|
153
240
|
|
|
154
241
|
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
155
242
|
// PROMPTS
|
|
156
243
|
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
157
244
|
|
|
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
245
|
/**
|
|
207
246
|
* Prompt for project selection from list
|
|
208
247
|
*/
|
|
209
248
|
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
|
-
});
|
|
249
|
+
const choice = await select<string>(
|
|
250
|
+
'Select a project',
|
|
251
|
+
projects.map((p) => ({
|
|
252
|
+
name: p.name,
|
|
253
|
+
message: p.name,
|
|
254
|
+
hint: p.adminURL,
|
|
255
|
+
})),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
return projects.find((p) => p.name === choice)!;
|
|
267
259
|
}
|
|
268
260
|
|
|
269
261
|
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
@@ -279,23 +271,25 @@ async function ensureCloudLogin(): Promise<{ accessToken: string; email?: string
|
|
|
279
271
|
|
|
280
272
|
if (existingAuth) {
|
|
281
273
|
// Verify the token is still valid
|
|
282
|
-
|
|
274
|
+
const spin = spinner('Checking existing session...');
|
|
283
275
|
const isValid = await verifyAccessToken(existingAuth.accessToken);
|
|
284
276
|
|
|
285
277
|
if (isValid) {
|
|
286
|
-
|
|
278
|
+
spin.succeed(`Logged in as ${theme.info(existingAuth.email ?? 'user')}`);
|
|
287
279
|
return existingAuth;
|
|
288
280
|
}
|
|
289
281
|
|
|
290
|
-
|
|
282
|
+
spin.warn('Session expired, need to re-authenticate');
|
|
291
283
|
}
|
|
292
284
|
|
|
293
285
|
// Need to login
|
|
294
|
-
|
|
286
|
+
log.info('Please log in to Husar.ai...');
|
|
287
|
+
log.blank();
|
|
288
|
+
|
|
295
289
|
const result = await startCloudLoginFlow();
|
|
296
290
|
|
|
297
291
|
if (result.success && result.accessToken) {
|
|
298
|
-
|
|
292
|
+
log.success(`Logged in as ${theme.info(result.email ?? 'user')}`);
|
|
299
293
|
return {
|
|
300
294
|
accessToken: result.accessToken,
|
|
301
295
|
email: result.email,
|
|
@@ -309,11 +303,63 @@ async function ensureCloudLogin(): Promise<{ accessToken: string; email?: string
|
|
|
309
303
|
// FRAMEWORK CLI
|
|
310
304
|
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
311
305
|
|
|
306
|
+
/**
|
|
307
|
+
* Initialize a clean project (npm init only)
|
|
308
|
+
*/
|
|
309
|
+
async function initCleanProject(projectName: string): Promise<boolean> {
|
|
310
|
+
const projectPath = resolve(process.cwd(), projectName);
|
|
311
|
+
|
|
312
|
+
return new Promise((done) => {
|
|
313
|
+
// Create directory
|
|
314
|
+
const mkdirChild = spawn('mkdir', ['-p', projectName], {
|
|
315
|
+
stdio: 'pipe',
|
|
316
|
+
shell: true,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
mkdirChild.on('close', (mkdirCode) => {
|
|
320
|
+
if (mkdirCode !== 0) {
|
|
321
|
+
done(false);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Run npm init -y
|
|
326
|
+
const initChild = spawn('npm', ['init', '-y'], {
|
|
327
|
+
cwd: projectPath,
|
|
328
|
+
stdio: 'pipe',
|
|
329
|
+
shell: true,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
initChild.on('close', async (initCode: number | null) => {
|
|
333
|
+
if (initCode !== 0) {
|
|
334
|
+
done(false);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Create src directory
|
|
339
|
+
try {
|
|
340
|
+
await fs.mkdir(join(projectPath, 'src'), { recursive: true });
|
|
341
|
+
done(true);
|
|
342
|
+
} catch {
|
|
343
|
+
done(false);
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
initChild.on('error', () => {
|
|
348
|
+
done(false);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
mkdirChild.on('error', () => {
|
|
353
|
+
done(false);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
312
358
|
/**
|
|
313
359
|
* Run the framework-specific CLI
|
|
314
360
|
*/
|
|
315
361
|
async function runFrameworkCli(framework: 'nextjs' | 'vite', projectName: string): Promise<boolean> {
|
|
316
|
-
return new Promise((
|
|
362
|
+
return new Promise((resolvePromise) => {
|
|
317
363
|
let command: string;
|
|
318
364
|
let args: string[];
|
|
319
365
|
|
|
@@ -337,7 +383,8 @@ async function runFrameworkCli(framework: 'nextjs' | 'vite', projectName: string
|
|
|
337
383
|
args = ['create', 'vite@latest', projectName, '--', '--template', 'react-ts'];
|
|
338
384
|
}
|
|
339
385
|
|
|
340
|
-
|
|
386
|
+
log.dim(`Running: ${command} ${args.join(' ')}`);
|
|
387
|
+
log.blank();
|
|
341
388
|
|
|
342
389
|
const child = spawn(command, args, {
|
|
343
390
|
stdio: 'inherit',
|
|
@@ -345,12 +392,11 @@ async function runFrameworkCli(framework: 'nextjs' | 'vite', projectName: string
|
|
|
345
392
|
});
|
|
346
393
|
|
|
347
394
|
child.on('close', (code) => {
|
|
348
|
-
|
|
395
|
+
resolvePromise(code === 0);
|
|
349
396
|
});
|
|
350
397
|
|
|
351
|
-
child.on('error', (
|
|
352
|
-
|
|
353
|
-
resolve(false);
|
|
398
|
+
child.on('error', () => {
|
|
399
|
+
resolvePromise(false);
|
|
354
400
|
});
|
|
355
401
|
});
|
|
356
402
|
}
|
|
@@ -359,23 +405,153 @@ async function runFrameworkCli(framework: 'nextjs' | 'vite', projectName: string
|
|
|
359
405
|
// CONFIGURATION
|
|
360
406
|
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
361
407
|
|
|
408
|
+
/**
|
|
409
|
+
* MCP command template for .opencode/command/husar.md
|
|
410
|
+
*/
|
|
411
|
+
const HUSAR_COMMAND_TEMPLATE = `# /husar - Husar.ai CMS Operations
|
|
412
|
+
|
|
413
|
+
Use the **husar.ai** MCP server (configured in \`opencode.jsonc\`) for all CMS operations.
|
|
414
|
+
|
|
415
|
+
## Available MCP Tools
|
|
416
|
+
|
|
417
|
+
### Discovery & Schema
|
|
418
|
+
|
|
419
|
+
- \`listModels\` ā List all model definitions with their field structures
|
|
420
|
+
- \`listViews\` ā List all view definitions with their field structures
|
|
421
|
+
- \`listShapes\` ā List all shape definitions
|
|
422
|
+
- \`graphQLTypes\` ā Get the generated GraphQL SDL schema (understand exact input/output types)
|
|
423
|
+
- \`rootParams\` ā Get root parameters (e.g. available \`_language\` options)
|
|
424
|
+
- \`links\` ā List internal links
|
|
425
|
+
|
|
426
|
+
### Shape Inspection
|
|
427
|
+
|
|
428
|
+
- \`shape.getWithDefinition\` ā Get complete shape info including definition, fieldSet, and GraphQL types
|
|
429
|
+
- \`shape.model\` ā Get full shape configuration
|
|
430
|
+
- \`shape.previewFields\` ā Get expanded fields for a shape
|
|
431
|
+
- \`shape.fieldSet\` ā Get admin page fieldset for a shape
|
|
432
|
+
|
|
433
|
+
### Model Operations
|
|
434
|
+
|
|
435
|
+
- \`model.getWithContent\` ā **Use before upsert** to understand exact structure and get current content
|
|
436
|
+
- \`model.list\` ā List all documents for a model
|
|
437
|
+
- \`model.one\` ā Get a single document by slug
|
|
438
|
+
- \`model.upsert\` ā Create or update a model document (provide slug + args matching field definitions)
|
|
439
|
+
- \`model.remove\` ā Remove a document by slug
|
|
440
|
+
|
|
441
|
+
### View Operations (singletons)
|
|
442
|
+
|
|
443
|
+
- \`view.getWithContent\` ā **Use before upsert** to understand exact structure and get current content
|
|
444
|
+
- \`view.one\` ā Get view content
|
|
445
|
+
- \`view.upsert\` ā Create or update view content
|
|
446
|
+
- \`view.remove\` ā Remove a view entry
|
|
447
|
+
|
|
448
|
+
### Definition Management
|
|
449
|
+
|
|
450
|
+
- \`upsertModel\` / \`removeModel\` ā Manage model definitions (schema)
|
|
451
|
+
- \`upsertView\` / \`removeView\` ā Manage view definitions (schema)
|
|
452
|
+
- \`upsertShape\` / \`removeShape\` ā Manage shape definitions (schema)
|
|
453
|
+
- \`removeModelWithDocuments\` ā Remove a model and all its documents
|
|
454
|
+
|
|
455
|
+
### Styling
|
|
456
|
+
|
|
457
|
+
- \`style.tailwind.css.get\` ā Fetch current Tailwind CSS source and compiled variants
|
|
458
|
+
- \`style.tailwind.css.set\` ā Set Tailwind CSS source content (Tailwind v4 compiles it after save)
|
|
459
|
+
|
|
460
|
+
### AI Generation
|
|
461
|
+
|
|
462
|
+
- \`generateContent\` ā Generate content using AI
|
|
463
|
+
- \`generateImage\` ā Generate an image using AI
|
|
464
|
+
- \`translateDocument\` ā Translate a model document to another language
|
|
465
|
+
- \`translateView\` ā Translate a view to another language
|
|
466
|
+
|
|
467
|
+
### Links
|
|
468
|
+
|
|
469
|
+
- \`upsertLink\` ā Create or update an internal link
|
|
470
|
+
- \`removeLink\` ā Remove an internal link
|
|
471
|
+
|
|
472
|
+
### Files
|
|
473
|
+
|
|
474
|
+
- \`uploadFile\` ā Get a signed S3 PUT URL for uploading a file
|
|
475
|
+
|
|
476
|
+
## Workflow Guidelines
|
|
477
|
+
|
|
478
|
+
1. **Always call \`getWithContent\` before any upsert** ā This gives you the exact field structure and current data so you can construct valid input.
|
|
479
|
+
2. **Use \`graphQLTypes\`** when you need to understand the precise GraphQL input/output types expected by upsert operations.
|
|
480
|
+
3. **Use \`listModels\` / \`listViews\` / \`listShapes\`** for discovery ā find out what content types exist before operating on them.
|
|
481
|
+
4. **For SHAPE fields**, provide nested objects matching the shape's field structure.
|
|
482
|
+
5. **For RELATION fields**, provide \`_id\` strings.
|
|
483
|
+
6. **Root params** (like \`_language\`) can be passed as a \`filter\` parameter to scope operations by locale.
|
|
484
|
+
7. **Tailwind styles** are managed through the CMS ā fetch with \`style.tailwind.css.get\` and update with \`style.tailwind.css.set\`.
|
|
485
|
+
8. **Draft versions** can be saved by setting \`draft_version: true\` on upsert calls.
|
|
486
|
+
|
|
487
|
+
## Example Workflows
|
|
488
|
+
|
|
489
|
+
### Update view content
|
|
490
|
+
|
|
491
|
+
\`\`\`
|
|
492
|
+
1. view.getWithContent (understand structure + current data)
|
|
493
|
+
2. view.upsert (provide updated args)
|
|
494
|
+
\`\`\`
|
|
495
|
+
|
|
496
|
+
### Create a new model document
|
|
497
|
+
|
|
498
|
+
\`\`\`
|
|
499
|
+
1. model.getWithContent (understand field structure)
|
|
500
|
+
2. model.upsert (provide slug + args)
|
|
501
|
+
\`\`\`
|
|
502
|
+
|
|
503
|
+
### Modify CMS schema
|
|
504
|
+
|
|
505
|
+
\`\`\`
|
|
506
|
+
1. listModels / listViews / listShapes (see what exists)
|
|
507
|
+
2. graphQLTypes (understand current schema)
|
|
508
|
+
3. upsertModel / upsertView / upsertShape (modify definitions)
|
|
509
|
+
\`\`\`
|
|
510
|
+
|
|
511
|
+
### Update Tailwind styles
|
|
512
|
+
|
|
513
|
+
\`\`\`
|
|
514
|
+
1. style.tailwind.css.get (see current styles)
|
|
515
|
+
2. style.tailwind.css.set (update with new content)
|
|
516
|
+
\`\`\`
|
|
517
|
+
`;
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Create .opencode/command/husar.md with MCP documentation
|
|
521
|
+
*/
|
|
522
|
+
async function createOpencodeCommandFolder(projectPath: string): Promise<void> {
|
|
523
|
+
const commandDir = join(projectPath, '.opencode', 'command');
|
|
524
|
+
|
|
525
|
+
try {
|
|
526
|
+
await fs.mkdir(commandDir, { recursive: true });
|
|
527
|
+
await fs.writeFile(join(commandDir, 'husar.md'), HUSAR_COMMAND_TEMPLATE);
|
|
528
|
+
} catch {
|
|
529
|
+
// Silent fail - not critical
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
362
533
|
/**
|
|
363
534
|
* Add Husar configuration files to the project
|
|
364
535
|
* Uses HYBRID storage: husar.json (committal) + .env.local (secrets)
|
|
365
536
|
*/
|
|
366
|
-
async function addHusarConfig(
|
|
367
|
-
|
|
537
|
+
async function addHusarConfig(
|
|
538
|
+
projectPath: string,
|
|
539
|
+
project: ProjectAuth,
|
|
540
|
+
framework: 'nextjs' | 'vite' | 'clean',
|
|
541
|
+
): Promise<void> {
|
|
542
|
+
// Use appropriate env var prefix based on framework
|
|
543
|
+
const hostEnvVar =
|
|
544
|
+
framework === 'nextjs' ? 'NEXT_PUBLIC_HUSAR_HOST' : framework === 'vite' ? 'VITE_HUSAR_HOST' : 'HUSAR_HOST';
|
|
368
545
|
|
|
369
546
|
// 1. Create husar.json (committal - NO adminToken!)
|
|
370
547
|
const husarConfig = {
|
|
371
548
|
host: project.host,
|
|
372
549
|
hostEnvironmentVariable: hostEnvVar,
|
|
373
550
|
authenticationEnvironmentVariable: 'HUSAR_API_KEY',
|
|
374
|
-
adminTokenEnv: 'HUSAR_ADMIN_TOKEN',
|
|
551
|
+
adminTokenEnv: 'HUSAR_ADMIN_TOKEN',
|
|
375
552
|
overrideHost: false,
|
|
376
553
|
};
|
|
377
554
|
await fs.writeFile(join(projectPath, 'husar.json'), JSON.stringify(husarConfig, null, 2));
|
|
378
|
-
console.log(' ā Created husar.json');
|
|
379
555
|
|
|
380
556
|
// 2. Create .env.local (secrets - NOT committed)
|
|
381
557
|
const envContent = `# Husar CMS Configuration
|
|
@@ -391,7 +567,6 @@ HUSAR_API_KEY=
|
|
|
391
567
|
HUSAR_ADMIN_TOKEN=${project.adminToken}
|
|
392
568
|
`;
|
|
393
569
|
await fs.writeFile(join(projectPath, '.env.local'), envContent);
|
|
394
|
-
console.log(' ā Created .env.local (contains admin credentials)');
|
|
395
570
|
|
|
396
571
|
// 3. Create opencode.jsonc for MCP integration
|
|
397
572
|
const opencodeConfig = {
|
|
@@ -405,9 +580,11 @@ HUSAR_ADMIN_TOKEN=${project.adminToken}
|
|
|
405
580
|
},
|
|
406
581
|
};
|
|
407
582
|
await fs.writeFile(join(projectPath, 'opencode.jsonc'), JSON.stringify(opencodeConfig, null, 2));
|
|
408
|
-
console.log(' ā Created opencode.jsonc (MCP configuration)');
|
|
409
583
|
|
|
410
|
-
// 4.
|
|
584
|
+
// 4. Create .opencode/command/husar.md (MCP command documentation)
|
|
585
|
+
await createOpencodeCommandFolder(projectPath);
|
|
586
|
+
|
|
587
|
+
// 5. Update .gitignore
|
|
411
588
|
const gitignorePath = join(projectPath, '.gitignore');
|
|
412
589
|
try {
|
|
413
590
|
let gitignore = await fs.readFile(gitignorePath, 'utf-8');
|
|
@@ -423,12 +600,10 @@ HUSAR_ADMIN_TOKEN=${project.adminToken}
|
|
|
423
600
|
if (additions.length > 0) {
|
|
424
601
|
gitignore += '\n# Local environment files (contain secrets)\n' + additions.join('\n') + '\n';
|
|
425
602
|
await fs.writeFile(gitignorePath, gitignore);
|
|
426
|
-
console.log(' ā Updated .gitignore');
|
|
427
603
|
}
|
|
428
604
|
} catch {
|
|
429
605
|
// .gitignore doesn't exist, create it
|
|
430
606
|
await fs.writeFile(gitignorePath, '# Local environment files (contain secrets)\n.env.local\n.env*.local\n');
|
|
431
|
-
console.log(' ā Created .gitignore');
|
|
432
607
|
}
|
|
433
608
|
}
|
|
434
609
|
|
|
@@ -436,24 +611,21 @@ HUSAR_ADMIN_TOKEN=${project.adminToken}
|
|
|
436
611
|
* Install Husar packages
|
|
437
612
|
*/
|
|
438
613
|
async function installHusarPackages(projectPath: string): Promise<void> {
|
|
439
|
-
return new Promise((
|
|
614
|
+
return new Promise((resolvePromise) => {
|
|
440
615
|
const packages = ['@husar.ai/ssr', '@husar.ai/render'];
|
|
441
616
|
|
|
442
617
|
const child = spawn('npm', ['install', ...packages], {
|
|
443
618
|
cwd: projectPath,
|
|
444
|
-
stdio: '
|
|
619
|
+
stdio: 'pipe',
|
|
445
620
|
shell: true,
|
|
446
621
|
});
|
|
447
622
|
|
|
448
623
|
child.on('close', () => {
|
|
449
|
-
|
|
450
|
-
resolve();
|
|
624
|
+
resolvePromise();
|
|
451
625
|
});
|
|
452
626
|
|
|
453
|
-
child.on('error', (
|
|
454
|
-
|
|
455
|
-
console.log(' Run manually: npm install @husar.ai/ssr @husar.ai/render');
|
|
456
|
-
resolve();
|
|
627
|
+
child.on('error', () => {
|
|
628
|
+
resolvePromise();
|
|
457
629
|
});
|
|
458
630
|
});
|
|
459
631
|
}
|
|
@@ -461,29 +633,23 @@ async function installHusarPackages(projectPath: string): Promise<void> {
|
|
|
461
633
|
/**
|
|
462
634
|
* Generate CMS client files using husar generate
|
|
463
635
|
*/
|
|
464
|
-
async function generateCmsClient(projectPath: string, framework: 'nextjs' | 'vite'): Promise<void> {
|
|
465
|
-
//
|
|
466
|
-
const srcFolder =
|
|
636
|
+
async function generateCmsClient(projectPath: string, framework: 'nextjs' | 'vite' | 'clean'): Promise<void> {
|
|
637
|
+
// All frameworks use ./src
|
|
638
|
+
const srcFolder = './src';
|
|
467
639
|
|
|
468
|
-
return new Promise((
|
|
640
|
+
return new Promise((resolvePromise) => {
|
|
469
641
|
const child = spawn('npx', ['@husar.ai/cli', 'generate', srcFolder], {
|
|
470
642
|
cwd: projectPath,
|
|
471
|
-
stdio: '
|
|
643
|
+
stdio: 'pipe',
|
|
472
644
|
shell: true,
|
|
473
645
|
});
|
|
474
646
|
|
|
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();
|
|
647
|
+
child.on('close', () => {
|
|
648
|
+
resolvePromise();
|
|
482
649
|
});
|
|
483
650
|
|
|
484
651
|
child.on('error', () => {
|
|
485
|
-
|
|
486
|
-
resolve();
|
|
652
|
+
resolvePromise();
|
|
487
653
|
});
|
|
488
654
|
});
|
|
489
655
|
}
|