@swell/cli 2.9.17 → 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.
@@ -1,4 +1,5 @@
1
1
  import { Help } from '@oclif/core';
2
+ import { FRONTEND_HOSTING_LABELS, FRONTEND_TEMPLATES, } from '../lib/apps/frontend-templates.js';
2
3
  /**
3
4
  * Custom Help class for Swell CLI
4
5
  *
@@ -79,6 +80,21 @@ export default class CustomHelp extends Help {
79
80
  if (flagsSection.length > 0) {
80
81
  lines.push('', ...flagsSection);
81
82
  }
83
+ if (helpMeta.frontendTemplates) {
84
+ lines.push('', 'FRONTENDS --frontend <template>', '', ` ${FRONTEND_HOSTING_LABELS.managed}`);
85
+ for (const template of FRONTEND_TEMPLATES.filter((item) => item.hosting === 'managed')) {
86
+ const detail = template.appTypes
87
+ ? `${template.appTypes.join(', ')} apps only`
88
+ : template.description;
89
+ lines.push(` ${template.slug.padEnd(15)}${template.name} · ${detail}`);
90
+ }
91
+ lines.push('', ` ${FRONTEND_HOSTING_LABELS['self-hosted']}`);
92
+ const slugs = FRONTEND_TEMPLATES.filter((item) => item.hosting === 'self-hosted').map((item) => item.slug);
93
+ lines.push(` ${slugs.join(', ')}`);
94
+ if (helpMeta.frontendTemplates === 'optional') {
95
+ lines.push('', ' Skip frontend setup', ' none (default with -y)');
96
+ }
97
+ }
82
98
  // DESCRIPTION section (if description differs from summary)
83
99
  const descSection = this.formatDescriptionSection(command);
84
100
  if (descSection.length > 0) {
@@ -257,13 +273,18 @@ export default class CustomHelp extends Help {
257
273
  // Alphabetical
258
274
  return aName.localeCompare(bName);
259
275
  });
260
- for (const [name, flag] of flagEntries) {
276
+ const flagLabels = new Map(Object.entries(command.flags)
277
+ .filter(([name, flag]) => name !== 'help' && !flag.hidden)
278
+ .map(([name, flag]) => {
261
279
  const shortChar = flag.char ? `-${flag.char}, ` : ' ';
262
- const longFlag = `--${name}`;
263
- const isBoolean = flag.type === 'boolean';
264
- const hasOptions = 'options' in flag && flag.options;
265
- const valueStr = isBoolean ? '' : hasOptions ? '=<option>' : '=<value>';
266
- const flagStr = `${shortChar}${longFlag}${valueStr}`;
280
+ const value = ('helpValue' in flag && flag.helpValue) ||
281
+ ('options' in flag && flag.options ? 'option' : 'value');
282
+ const suffix = flag.type === 'boolean' ? '' : `=<${value}>`;
283
+ return [name, `${shortChar}--${name}${suffix}`];
284
+ }));
285
+ const flagWidth = Math.max(30, ...[...flagLabels.values()].map((label) => label.length));
286
+ for (const [name, flag] of flagEntries) {
287
+ const flagStr = flagLabels.get(name);
267
288
  // Get description, clean up redundant "(required in -y)" text
268
289
  let desc = flag.summary || flag.description || '';
269
290
  desc = desc
@@ -272,7 +293,7 @@ export default class CustomHelp extends Help {
272
293
  .replaceAll(/\s*\(default in -y: .+\)/gi, '')
273
294
  .split('\n')[0]
274
295
  .trim();
275
- lines.push(` ${flagStr.padEnd(30)} ${desc}`);
296
+ lines.push(` ${flagStr.padEnd(flagWidth)} ${desc}`);
276
297
  }
277
298
  return lines;
278
299
  }
@@ -31,6 +31,8 @@ export interface VariantOption {
31
31
  * Add this as a static property on commands that need enhanced help output.
32
32
  */
