@swell/cli 2.9.18 → 2.9.19
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/README.md +90 -0
- package/dist/commands/app/dev.js +21 -34
- package/dist/commands/app/frontend/deploy.js +3 -3
- package/dist/commands/app/frontend/dev.js +5 -2
- package/dist/commands/app/init.js +12 -2
- package/dist/commands/create/app.d.ts +10 -1
- package/dist/commands/create/app.js +48 -25
- package/dist/commands/create/frontend.d.ts +3 -1
- package/dist/commands/create/frontend.js +21 -13
- package/dist/create-app-command.d.ts +10 -51
- package/dist/create-app-command.js +99 -526
- package/dist/help/custom-help.js +28 -7
- package/dist/help/types.d.ts +2 -0
- package/dist/lib/app-config.js +6 -0
- package/dist/lib/apps/frontend-package.d.ts +36 -0
- package/dist/lib/apps/frontend-package.js +375 -0
- package/dist/lib/apps/frontend-routes.d.ts +1 -0
- package/dist/lib/apps/frontend-routes.js +40 -0
- package/dist/lib/apps/frontend-scaffold.d.ts +35 -0
- package/dist/lib/apps/frontend-scaffold.js +502 -0
- package/dist/lib/apps/frontend-templates.d.ts +32 -0
- package/dist/lib/apps/frontend-templates.js +137 -0
- package/dist/lib/apps/index.d.ts +4 -6
- package/dist/lib/apps/index.js +10 -33
- package/dist/lib/apps/storefront-resources.d.ts +3 -2
- package/dist/lib/apps/storefront-resources.js +3 -2
- package/dist/push-app-command.js +22 -0
- package/dist/remote-app-command.js +6 -0
- package/dist/types/index.d.ts +5 -0
- package/oclif.manifest.json +63 -94
- package/package.json +5 -3
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
import { exec } from 'node:child_process';
|
|
3
|
+
import * as fs from 'node:fs/promises';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { getPackageManagerCommands, transformCreateCommand, } from '../package-manager.js';
|
|
7
|
+
import { writeJsonFile } from './index.js';
|
|
8
|
+
const execAsync = promisify(exec);
|
|
9
|
+
/** Allowed hosts for tunnel providers used in local development */
|
|
10
|
+
export const TUNNEL_ALLOWED_HOSTS = ['.trycloudflare.com', '.swell.store'];
|
|
11
|
+
/**
|
|
12
|
+
* Find the closing brace position for a config function call (e.g., defineConfig, defineNuxtConfig).
|
|
13
|
+
* Uses brace counting to find the matching `}` for the opening `{`.
|
|
14
|
+
* @returns Position of the closing `}`, or -1 if not found
|
|
15
|
+
*/
|
|
16
|
+
function findConfigClosingBrace(content, functionName) {
|
|
17
|
+
const pattern = new RegExp(`${functionName}\\s*\\(\\s*\\{`);
|
|
18
|
+
const match = content.match(pattern);
|
|
19
|
+
if (!match || match.index === undefined) {
|
|
20
|
+
return -1;
|
|
21
|
+
}
|
|
22
|
+
const startPos = match.index + match[0].length;
|
|
23
|
+
let depth = 1;
|
|
24
|
+
for (let i = startPos; i < content.length && depth > 0; i++) {
|
|
25
|
+
const char = content[i];
|
|
26
|
+
if (char === '{') {
|
|
27
|
+
depth++;
|
|
28
|
+
}
|
|
29
|
+
else if (char === '}') {
|
|
30
|
+
depth--;
|
|
31
|
+
if (depth === 0) {
|
|
32
|
+
return i;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return -1;
|
|
37
|
+
}
|
|
38
|
+
async function addAllowedHostsToAngular(configPath) {
|
|
39
|
+
const angularJsonPath = path.join(configPath, 'frontend', 'angular.json');
|
|
40
|
+
let content;
|
|
41
|
+
try {
|
|
42
|
+
content = await fs.readFile(angularJsonPath, 'utf8');
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
let config;
|
|
48
|
+
try {
|
|
49
|
+
config = JSON.parse(content);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const { projects } = config;
|
|
55
|
+
if (!projects || typeof projects !== 'object') {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const projectNames = Object.keys(projects);
|
|
59
|
+
if (projectNames.length === 0) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// Use first project (typically only one exists in new apps)
|
|
63
|
+
const projectName = projectNames[0];
|
|
64
|
+
const project = projects[projectName];
|
|
65
|
+
if (!project?.architect?.serve) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (!project.architect.serve.options) {
|
|
69
|
+
project.architect.serve.options = {};
|
|
70
|
+
}
|
|
71
|
+
if (project.architect.serve.options.allowedHosts) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
project.architect.serve.options.allowedHosts = TUNNEL_ALLOWED_HOSTS;
|
|
75
|
+
await fs.writeFile(angularJsonPath, JSON.stringify(config, null, 2), 'utf8');
|
|
76
|
+
}
|
|
77
|
+
async function addAllowedHostsToAstro(configPath) {
|
|
78
|
+
return addViteAllowedHosts(configPath, 'astro.config.mjs', 'defineConfig');
|
|
79
|
+
}
|
|
80
|
+
async function addAllowedHostsToNuxt(configPath) {
|
|
81
|
+
return addViteAllowedHosts(configPath, 'nuxt.config.ts', 'defineNuxtConfig');
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* For frameworks whose dev server is Vite directly. Astro/Nuxt nest the
|
|
85
|
+
* vite block inside their own defineConfig — see addViteAllowedHosts.
|
|
86
|
+
*/
|
|
87
|
+
async function addAllowedHostsToVite(configPath) {
|
|
88
|
+
const candidates = [
|
|
89
|
+
'vite.config.ts',
|
|
90
|
+
'vite.config.js',
|
|
91
|
+
'vite.config.mts',
|
|
92
|
+
'vite.config.mjs',
|
|
93
|
+
];
|
|
94
|
+
let configFilePath;
|
|
95
|
+
let content;
|
|
96
|
+
for (const fileName of candidates) {
|
|
97
|
+
const candidatePath = path.join(configPath, 'frontend', fileName);
|
|
98
|
+
try {
|
|
99
|
+
// eslint-disable-next-line no-await-in-loop
|
|
100
|
+
content = await fs.readFile(candidatePath, 'utf8');
|
|
101
|
+
configFilePath = candidatePath;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
/* try next */
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (!configFilePath || content === undefined) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (content.includes('allowedHosts')) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const serverBlock = ` server: {
|
|
115
|
+
allowedHosts: ${JSON.stringify(TUNNEL_ALLOWED_HOSTS)},
|
|
116
|
+
},`;
|
|
117
|
+
const emptyConfig = 'defineConfig({})';
|
|
118
|
+
if (content.includes(emptyConfig)) {
|
|
119
|
+
content = content.replace(emptyConfig, `defineConfig({\n${serverBlock}\n})`);
|
|
120
|
+
await fs.writeFile(configFilePath, content, 'utf8');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const closingPos = findConfigClosingBrace(content, 'defineConfig');
|
|
124
|
+
if (closingPos === -1) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const before = content.slice(0, closingPos);
|
|
128
|
+
const after = content.slice(closingPos);
|
|
129
|
+
const needsComma = before.trimEnd().slice(-1) !== ',';
|
|
130
|
+
const separator = needsComma ? ',\n' : '\n';
|
|
131
|
+
content = before.trimEnd() + separator + serverBlock + '\n' + after;
|
|
132
|
+
await fs.writeFile(configFilePath, content, 'utf8');
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Add vite allowedHosts configuration to a framework config file.
|
|
136
|
+
* Supports config files that use a defineX({}) pattern (Astro, Nuxt, etc.)
|
|
137
|
+
*/
|
|
138
|
+
async function addViteAllowedHosts(configPath, configFile, functionName) {
|
|
139
|
+
const configFilePath = path.join(configPath, 'frontend', configFile);
|
|
140
|
+
let content;
|
|
141
|
+
try {
|
|
142
|
+
content = await fs.readFile(configFilePath, 'utf8');
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (content.includes('allowedHosts')) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const viteConfig = ` vite: {
|
|
151
|
+
server: {
|
|
152
|
+
allowedHosts: ${JSON.stringify(TUNNEL_ALLOWED_HOSTS)},
|
|
153
|
+
},
|
|
154
|
+
},`;
|
|
155
|
+
// Handle empty config: functionName({})
|
|
156
|
+
const emptyConfig = `${functionName}({})`;
|
|
157
|
+
if (content.includes(emptyConfig)) {
|
|
158
|
+
content = content.replace(emptyConfig, `${functionName}({\n${viteConfig}\n})`);
|
|
159
|
+
await fs.writeFile(configFilePath, content, 'utf8');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
// Find the closing brace of the config function using brace counting
|
|
163
|
+
const closingPos = findConfigClosingBrace(content, functionName);
|
|
164
|
+
if (closingPos === -1) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const before = content.slice(0, closingPos);
|
|
168
|
+
const after = content.slice(closingPos);
|
|
169
|
+
// Ensure proper comma before vite config
|
|
170
|
+
const needsComma = before.trimEnd().slice(-1) !== ',';
|
|
171
|
+
const separator = needsComma ? ',\n' : '\n';
|
|
172
|
+
content = before.trimEnd() + separator + viteConfig + '\n' + after;
|
|
173
|
+
await fs.writeFile(configFilePath, content, 'utf8');
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Fix wrangler.jsonc `main` field when it points to a build artifact that
|
|
177
|
+
* doesn't exist at dev time. The @cloudflare/vite-plugin validates this path
|
|
178
|
+
* at startup and crashes during `astro dev` if it can't resolve it.
|
|
179
|
+
*
|
|
180
|
+
* Only replaces the exact legacy value that C3 currently scaffolds.
|
|
181
|
+
* Once C3 updates its template, this becomes a no-op.
|
|
182
|
+
*/
|
|
183
|
+
async function fixAstroWranglerEntrypoint(configPath) {
|
|
184
|
+
const filePath = path.join(configPath, 'frontend', 'wrangler.jsonc');
|
|
185
|
+
let content;
|
|
186
|
+
try {
|
|
187
|
+
content = await fs.readFile(filePath, 'utf8');
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const legacy = '"main": "./dist/_worker.js/index.js"';
|
|
193
|
+
if (!content.includes(legacy)) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const updated = content.replace(legacy, '"main": "@astrojs/cloudflare/entrypoints/server"');
|
|
197
|
+
await fs.writeFile(filePath, updated, 'utf8');
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Configure allowedHosts for frameworks using Vite dev server.
|
|
201
|
+
* This allows tunneling services (localtunnel) to proxy requests to the
|
|
202
|
+
* local dev environment.
|
|
203
|
+
*/
|
|
204
|
+
export async function addFrontendAllowedHosts(framework, configPath) {
|
|
205
|
+
switch (framework) {
|
|
206
|
+
case 'astro': {
|
|
207
|
+
await addAllowedHostsToAstro(configPath);
|
|
208
|
+
await fixAstroWranglerEntrypoint(configPath);
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
case 'angular': {
|
|
212
|
+
await addAllowedHostsToAngular(configPath);
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
case 'nuxt': {
|
|
216
|
+
await addAllowedHostsToNuxt(configPath);
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
case 'react': {
|
|
220
|
+
await addAllowedHostsToVite(configPath);
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
// Other frameworks don't require allowedHosts configuration
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/** Write the root workspace files and install root dependencies. */
|
|
227
|
+
export async function setupPackage(name, config, pkg) {
|
|
228
|
+
const configPath = path.dirname(config.path);
|
|
229
|
+
const packageJson = {
|
|
230
|
+
description: config.get('description'),
|
|
231
|
+
devDependencies: {
|
|
232
|
+
'@swell/app-types': '^1.2.0',
|
|
233
|
+
typescript: '^5.9.3',
|
|
234
|
+
},
|
|
235
|
+
name,
|
|
236
|
+
// Required for yarn workspaces, good practice for all package managers
|
|
237
|
+
private: true,
|
|
238
|
+
scripts: {
|
|
239
|
+
typecheck: '([ -z "$(find functions -name \'*.ts\' 2>/dev/null | head -1)" ] || tsc --noEmit) && ([ ! -f test/tsconfig.json ] || tsc --build --noEmit test) && ([ ! -f frontend/tsconfig.json ] || tsc --build --noEmit frontend)',
|
|
240
|
+
},
|
|
241
|
+
version: config.get('version'),
|
|
242
|
+
};
|
|
243
|
+
// pnpm uses pnpm-workspace.yaml instead of workspaces field in package.json
|
|
244
|
+
if (pkg !== 'pnpm') {
|
|
245
|
+
packageJson.workspaces = ['frontend'];
|
|
246
|
+
}
|
|
247
|
+
const tsConfig = {
|
|
248
|
+
compilerOptions: {
|
|
249
|
+
lib: ['esnext', 'webworker'],
|
|
250
|
+
module: 'esnext',
|
|
251
|
+
target: 'esnext',
|
|
252
|
+
moduleResolution: 'bundler',
|
|
253
|
+
types: ['@swell/app-types'],
|
|
254
|
+
},
|
|
255
|
+
exclude: ['node_modules', 'frontend', 'test', 'vitest.config.ts'],
|
|
256
|
+
};
|
|
257
|
+
await writeJsonFile(path.join(configPath, 'package.json'), packageJson);
|
|
258
|
+
// create frontend can initialize a workspace around an existing manifest.
|
|
259
|
+
// Keep any configuration the developer has already written.
|
|
260
|
+
const writeDefault = async (filename, contents) => {
|
|
261
|
+
await fs
|
|
262
|
+
.writeFile(path.join(configPath, filename), contents, { flag: 'wx' })
|
|
263
|
+
.catch((error) => {
|
|
264
|
+
if (error.code !== 'EEXIST')
|
|
265
|
+
throw error;
|
|
266
|
+
});
|
|
267
|
+
};
|
|
268
|
+
await writeDefault('tsconfig.json', `${JSON.stringify(tsConfig, null, 2)}\n`);
|
|
269
|
+
await writeDefault('.gitignore', `node_modules\n.wrangler\n.dev.vars\n.dev.vars.*\n`);
|
|
270
|
+
// Create pnpm-workspace.yaml for pnpm (required for workspace support)
|
|
271
|
+
if (pkg === 'pnpm') {
|
|
272
|
+
await writeDefault('pnpm-workspace.yaml', 'packages:\n - frontend\n');
|
|
273
|
+
}
|
|
274
|
+
// Create placeholder frontend/package.json for bun and yarn
|
|
275
|
+
// These package managers require workspace directories to exist with a package.json
|
|
276
|
+
if (pkg === 'bun' || pkg === 'yarn') {
|
|
277
|
+
const frontendPath = path.join(configPath, 'frontend');
|
|
278
|
+
await fs.mkdir(frontendPath, { recursive: true });
|
|
279
|
+
await writeDefault('frontend/package.json', JSON.stringify({
|
|
280
|
+
name: 'frontend',
|
|
281
|
+
private: true,
|
|
282
|
+
version: '0.0.0',
|
|
283
|
+
}, null, 2));
|
|
284
|
+
}
|
|
285
|
+
// Install root dependencies using selected package manager
|
|
286
|
+
const { install } = getPackageManagerCommands(pkg);
|
|
287
|
+
await execAsync(install, { cwd: configPath });
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Remove the empty workspace placeholder created by setupPackage; refuse to
|
|
291
|
+
* scaffold over any other content in frontend/.
|
|
292
|
+
*/
|
|
293
|
+
export async function prepareFrontendDirectory(configPath) {
|
|
294
|
+
const frontendPath = path.join(configPath, 'frontend');
|
|
295
|
+
const frontendPkgPath = path.join(frontendPath, 'package.json');
|
|
296
|
+
const files = await fs.readdir(frontendPath).catch((error) => {
|
|
297
|
+
if (error.code === 'ENOENT')
|
|
298
|
+
return [];
|
|
299
|
+
throw error;
|
|
300
|
+
});
|
|
301
|
+
// Only replace the empty workspace placeholder created by setupPackage.
|
|
302
|
+
let placeholder = false;
|
|
303
|
+
try {
|
|
304
|
+
const content = await fs.readFile(frontendPkgPath, 'utf8');
|
|
305
|
+
const frontendPkg = JSON.parse(content);
|
|
306
|
+
placeholder =
|
|
307
|
+
files.length === 1 &&
|
|
308
|
+
Object.keys(frontendPkg).length === 3 &&
|
|
309
|
+
frontendPkg.version === '0.0.0' &&
|
|
310
|
+
frontendPkg.name === 'frontend' &&
|
|
311
|
+
frontendPkg.private === true;
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
// An unreadable or invalid manifest is not our placeholder.
|
|
315
|
+
}
|
|
316
|
+
if (files.length > 0 && !placeholder) {
|
|
317
|
+
throw new Error('frontend/ folder is not empty. Move existing files before scaffolding.');
|
|
318
|
+
}
|
|
319
|
+
if (placeholder)
|
|
320
|
+
await fs.unlink(frontendPkgPath);
|
|
321
|
+
}
|
|
322
|
+
/** Create the root workspace, or add frontend to an existing one. */
|
|
323
|
+
export async function ensureRootWorkspace(swellConfig, pkg) {
|
|
324
|
+
const configPath = path.dirname(swellConfig.path);
|
|
325
|
+
const rootPkgPath = path.join(configPath, 'package.json');
|
|
326
|
+
const rootPackage = await fs.readFile(rootPkgPath, 'utf8').catch((error) => {
|
|
327
|
+
if (error.code === 'ENOENT')
|
|
328
|
+
return null;
|
|
329
|
+
throw error;
|
|
330
|
+
});
|
|
331
|
+
if (rootPackage === null) {
|
|
332
|
+
await setupPackage(swellConfig.get('id'), swellConfig, pkg);
|
|
333
|
+
// Bun/Yarn setup creates an empty workspace placeholder.
|
|
334
|
+
if (pkg === 'bun' || pkg === 'yarn') {
|
|
335
|
+
await fs.unlink(path.join(configPath, 'frontend', 'package.json'));
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
else if (pkg !== 'pnpm') {
|
|
339
|
+
const manifest = JSON.parse(rootPackage);
|
|
340
|
+
const workspaces = Array.isArray(manifest.workspaces)
|
|
341
|
+
? manifest.workspaces
|
|
342
|
+
: manifest.workspaces?.packages || [];
|
|
343
|
+
let changed = false;
|
|
344
|
+
if (!workspaces.includes('frontend')) {
|
|
345
|
+
if (manifest.workspaces && !Array.isArray(manifest.workspaces)) {
|
|
346
|
+
manifest.workspaces.packages = [...workspaces, 'frontend'];
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
manifest.workspaces = [...workspaces, 'frontend'];
|
|
350
|
+
}
|
|
351
|
+
changed = true;
|
|
352
|
+
}
|
|
353
|
+
if (pkg === 'yarn' && manifest.private !== true) {
|
|
354
|
+
manifest.private = true;
|
|
355
|
+
changed = true;
|
|
356
|
+
}
|
|
357
|
+
if (changed) {
|
|
358
|
+
await writeJsonFile(rootPkgPath, manifest);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (pkg === 'pnpm') {
|
|
362
|
+
const { stdout } = await execa('pnpm', ['config', 'get', '--json', 'packages'], { cwd: configPath });
|
|
363
|
+
const packages = stdout.trim() && stdout.trim() !== 'undefined' ? JSON.parse(stdout) : [];
|
|
364
|
+
if (!packages.includes('frontend')) {
|
|
365
|
+
await execa('pnpm', [
|
|
366
|
+
'config',
|
|
367
|
+
'set',
|
|
368
|
+
'--location',
|
|
369
|
+
'project',
|
|
370
|
+
'--json',
|
|
371
|
+
'packages',
|
|
372
|
+
JSON.stringify([...packages, 'frontend']),
|
|
373
|
+
], { cwd: configPath });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* pnpm's native build policy must include the two toolchain binaries.
|
|
379
|
+
* Use pnpm to merge its YAML settings without replacing other entries.
|
|
380
|
+
*/
|
|
381
|
+
export async function ensurePnpmBuildApprovals(configPath) {
|
|
382
|
+
const { stdout } = await execa('pnpm', ['config', 'get', '--json', 'allowBuilds'], { cwd: configPath });
|
|
383
|
+
const current = stdout.trim() && stdout.trim() !== 'undefined'
|
|
384
|
+
? JSON.parse(stdout)
|
|
385
|
+
: undefined;
|
|
386
|
+
if (current?.esbuild === false || current?.workerd === false) {
|
|
387
|
+
throw new Error('Managed templates require esbuild and workerd build scripts. Update allowBuilds in pnpm-workspace.yaml before scaffolding.');
|
|
388
|
+
}
|
|
389
|
+
await execa('pnpm', [
|
|
390
|
+
'config',
|
|
391
|
+
'set',
|
|
392
|
+
'--location',
|
|
393
|
+
'project',
|
|
394
|
+
'--json',
|
|
395
|
+
'allowBuilds',
|
|
396
|
+
JSON.stringify({ ...current, esbuild: true, workerd: true }),
|
|
397
|
+
], { cwd: configPath });
|
|
398
|
+
}
|
|
399
|
+
function commandFailureDetail(error) {
|
|
400
|
+
return ([error.stdout, error.stderr]
|
|
401
|
+
.map((s) => (s || '').trim())
|
|
402
|
+
.filter(Boolean)
|
|
403
|
+
.join('\n') || (error.message ?? '').trim());
|
|
404
|
+
}
|
|
405
|
+
/** Run the template's scaffold command into frontend/. */
|
|
406
|
+
export async function scaffoldFrontendTemplate(configPath, template, pkg) {
|
|
407
|
+
try {
|
|
408
|
+
await fs.mkdir(path.join(configPath, 'frontend'), {
|
|
409
|
+
recursive: true,
|
|
410
|
+
});
|
|
411
|
+
// SWELL_LOCAL_TEMPLATE_PATH overrides the scaffold for local dev — copies
|
|
412
|
+
// the directory at the path into frontend/ instead of running the install
|
|
413
|
+
// command. Used to iterate on the canonical template repo without pushing.
|
|
414
|
+
const localTemplatePath = process.env.SWELL_LOCAL_TEMPLATE_PATH;
|
|
415
|
+
if (localTemplatePath) {
|
|
416
|
+
const source = path.resolve(localTemplatePath);
|
|
417
|
+
const dest = path.join(configPath, 'frontend');
|
|
418
|
+
await fs.cp(source, dest, {
|
|
419
|
+
recursive: true,
|
|
420
|
+
filter: (src) => !src.includes('/node_modules') &&
|
|
421
|
+
!src.endsWith('/.git') &&
|
|
422
|
+
!src.includes('/dist') &&
|
|
423
|
+
!src.includes('/.wrangler'),
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
// Managed commands run the pinned generator through npx; only the
|
|
428
|
+
// generic self-hosted commands are rewritten for the package manager.
|
|
429
|
+
const installCommand = template.hosting === 'managed'
|
|
430
|
+
? template.installCommand
|
|
431
|
+
: transformCreateCommand(template.installCommand, pkg);
|
|
432
|
+
const env = { ...process.env };
|
|
433
|
+
if (template.hosting === 'managed') {
|
|
434
|
+
const { stdout } = await execa(pkg, ['--version']);
|
|
435
|
+
// Keep C3's dependency edits on the selected package manager,
|
|
436
|
+
// even though npx supplies the pinned generator executable.
|
|
437
|
+
env.npm_config_user_agent = `${pkg}/${stdout.trim()}`;
|
|
438
|
+
}
|
|
439
|
+
await execAsync(installCommand, {
|
|
440
|
+
cwd: configPath,
|
|
441
|
+
env,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
throw new Error(commandFailureDetail(error) || 'Frontend scaffolding failed.');
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* The template finalizer restores the pinned declarations and managed
|
|
451
|
+
* settings that C3 rewrites while scaffolding.
|
|
452
|
+
*/
|
|
453
|
+
export async function finalizeManagedFrontend(frontendPath) {
|
|
454
|
+
try {
|
|
455
|
+
await execa(process.execPath, ['scripts/prepare-managed.mjs'], {
|
|
456
|
+
cwd: frontendPath,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
throw new Error(commandFailureDetail(error) || 'Frontend scaffolding failed.');
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
/** Ensure frontend package.json has the correct name for the workspace. */
|
|
464
|
+
export async function normalizeFrontendManifest(frontendPath, template) {
|
|
465
|
+
const frontendPkgPath = path.join(frontendPath, 'package.json');
|
|
466
|
+
const pkgContent = await fs.readFile(frontendPkgPath, 'utf8');
|
|
467
|
+
const frontendPkgJson = JSON.parse(pkgContent);
|
|
468
|
+
frontendPkgJson.name = 'frontend';
|
|
469
|
+
if (template.hosting === 'managed') {
|
|
470
|
+
// The CLI owns workspace installation; template lockfiles must not
|
|
471
|
+
// redirect later commands.
|
|
472
|
+
for (const lockfile of [
|
|
473
|
+
'package-lock.json',
|
|
474
|
+
'bun.lock',
|
|
475
|
+
'bun.lockb',
|
|
476
|
+
'pnpm-lock.yaml',
|
|
477
|
+
'yarn.lock',
|
|
478
|
+
]) {
|
|
479
|
+
// eslint-disable-next-line no-await-in-loop
|
|
480
|
+
await fs.rm(path.join(frontendPath, lockfile), { force: true });
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
await fs.writeFile(frontendPkgPath, JSON.stringify(frontendPkgJson, null, 2), 'utf8');
|
|
484
|
+
}
|
|
485
|
+
/** Run install at root to hoist dependencies to the workspace. */
|
|
486
|
+
export async function installFrontendWorkspace(configPath, template, pkg) {
|
|
487
|
+
const frontendPath = path.join(configPath, 'frontend');
|
|
488
|
+
try {
|
|
489
|
+
const { install } = getPackageManagerCommands(pkg);
|
|
490
|
+
await execAsync(template.hosting === 'managed' && pkg === 'pnpm'
|
|
491
|
+
? `${install} --no-frozen-lockfile`
|
|
492
|
+
: install, { cwd: configPath });
|
|
493
|
+
if (template.hosting === 'managed') {
|
|
494
|
+
await execAsync(getPackageManagerCommands(pkg).run('cf-typegen'), {
|
|
495
|
+
cwd: frontendPath,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
catch (error) {
|
|
500
|
+
throw new Error(error.stderr || error.stdout || error.message);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Separator } from '@inquirer/prompts';
|
|
2
|
+
export declare const FRONTEND_HOSTING_LABELS: {
|
|
3
|
+
managed: string;
|
|
4
|
+
'self-hosted': string;
|
|
5
|
+
};
|
|
6
|
+
export declare const C3_VERSION = "2.72.7";
|
|
7
|
+
export declare const FRONTEND_TEMPLATE_REF = "v0.1";
|
|
8
|
+
export interface FrontendTemplate {
|
|
9
|
+
/** `--frontend` value and picker value. */
|
|
10
|
+
slug: string;
|
|
11
|
+
name: string;
|
|
12
|
+
hosting: 'managed' | 'self-hosted';
|
|
13
|
+
/** Runtime detection slug in FrontendProjectTypes. */
|
|
14
|
+
framework: string;
|
|
15
|
+
description: string;
|
|
16
|
+
installCommand: string;
|
|
17
|
+
/** Absent means available for all app types. */
|
|
18
|
+
appTypes?: string[];
|
|
19
|
+
provisionsStorefront?: boolean;
|
|
20
|
+
/** Accepted `--frontend` values kept out of prompts and help. */
|
|
21
|
+
aliases?: string[];
|
|
22
|
+
}
|
|
23
|
+
export declare const FRONTEND_TEMPLATES: readonly FrontendTemplate[];
|
|
24
|
+
/** Public `--frontend` values; aliases are accepted but not listed. */
|
|
25
|
+
export declare const FRONTEND_TEMPLATE_VALUES: string[];
|
|
26
|
+
export declare const FRONTEND_TEMPLATE_ALIASES: string[];
|
|
27
|
+
export declare function getFrontendTemplate(value: string): FrontendTemplate | undefined;
|
|
28
|
+
export declare function getFrontendTemplateChoices(appType: string, direct?: boolean): (Separator | {
|
|
29
|
+
name: string;
|
|
30
|
+
value: string;
|
|
31
|
+
description: string;
|
|
32
|
+
})[];
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { Separator } from '@inquirer/prompts';
|
|
2
|
+
import style from '../style.js';
|
|
3
|
+
export const FRONTEND_HOSTING_LABELS = {
|
|
4
|
+
managed: 'Hosted by Swell',
|
|
5
|
+
'self-hosted': 'Your Cloudflare account',
|
|
6
|
+
};
|
|
7
|
+
export const C3_VERSION = '2.72.7';
|
|
8
|
+
// Moving compatibility tag in swell-app-templates: patch releases move it, so
|
|
9
|
+
// template hotfixes need no CLI release; a breaking template change gets a new
|
|
10
|
+
// minor tag and a CLI release that points at it. degit resolves only branch
|
|
11
|
+
// and tag tips, never arbitrary commits.
|
|
12
|
+
export const FRONTEND_TEMPLATE_REF = 'v0.1';
|
|
13
|
+
// npx pins the generator even on Yarn Classic. The caller passes the selected
|
|
14
|
+
// manager's user agent for C3's dependency edits.
|
|
15
|
+
const managedInstallCommand = (directory) => `npx --yes create-cloudflare@${C3_VERSION} frontend --template=swellstores/swell-app-templates/${directory}#${FRONTEND_TEMPLATE_REF} --deploy=false --git=false --no-agents --no-auto-update --lang=ts`;
|
|
16
|
+
const SELF_HOSTED_DESCRIPTION = 'Deploys to your own Cloudflare account.';
|
|
17
|
+
// Picker and help order. Runtime detection lives in FrontendProjectTypes.
|
|
18
|
+
export const FRONTEND_TEMPLATES = [
|
|
19
|
+
{
|
|
20
|
+
slug: 'swell-react',
|
|
21
|
+
name: 'React + Vite',
|
|
22
|
+
hosting: 'managed',
|
|
23
|
+
framework: 'react',
|
|
24
|
+
description: 'client rendering + server endpoints',
|
|
25
|
+
installCommand: managedInstallCommand('react'),
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
slug: 'swell-vinext',
|
|
29
|
+
name: 'Vinext',
|
|
30
|
+
hosting: 'managed',
|
|
31
|
+
framework: 'vinext',
|
|
32
|
+
description: 'server rendering',
|
|
33
|
+
installCommand: managedInstallCommand('vinext'),
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
slug: 'swell-spa',
|
|
37
|
+
name: 'React SPA Storefront',
|
|
38
|
+
hosting: 'managed',
|
|
39
|
+
framework: 'react',
|
|
40
|
+
description: 'Client-only commerce starter for AI-authored storefronts.',
|
|
41
|
+
installCommand: managedInstallCommand('react-storefront'),
|
|
42
|
+
appTypes: ['storefront'],
|
|
43
|
+
provisionsStorefront: true,
|
|
44
|
+
// The former internal AI starter now uses the managed SPA template. This
|
|
45
|
+
// is a scaffold alias only; the `react-storefront` runtime detection slug
|
|
46
|
+
// in FrontendProjectTypes still means the legacy chassis storefront.
|
|
47
|
+
aliases: ['react-storefront'],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
slug: 'nextjs',
|
|
51
|
+
name: 'Next.js',
|
|
52
|
+
hosting: 'self-hosted',
|
|
53
|
+
framework: 'nextjs',
|
|
54
|
+
description: SELF_HOSTED_DESCRIPTION,
|
|
55
|
+
installCommand: 'npm create cloudflare@latest -- frontend --framework=next --deploy=false --git=false -- --typescript --use-npm --src-dir --app --eslint --import-alias "@/*" --tailwind --turbopack',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
slug: 'astro',
|
|
59
|
+
name: 'Astro',
|
|
60
|
+
hosting: 'self-hosted',
|
|
61
|
+
framework: 'astro',
|
|
62
|
+
description: SELF_HOSTED_DESCRIPTION,
|
|
63
|
+
installCommand: 'npm create cloudflare@latest -- frontend --framework=astro --deploy=false --git=false -- --no-git --yes --skip-houston --typescript strict',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
slug: 'nuxt',
|
|
67
|
+
name: 'Nuxt',
|
|
68
|
+
hosting: 'self-hosted',
|
|
69
|
+
framework: 'nuxt',
|
|
70
|
+
description: SELF_HOSTED_DESCRIPTION,
|
|
71
|
+
installCommand: 'npm create cloudflare@latest -- frontend --framework=nuxt --deploy=false --git=false -- --no-modules -f',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
// `--variant=react-ts` pre-answers C3's variant prompt (TS Vite +
|
|
75
|
+
// plugin-react, not SWC) so install doesn't hang behind our spinner.
|
|
76
|
+
slug: 'react',
|
|
77
|
+
name: 'React + Vite',
|
|
78
|
+
hosting: 'self-hosted',
|
|
79
|
+
framework: 'react',
|
|
80
|
+
description: SELF_HOSTED_DESCRIPTION,
|
|
81
|
+
installCommand: 'npm create cloudflare@latest -- frontend --framework=react --deploy=false --git=false --variant=react-ts',
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
slug: 'hono',
|
|
85
|
+
name: 'Hono',
|
|
86
|
+
hosting: 'self-hosted',
|
|
87
|
+
framework: 'hono',
|
|
88
|
+
description: SELF_HOSTED_DESCRIPTION,
|
|
89
|
+
installCommand: 'npm create cloudflare@latest -- frontend --framework=hono --deploy=false --git=false',
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
slug: 'angular',
|
|
93
|
+
name: 'Angular',
|
|
94
|
+
hosting: 'self-hosted',
|
|
95
|
+
framework: 'angular',
|
|
96
|
+
description: SELF_HOSTED_DESCRIPTION,
|
|
97
|
+
installCommand: 'npm create cloudflare@latest -- frontend --framework=angular --deploy=false --git=false -- --style=sass --zoneless --ai-config=none',
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
/** Public `--frontend` values; aliases are accepted but not listed. */
|
|
101
|
+
export const FRONTEND_TEMPLATE_VALUES = [
|
|
102
|
+
...FRONTEND_TEMPLATES.map((template) => template.slug),
|
|
103
|
+
'none',
|
|
104
|
+
];
|
|
105
|
+
export const FRONTEND_TEMPLATE_ALIASES = FRONTEND_TEMPLATES.flatMap((template) => template.aliases ?? []);
|
|
106
|
+
export function getFrontendTemplate(value) {
|
|
107
|
+
return FRONTEND_TEMPLATES.find((template) => template.slug === value || template.aliases?.includes(value));
|
|
108
|
+
}
|
|
109
|
+
export function getFrontendTemplateChoices(appType, direct = false) {
|
|
110
|
+
const templates = FRONTEND_TEMPLATES.filter((template) => !template.appTypes || template.appTypes.includes(appType));
|
|
111
|
+
return [
|
|
112
|
+
...(direct
|
|
113
|
+
? []
|
|
114
|
+
: [
|
|
115
|
+
{
|
|
116
|
+
name: 'Skip for now',
|
|
117
|
+
value: 'none',
|
|
118
|
+
description: style.dim('\n Add a frontend later with swell create frontend.'),
|
|
119
|
+
},
|
|
120
|
+
]),
|
|
121
|
+
...['managed', 'self-hosted'].flatMap((hosting) => [
|
|
122
|
+
new Separator(' '),
|
|
123
|
+
new Separator(` ${style.dim(FRONTEND_HOSTING_LABELS[hosting])}`),
|
|
124
|
+
...templates
|
|
125
|
+
.filter((template) => template.hosting === hosting)
|
|
126
|
+
.map((template) => {
|
|
127
|
+
const description = template.description.charAt(0).toUpperCase() +
|
|
128
|
+
template.description.slice(1);
|
|
129
|
+
return {
|
|
130
|
+
name: ` ${template.name}`,
|
|
131
|
+
value: template.slug,
|
|
132
|
+
description: style.dim(`\n ${template.name}: ${description}${description.endsWith('.') ? '' : '.'}`),
|
|
133
|
+
};
|
|
134
|
+
}),
|
|
135
|
+
]),
|
|
136
|
+
];
|
|
137
|
+
}
|