33
33
  export interface HelpMeta {
34
+ /** Show the public frontend template choices grouped by hosting mode. */
35
+ frontendTemplates?: 'optional' | 'required';
34
36
  /**
35
37
  * Custom usage line for direct/non-interactive mode.
36
38
  * Example: '<id> -t <type> [...] -y'
@@ -8,6 +8,12 @@ const schema = {
8
8
  compatibilities: { type: 'object' },
9
9
  description: { type: 'string' },
10
10
  extensions: { type: 'array' },
11
+ frontend: {
12
+ type: 'object',
13
+ properties: {
14
+ hosting: { type: 'string', enum: ['managed', 'self-hosted'] },
15
+ },
16
+ },
11
17
  id: { type: 'string' },
12
18
  integrations: { type: 'array' },
13
19
  name: { type: 'string' },
@@ -0,0 +1,36 @@
1
+ export type ManagedFrontendFramework = 'react' | 'vinext';
2
+ export interface FrontendPackageFile {
3
+ kind: 'module' | 'asset';
4
+ path: string;
5
+ content_type: string;
6
+ size: number;
7
+ sha256: string;
8
+ data: string;
9
+ }
10
+ export interface FrontendPackage {
11
+ format: 1;
12
+ framework: ManagedFrontendFramework;
13
+ entrypoint: string;
14
+ runtime: {
15
+ bindings: string[];
16
+ cache: boolean;
17
+ compatibility_date: string;
18
+ compatibility_flags: string[];
19
+ };
20
+ assets: {
21
+ _headers?: string;
22
+ _redirects?: string;
23
+ html_handling: 'auto-trailing-slash' | 'none';
24
+ not_found_handling: 'none' | 'single-page-application';
25
+ run_worker_first: boolean;
26
+ };
27
+ files: FrontendPackageFile[];
28
+ digest: string;
29
+ }
30
+ export declare function frontendPackageDigest(pkg: FrontendPackage): string;
31
+ export declare function isPrivateFrontendAsset(name: string): boolean;
32
+ export declare function verifyFrontendPackage(pkg: FrontendPackage): void;
33
+ export declare function checkModuleGraph(pkg: FrontendPackage): Promise<void>;
34
+ export declare function captureFrontendPackage(frontend: string, framework: ManagedFrontendFramework): Promise<FrontendPackage>;
35
+ export declare function buildFrontendPackage(frontend: string, framework: ManagedFrontendFramework): Promise<FrontendPackage>;
36
+ export declare function getManagedFrontendFramework(frontend: string): Promise<ManagedFrontendFramework>;
@@ -0,0 +1,375 @@
1
+ import { build } from 'esbuild';
2
+ import { execa } from 'execa';
3
+ import { isIgnoredByIgnoreFiles } from 'globby';
4
+ import { createHash } from 'node:crypto';
5
+ import * as fs from 'node:fs/promises';
6
+ import { createRequire } from 'node:module';
7
+ import * as os from 'node:os';
8
+ import * as path from 'node:path';
9
+ const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
10
+ function compareFiles(a, b) {
11
+ const first = `${a.kind}/${a.path}`;
12
+ const second = `${b.kind}/${b.path}`;
13
+ return first < second ? -1 : first > second ? 1 : 0;
14
+ }
15
+ function canonical(value) {
16
+ if (Array.isArray(value))
17
+ return `[${value.map((item) => canonical(item)).join(',')}]`;
18
+ if (value && typeof value === 'object') {
19
+ return `{${Object.keys(value)
20
+ .filter((key) => value[key] !== undefined)
21
+ .sort()
22
+ .map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`)
23
+ .join(',')}}`;
24
+ }
25
+ return JSON.stringify(value);
26
+ }
27
+ // Hash the serving contract and sorted file descriptors, independently of encoding.
28
+ export function frontendPackageDigest(pkg) {
29
+ const { files, ...settings } = pkg;
30
+ const manifest = { ...settings, digest: undefined };
31
+ return sha256(canonical({
32
+ ...manifest,
33
+ files: [...files]
34
+ .sort(compareFiles)
35
+ .map(({ data: _data, ...file }) => file),
36
+ }));
37
+ }
38
+ function checkPath(name) {
39
+ if (!name ||
40
+ // eslint-disable-next-line no-control-regex
41
+ /[\u0000-\u001F#%?\\]/.test(name) ||
42
+ name.split('/').some((part) => !part || part === '.' || part === '..')) {
43
+ throw new Error(`Invalid frontend package path: ${name}`);
44
+ }
45
+ }
46
+ // Mandatory exclusions apply even when .assetsignore tries to re-include them.
47
+ export function isPrivateFrontendAsset(name) {
48
+ return name
49
+ .split('/')
50
+ .some((part) => (part.startsWith('.') && part !== '.well-known') ||
51
+ part === 'node_modules' ||
52
+ part.endsWith('.map') ||
53
+ /^(?:wrangler(?:\.[^.]+)?\.(?:jsonc?|toml)|(?:vite|vitest|next|eslint|postcss|tailwind)\.config\.[cm]?[jt]s|(?:tsconfig|jsconfig)(?:\.[^.]+)?\.json)$/.test(part) ||
54
+ /^(?:package(?:-lock)?\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|_headers|_redirects|_worker\.js)$/.test(part));
55
+ }
56
+ function packageFile(kind, name, contentType, bytes) {
57
+ checkPath(name);
58
+ return {
59
+ kind,
60
+ path: name,
61
+ content_type: contentType,
62
+ size: bytes.length,
63
+ sha256: sha256(bytes),
64
+ data: bytes.toString('base64'),
65
+ };
66
+ }
67
+ // Local integrity check; platform admission must independently validate untrusted input.
68
+ export function verifyFrontendPackage(pkg) {
69
+ const names = new Set();
70
+ for (const file of pkg.files) {
71
+ checkPath(file.path);
72
+ if (file.kind === 'module' && file.path === 'metadata') {
73
+ throw new Error('metadata is a reserved Worker module name');
74
+ }
75
+ const key = `${file.kind}/${file.path}`;
76
+ if (names.has(key))
77
+ throw new Error(`Duplicate frontend file: ${key}`);
78
+ names.add(key);
79
+ const bytes = Buffer.from(file.data, 'base64');
80
+ if (bytes.toString('base64') !== file.data ||
81
+ bytes.length !== file.size ||
82
+ sha256(bytes) !== file.sha256) {
83
+ throw new Error(`Frontend file integrity mismatch: ${key}`);
84
+ }
85
+ if (file.kind === 'asset' && isPrivateFrontendAsset(file.path)) {
86
+ throw new Error(`Private frontend asset: ${file.path}`);
87
+ }
88
+ }
89
+ if (!names.has(`module/${pkg.entrypoint}`)) {
90
+ throw new Error('Frontend package is missing its Worker entrypoint');
91
+ }
92
+ if (pkg.framework === 'react' && !names.has('asset/index.html')) {
93
+ throw new Error('React frontend package is missing index.html');
94
+ }
95
+ if (frontendPackageDigest(pkg) !== pkg.digest) {
96
+ throw new Error('Frontend package digest mismatch (missing files or changed settings)');
97
+ }
98
+ }
99
+ async function collectAssets(directory) {
100
+ const ignored = await isIgnoredByIgnoreFiles('.assetsignore', {
101
+ cwd: directory,
102
+ });
103
+ const files = [];
104
+ async function walk(relative = '') {
105
+ for (const entry of await fs.readdir(path.join(directory, relative), {
106
+ withFileTypes: true,
107
+ })) {
108
+ const name = path.posix.join(relative, entry.name);
109
+ if (isPrivateFrontendAsset(name) || ignored(name))
110
+ continue;
111
+ if (entry.isDirectory()) {
112
+ // eslint-disable-next-line no-await-in-loop
113
+ await walk(name);
114
+ }
115
+ else if (entry.isFile()) {
116
+ // Asset MIME types are inferred by the serving platform from the retained path.
117
+ // eslint-disable-next-line no-await-in-loop
118
+ const bytes = await fs.readFile(path.join(directory, name));
119
+ files.push(packageFile('asset', name, 'application/octet-stream', bytes));
120
+ }
121
+ else {
122
+ throw new Error(`Unsupported frontend asset (symlink or special file): ${name}`);
123
+ }
124
+ }
125
+ }
126
+ await walk();
127
+ return files;
128
+ }
129
+ async function readServingControl(directory, name) {
130
+ const filename = path.join(directory, name);
131
+ const stat = await fs.lstat(filename).catch((error) => {
132
+ if (error.code !== 'ENOENT')
133
+ throw error;
134
+ });
135
+ if (!stat)
136
+ return undefined;
137
+ if (!stat.isFile())
138
+ throw new Error(`Frontend serving control must be a regular file: ${name}`);
139
+ return fs.readFile(filename, 'utf8');
140
+ }
141
+ // Link in memory only: no module is executed and no rebuilt bytes enter the package.
142
+ export async function checkModuleGraph(pkg) {
143
+ const modules = new Map(pkg.files
144
+ .filter((file) => file.kind === 'module')
145
+ .map((file) => [file.path, file]));
146
+ await build({
147
+ entryPoints: [pkg.entrypoint],
148
+ bundle: true,
149
+ write: false,
150
+ platform: 'neutral',
151
+ format: 'esm',
152
+ logLevel: 'silent',
153
+ plugins: [
154
+ {
155
+ name: 'retained-frontend-modules',
156
+ setup(builder) {
157
+ builder.onResolve({ filter: /.*/ }, (args) => {
158
+ if (/^(?:node|cloudflare):/.test(args.path))
159
+ return { path: args.path, external: true };
160
+ const name = args.kind === 'entry-point'
161
+ ? args.path
162
+ : path.posix.join(path.posix.dirname(args.importer), args.path);
163
+ if (!modules.has(name))
164
+ throw new Error(`Missing frontend Worker module: ${name}`);
165
+ return { path: name, namespace: 'frontend' };
166
+ });
167
+ builder.onLoad({ filter: /.*/, namespace: 'frontend' }, (args) => {
168
+ const file = modules.get(args.path);
169
+ const loaders = {
170
+ 'application/javascript+module': 'js',
171
+ 'text/plain': 'text',
172
+ 'application/wasm': 'binary',
173
+ 'application/octet-stream': 'binary',
174
+ };
175
+ const loader = loaders[file.content_type];
176
+ if (!loader)
177
+ throw new Error(`Unsupported frontend Worker module type: ${file.content_type}`);
178
+ return { contents: Buffer.from(file.data, 'base64'), loader };
179
+ });
180
+ },
181
+ },
182
+ ],
183
+ });
184
+ }
185
+ async function localBin(frontend, name) {
186
+ const require = createRequire(path.join(frontend, 'package.json'));
187
+ for (const directory of require.resolve.paths(name) ?? []) {
188
+ const manifest = path.join(directory, name, 'package.json');
189
+ // Packages may intentionally omit package.json from their exports.
190
+ // eslint-disable-next-line no-await-in-loop
191
+ const text = await fs.readFile(manifest, 'utf8').catch((error) => {
192
+ if (error.code !== 'ENOENT')
193
+ throw error;
194
+ return null;
195
+ });
196
+ if (!text)
197
+ continue;
198
+ const pkg = JSON.parse(text);
199
+ const bin = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin[name];
200
+ return path.resolve(path.dirname(manifest), bin);
201
+ }
202
+ throw new Error(`Install ${name} in the frontend workspace before building a managed package`);
203
+ }
204
+ async function runLocal(frontend, name, args, temp) {
205
+ await execa(process.execPath, [await localBin(frontend, name), ...args], {
206
+ cwd: frontend,
207
+ env: {
208
+ CI: '1',
209
+ WRANGLER_SEND_METRICS: 'false',
210
+ WRANGLER_LOG_PATH: path.join(temp, 'wrangler.log'),
211
+ },
212
+ });
213
+ }
214
+ // Capture actual Wrangler modules plus the separate complete public asset inventory.
215
+ export async function captureFrontendPackage(frontend, framework) {
216
+ frontend = path.resolve(frontend);
217
+ const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'swell-frontend-package-'));
218
+ try {
219
+ let configPath = path.join(frontend, 'dist/server/wrangler.json');
220
+ if (framework === 'react') {
221
+ const manifest = JSON.parse(await fs.readFile(path.join(frontend, 'package.json'), 'utf8'));
222
+ const usesCloudflarePlugin = manifest.dependencies?.['@cloudflare/vite-plugin'] ||
223
+ manifest.devDependencies?.['@cloudflare/vite-plugin'];
224
+ if (usesCloudflarePlugin) {
225
+ const redirectPath = path.join(frontend, '.wrangler/deploy/config.json');
226
+ const redirect = JSON.parse(await fs.readFile(redirectPath, 'utf8').catch((error) => {
227
+ if (error.code !== 'ENOENT')
228
+ throw error;
229
+ throw new Error('Cloudflare Vite build is missing its deployment config. Build the frontend before packaging.');
230
+ }));
231
+ if (typeof redirect.configPath !== 'string' || !redirect.configPath)
232
+ throw new Error('Cloudflare Vite build is missing configPath');
233
+ configPath = path.resolve(path.dirname(redirectPath), redirect.configPath);
234
+ }
235
+ else {
236
+ configPath = path.join(temp, 'wrangler.json');
237
+ await fs.writeFile(path.join(temp, 'worker.js'), 'export default { fetch(request, env) { return env.ASSETS.fetch(request); } };\n');
238
+ await fs.writeFile(configPath, JSON.stringify({
239
+ name: 'swell-managed-react',
240
+ main: './worker.js',
241
+ compatibility_date: '2026-09-08',
242
+ assets: {
243
+ directory: path.join(frontend, 'dist'),
244
+ binding: 'ASSETS',
245
+ not_found_handling: 'single-page-application',
246
+ html_handling: 'none',
247
+ },
248
+ }));
249
+ }
250
+ }
251
+ const config = JSON.parse(await fs.readFile(configPath, 'utf8'));
252
+ if (!config.assets?.directory)
253
+ throw new Error('Managed frontend build must declare an assets directory');
254
+ const assetDirectory = path.resolve(path.dirname(configPath), config.assets.directory);
255
+ await runLocal(frontend, 'wrangler', [
256
+ 'deploy',
257
+ '--dry-run',
258
+ '--config',
259
+ configPath,
260
+ '--outdir',
261
+ path.join(temp, 'worker'),
262
+ '--outfile',
263
+ path.join(temp, 'upload.multipart'),
264
+ '--upload-source-maps=false',
265
+ ], temp);
266
+ const raw = await fs.readFile(path.join(temp, 'upload.multipart'));
267
+ const boundary = raw.subarray(2, raw.indexOf('\r\n')).toString();
268
+ const form = await new Response(raw, {
269
+ headers: {
270
+ 'content-type': `multipart/form-data; boundary=${boundary}`,
271
+ },
272
+ }).formData();
273
+ const metadataPart = form.get('metadata');
274
+ if (!metadataPart)
275
+ throw new Error('Wrangler output is missing metadata');
276
+ const metadata = JSON.parse(typeof metadataPart === 'string'
277
+ ? metadataPart
278
+ : await metadataPart.text());
279
+ const files = await collectAssets(assetDirectory);
280
+ for (const [name, part] of form) {
281
+ if (name === 'metadata')
282
+ continue;
283
+ if (typeof part === 'string')
284
+ throw new Error(`Invalid Worker module: ${name}`);
285
+ if (name.endsWith('.map'))
286
+ continue;
287
+ files.push(packageFile('module', name, part.type,
288
+ // eslint-disable-next-line no-await-in-loop
289
+ Buffer.from(await part.arrayBuffer())));
290
+ }
291
+ const bindings = (metadata.bindings ?? []).map((binding) => {
292
+ if ((binding.type === 'assets' && binding.name === 'ASSETS') ||
293
+ (framework === 'vinext' &&
294
+ binding.type === 'version_metadata' &&
295
+ binding.name === 'CF_VERSION_METADATA')) {
296
+ return binding.name;
297
+ }
298
+ throw new Error(`Unsupported managed frontend binding: ${binding.name}`);
299
+ });
300
+ const { assets, cache } = config;
301
+ const [_headers, _redirects] = await Promise.all([
302
+ readServingControl(assetDirectory, '_headers'),
303
+ readServingControl(assetDirectory, '_redirects'),
304
+ ]);
305
+ if (assets.run_worker_first !== undefined &&
306
+ typeof assets.run_worker_first !== 'boolean') {
307
+ throw new Error('Managed frontend assets.run_worker_first must be boolean');
308
+ }
309
+ const pkg = {
310
+ format: 1,
311
+ framework,
312
+ entrypoint: metadata.main_module,
313
+ runtime: {
314
+ compatibility_date: metadata.compatibility_date,
315
+ compatibility_flags: metadata.compatibility_flags ?? [],
316
+ bindings: bindings.sort(),
317
+ cache: cache?.enabled === true,
318
+ },
319
+ assets: {
320
+ ...(_headers === undefined ? {} : { _headers }),
321
+ ...(_redirects === undefined ? {} : { _redirects }),
322
+ html_handling: assets.html_handling ?? 'auto-trailing-slash',
323
+ not_found_handling: assets.not_found_handling ?? 'none',
324
+ run_worker_first: assets.run_worker_first ?? false,
325
+ },
326
+ files: files.sort(compareFiles),
327
+ digest: '',
328
+ };
329
+ pkg.digest = frontendPackageDigest(pkg);
330
+ verifyFrontendPackage(pkg);
331
+ await checkModuleGraph(pkg);
332
+ return pkg;
333
+ }
334
+ finally {
335
+ await fs.rm(temp, { recursive: true, force: true });
336
+ }
337
+ }
338
+ // Always rebuild: source-only hashes miss imported workspace files and dependencies.
339
+ export async function buildFrontendPackage(frontend, framework) {
340
+ frontend = path.resolve(frontend);
341
+ const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'swell-frontend-build-'));
342
+ try {
343
+ await runLocal(frontend, framework === 'vinext' ? 'vinext' : 'vite', ['build'], temp);
344
+ return await captureFrontendPackage(frontend, framework);
345
+ }
346
+ finally {
347
+ await fs.rm(temp, { recursive: true, force: true });
348
+ }
349
+ }
350
+ // Hosting is explicit; an unsupported managed project never falls back to Wrangler deploy.
351
+ export async function getManagedFrontendFramework(frontend) {
352
+ let manifestText;
353
+ try {
354
+ manifestText = await fs.readFile(path.join(frontend, 'package.json'), 'utf8');
355
+ }
356
+ catch (error) {
357
+ if (error.code !== 'ENOENT')
358
+ throw error;
359
+ throw new Error('Managed hosting requires a frontend/ directory with a package.json. Run swell create frontend, or set frontend.hosting to self-hosted.');
360
+ }
361
+ const manifest = JSON.parse(manifestText);
362
+ const dependencies = {
363
+ ...manifest.dependencies,
364
+ ...manifest.devDependencies,
365
+ };
366
+ if (dependencies['@opennextjs/cloudflare'] ||
367
+ (dependencies.next && !dependencies.vinext)) {
368
+ throw new Error('OpenNext and Next.js require frontend.hosting: self-hosted. Managed hosting supports Vinext or client-side React+Vite.');
369
+ }
370
+ if (dependencies.vinext)
371
+ return 'vinext';
372
+ if (dependencies.react && dependencies.vite)
373
+ return 'react';
374
+ throw new Error('Managed hosting requires Vinext or client-side React+Vite in frontend/package.json.');
375
+ }
@@ -0,0 +1 @@
1
+ export declare function warnFrontendRouteCollisions(appPath: string, framework: string, warn: (message: string) => void): Promise<void>;
@@ -0,0 +1,40 @@
1
+ import { globby } from 'globby';
2
+ import { existsSync } from 'node:fs';
3
+ import * as path from 'node:path';
4
+ // Inspect conventional filesystem handlers only. Browser calls to /api are valid
5
+ // Swell requests, and arbitrary Worker routing cannot be inferred from filenames.
6
+ export async function warnFrontendRouteCollisions(appPath, framework, warn) {
7
+ if (!['nextjs', 'vinext'].includes(framework))
8
+ return;
9
+ const frontend = path.join(appPath, 'frontend');
10
+ const root = (name) => existsSync(path.join(frontend, name)) ? name : `src/${name}`;
11
+ const appRoot = root('app');
12
+ const pagesRoot = root('pages');
13
+ const files = await globby([
14
+ `${appRoot}/**/route.{js,jsx,ts,tsx}`,
15
+ `${pagesRoot}/api/**/*.{js,jsx,ts,tsx}`,
16
+ '!**/*.d.ts',
17
+ ], { cwd: frontend, onlyFiles: true });
18
+ for (const file of files.sort()) {
19
+ const segments = file.startsWith(`${appRoot}/`)
20
+ ? appRouteSegments(file.slice(appRoot.length + 1))
21
+ : pagesRouteSegments(file.slice(pagesRoot.length + 1));
22
+ if (segments?.[0] === 'api') {
23
+ warn(`frontend/${file} declares /${segments.join('/')}, which Swell owns on Swell-routed domains. Use an unreserved frontend path such as /app-api, or explicitly configure the selected app's compatibility prefix for existing /api handlers.`);
24
+ }
25
+ }
26
+ }
27
+ // App Router: (group)/api/quote/route.ts declares /api/quote; _private is skipped.
28
+ function appRouteSegments(file) {
29
+ const segments = file.split('/').slice(0, -1);
30
+ if (segments.some((segment) => segment.startsWith('_')))
31
+ return undefined;
32
+ return segments.filter((segment) => !/^\([^)]*\)$/.test(segment));
33
+ }
34
+ // Pages Router: api/quote/index.ts and api/quote.ts both declare /api/quote.
35
+ function pagesRouteSegments(file) {
36
+ const segments = file.replace(/\.[^.]+$/, '').split('/');
37
+ if (segments.at(-1) === 'index')
38
+ segments.pop();
39
+ return segments;
40
+ }
@@ -0,0 +1,35 @@
1
+ import type { FrontendTemplate } from './frontend-templates.js';
2
+ import { PackageManager } from '../package-manager.js';
3
+ /** Allowed hosts for tunnel providers used in local development */
4
+ export declare const TUNNEL_ALLOWED_HOSTS: string[];
5
+ /**
6
+ * Configure allowedHosts for frameworks using Vite dev server.
7
+ * This allows tunneling services (localtunnel) to proxy requests to the
8
+ * local dev environment.
9
+ */
10
+ export declare function addFrontendAllowedHosts(framework: string, configPath: string): Promise<void>;
11
+ /** Write the root workspace files and install root dependencies. */
12
+ export declare function setupPackage(name: string, config: any, pkg: PackageManager): Promise<void>;
13
+ /**
14
+ * Remove the empty workspace placeholder created by setupPackage; refuse to
15
+ * scaffold over any other content in frontend/.
16
+ */
17
+ export declare function prepareFrontendDirectory(configPath: string): Promise<void>;
18
+ /** Create the root workspace, or add frontend to an existing one. */
19
+ export declare function ensureRootWorkspace(swellConfig: any, pkg: PackageManager): Promise<void>;
20
+ /**
21
+ * pnpm's native build policy must include the two toolchain binaries.
22
+ * Use pnpm to merge its YAML settings without replacing other entries.
23
+ */
24
+ export declare function ensurePnpmBuildApprovals(configPath: string): Promise<void>;
25
+ /** Run the template's scaffold command into frontend/. */
26
+ export declare function scaffoldFrontendTemplate(configPath: string, template: FrontendTemplate, pkg: PackageManager): Promise<void>;
27
+ /**
28
+ * The template finalizer restores the pinned declarations and managed
29
+ * settings that C3 rewrites while scaffolding.
30
+ */
31
+ export declare function finalizeManagedFrontend(frontendPath: string): Promise<void>;
32
+ /** Ensure frontend package.json has the correct name for the workspace. */
33
+ export declare function normalizeFrontendManifest(frontendPath: string, template: FrontendTemplate): Promise<void>;
34
+ /** Run install at root to hoist dependencies to the workspace. */
35
+ export declare function installFrontendWorkspace(configPath: string, template: FrontendTemplate, pkg: PackageManager): Promise<void>;