spyne-cli 0.6.8 → 0.7.1

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.
Files changed (94) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +80 -30
  3. package/index.js +131 -21
  4. package/package.json +31 -6
  5. package/src/cli/args.js +197 -0
  6. package/src/cli/render.js +198 -0
  7. package/src/core/create-app.js +256 -0
  8. package/src/core/generate-app.js +155 -0
  9. package/src/core/generate-module.js +146 -0
  10. package/src/registry.js +180 -0
  11. package/src/spyne-file-prompt.js +77 -30
  12. package/src/templates/generate-prompt-input-fields.js +1 -1
  13. package/src/ui.js +5 -1
  14. package/lib/ansi.js +0 -116
  15. package/lib/combos.js +0 -75
  16. package/lib/completer.js +0 -52
  17. package/lib/interpolate.js +0 -266
  18. package/lib/keypress.js +0 -243
  19. package/lib/placeholder.js +0 -63
  20. package/lib/prompt.js +0 -485
  21. package/lib/prompts/autocomplete.js +0 -113
  22. package/lib/prompts/basicauth.js +0 -41
  23. package/lib/prompts/confirm.js +0 -13
  24. package/lib/prompts/editable.js +0 -136
  25. package/lib/prompts/form.js +0 -196
  26. package/lib/prompts/index.js +0 -28
  27. package/lib/prompts/input.js +0 -55
  28. package/lib/prompts/invisible.js +0 -11
  29. package/lib/prompts/list.js +0 -36
  30. package/lib/prompts/multiselect.js +0 -11
  31. package/lib/prompts/numeral.js +0 -1
  32. package/lib/prompts/password.js +0 -18
  33. package/lib/prompts/quiz.js +0 -37
  34. package/lib/prompts/scale.js +0 -237
  35. package/lib/prompts/select.js +0 -139
  36. package/lib/prompts/snippet.js +0 -185
  37. package/lib/prompts/sort.js +0 -37
  38. package/lib/prompts/survey.js +0 -163
  39. package/lib/prompts/text.js +0 -1
  40. package/lib/prompts/toggle.js +0 -109
  41. package/lib/render.js +0 -33
  42. package/lib/roles.js +0 -46
  43. package/lib/state.js +0 -69
  44. package/lib/styles.js +0 -144
  45. package/lib/symbols.js +0 -66
  46. package/lib/theme.js +0 -11
  47. package/lib/timer.js +0 -38
  48. package/lib/types/array.js +0 -658
  49. package/lib/types/auth.js +0 -29
  50. package/lib/types/boolean.js +0 -88
  51. package/lib/types/index.js +0 -7
  52. package/lib/types/number.js +0 -86
  53. package/lib/types/string.js +0 -185
  54. package/lib/utils.js +0 -268
  55. package/mocha.conf.cjs +0 -16
  56. package/src/app/channels/.gitkeep +0 -0
  57. package/src/app/channels/channel-custom.js +0 -1
  58. package/src/app/components/.gitkeep +0 -0
  59. package/src/app/traits/.gitkeep +0 -0
  60. package/src/hello-world-app-source/README.md +0 -15
  61. package/src/hello-world-app-source/apache-htaccess +0 -20
  62. package/src/hello-world-app-source/karma.conf.js +0 -75
  63. package/src/hello-world-app-source/package.json +0 -50
  64. package/src/hello-world-app-source/src/app/app-view.js +0 -38
  65. package/src/hello-world-app-source/src/app/channels/.gitkeep +0 -0
  66. package/src/hello-world-app-source/src/app/components/.gitkeep +0 -0
  67. package/src/hello-world-app-source/src/app/traits/.gitkeep +0 -0
  68. package/src/hello-world-app-source/src/index.js +0 -16
  69. package/src/hello-world-app-source/src/index.tmpl.html +0 -12
  70. package/src/hello-world-app-source/src/scss/01-vendors/normalize.css +0 -349
  71. package/src/hello-world-app-source/src/scss/02-variables/mixins.scss +0 -0
  72. package/src/hello-world-app-source/src/scss/03-components/.gitkeep +0 -0
  73. package/src/hello-world-app-source/src/scss/main.scss +0 -15
  74. package/src/hello-world-app-source/src/static/data/.gitkeep +0 -0
  75. package/src/hello-world-app-source/src/static/fonts/.gitkeep +0 -0
  76. package/src/hello-world-app-source/src/static/imgs/.gitkeep +0 -0
  77. package/src/hello-world-app-source/src/tests/unit-tests/index.test.js +0 -10
  78. package/src/hello-world-app-source/webpack.config.js +0 -178
  79. package/src/spyne-app-create.js +0 -209
  80. package/src/spyne-app-creator.js +0 -119
  81. package/src/spyne-starter-app-create.js +0 -147
  82. package/tests/create-spyne-app.test.js +0 -63
  83. package/tests/generate-file-prompt.test.js +0 -45
  84. package/tests/generate-file-string.test.js +0 -66
  85. package/tests/generate-prompt-input-fields-methods.test.js +0 -177
  86. package/tests/generate-prompt-input-fields.test.js +0 -179
  87. package/tests/generate-prompt-input-object.test.js +0 -39
  88. package/tests/generate-prompt-output.test.js +0 -58
  89. package/tests/index.test.js +0 -11
  90. package/tests/mocks/answers.js +0 -33
  91. package/tests/mocks/answers.json +0 -33
  92. package/tests/mocks/enquirer-data.js +0 -411
  93. package/tests/mocks/enquirer-data.json +0 -409
  94. package/tests/utils/file-utils.test.js +0 -84
@@ -0,0 +1,198 @@
1
+ // Thin presentation layer. Everything here is terminal chrome — no generation
2
+ // logic lives in this file, and nothing in src/core imports it.
3
+
4
+ import c from 'ansi-colors';
5
+ import ora from 'ora';
6
+ import {generatePromptOutput} from '../templates/generate-prompt-output.js';
7
+ import {commands} from '../registry.js';
8
+ import {TEMPLATES} from '../core/create-app.js';
9
+
10
+ /**
11
+ * Rebuild the legacy shapes generatePromptOutput expects, so module output is
12
+ * identical to 0.6.8 whether it came from the picker or from flags.
13
+ */
14
+ export const renderModuleResult = (result) => {
15
+ const answers = {
16
+ fileType: result.fileType,
17
+ fileName: result.fileName,
18
+ className: result.className,
19
+ fileDirectory: result.fileDirectory,
20
+ channelName: result.channelName,
21
+ };
22
+ const savedProps = {
23
+ fileExists: result.error?.code === 'FILE_ALREADY_EXISTS',
24
+ fileHasSaved: result.ok === true,
25
+ fileDirPath: result.filePath,
26
+ errorType: result.errorType,
27
+ };
28
+ return generatePromptOutput(answers, savedProps, result.fileString,
29
+ result.channelRegistered);
30
+ };
31
+
32
+ export const renderAppResult = (result) => {
33
+ const lines = [
34
+ '',
35
+ c.greenBright('Success!'),
36
+ `Created ${c.bold(result.appName)} at ${result.path}`,
37
+ result.generated
38
+ ? `Template: ${result.template} (AI-generated, app id ${result.appId})`
39
+ : `Template: ${result.template}`,
40
+ '',
41
+ 'Next steps:',
42
+ ...result.nextSteps.map((step) => ` ${c.cyan(step)}`),
43
+ ];
44
+ return lines.join('\n');
45
+ };
46
+
47
+ export const renderError = (error) => `${c.red('Error:')} ${error.message}`;
48
+
49
+ /**
50
+ * Enquirer clamps a select's visible rows with Math.min(limit, height), where
51
+ * height comes from options.rows and falls back to process.stdout.rows. When
52
+ * that reports 0 — short panes, some CI and multiplexer setups — height wins at
53
+ * 0 and the list renders as "No matching choices". Passing rows explicitly is
54
+ * the only way to raise the floor; a real terminal taller than the list keeps
55
+ * its own value, so this only ever kicks in when the default would break.
56
+ */
57
+ export const selectSizing = (choiceCount) => {
58
+ const needed = choiceCount + 2;
59
+ const limit = choiceCount;
60
+ return {limit, rows: Math.max(process.stdout.rows || 0, needed)};
61
+ };
62
+
63
+ /**
64
+ * Template choices for the enquirer select. `name` must be the template key,
65
+ * not the label — it is what reaches createApp.
66
+ */
67
+ export const templateChoices = () => Object.values(TEMPLATES).map((t) => ({
68
+ name: t.value,
69
+ message: c.greenBright(t.label),
70
+ hint: c.dim(`— ${t.description}`),
71
+ }));
72
+
73
+ export const templatePrompt = () => {
74
+ const choices = templateChoices();
75
+ return {
76
+ type: 'select',
77
+ name: 'template',
78
+ message: c.blueBright('Choose a template'),
79
+ choices,
80
+ ...selectSizing(choices.length),
81
+ };
82
+ };
83
+
84
+ export const appNamePrompt = () => ({
85
+ type: 'input',
86
+ name: 'appName',
87
+ message: c.blueBright('Enter the application name'),
88
+ initial: 'my-spyne-app',
89
+ validate: (value) => String(value).trim().length > 0 ||
90
+ 'An app name is required.',
91
+ });
92
+
93
+ // The default follow-up when the shell template is chosen: describing the
94
+ // site routes scaffolding through the AI generator; Enter on an empty line
95
+ // keeps the blank shell clone.
96
+ export const sitePromptPrompt = () => ({
97
+ type: 'input',
98
+ name: 'prompt',
99
+ message: c.blueBright(
100
+ 'Describe your site — AI generates its pages and content ' +
101
+ '(Enter to skip for the blank shell)'),
102
+ initial: '',
103
+ });
104
+
105
+ export const createProgressReporter = () => {
106
+ let spinner;
107
+ return ({status, message}) => {
108
+ if (status === 'start') {
109
+ spinner = ora({text: message, spinner: 'dots'}).start();
110
+ return;
111
+ }
112
+ if (!spinner) return;
113
+ if (status === 'success') spinner.succeed(message);
114
+ else if (status === 'fail') spinner.fail(message);
115
+ else spinner.info(message);
116
+ spinner = undefined;
117
+ };
118
+ };
119
+
120
+ const isPositional = (def) => typeof def.positional === 'number';
121
+
122
+ const describeArg = (def) => {
123
+ const parts = [def.description];
124
+ if (def.enum) parts.push(`Values: ${def.enum.join(' | ')}.`);
125
+ if (def.default !== undefined && def.type !== 'boolean') {
126
+ parts.push(`Default: ${def.default}.`);
127
+ }
128
+ return parts.filter(Boolean).join(' ');
129
+ };
130
+
131
+ const formatOption = ([name, def]) => {
132
+ // A boolean that defaults true is only actionable in its --no- form, so that
133
+ // is what help shows. `description` stays true to the positive form for
134
+ // programmatic consumers of the registry.
135
+ const showNegated = def.type === 'boolean' && def.default === true;
136
+ const flag = showNegated
137
+ ? `--no-${name}`
138
+ : (def.alias ? `-${def.alias}, --${name}` : `--${name}`);
139
+ const description = showNegated
140
+ ? (def.negatedDescription || `Skip: ${def.description}`)
141
+ : describeArg(def);
142
+ return ` ${flag.padEnd(20)} ${description}`;
143
+ };
144
+
145
+ const formatPositional = ([name, def]) =>
146
+ ` ${`<${name}>`.padEnd(20)} ${describeArg(def)}`;
147
+
148
+ export const renderCommandHelp = (command) => {
149
+ const entries = Object.entries(command.args);
150
+ const positionals = entries.filter(([, def]) => isPositional(def));
151
+ const options = entries.filter(([, def]) => !isPositional(def));
152
+ const usage = [
153
+ 'spyne-cli',
154
+ command.name,
155
+ ...positionals.map(([name, def]) => def.required ? `<${name}>` : `[${name}]`),
156
+ options.length ? '[options]' : '',
157
+ ].filter(Boolean).join(' ');
158
+
159
+ return [
160
+ '',
161
+ ` ${c.bold(command.name)} — ${command.summary}`,
162
+ '',
163
+ ' Usage:',
164
+ ` ${usage}`,
165
+ ...(positionals.length
166
+ ? ['', ' Arguments:', ...positionals.map(formatPositional)]
167
+ : []),
168
+ ...(options.length ? ['', ' Options:', ...options.map(formatOption)] : []),
169
+ '',
170
+ ].join('\n');
171
+ };
172
+
173
+ export const renderHelp = (version) => {
174
+ const templateList = Object.values(TEMPLATES).
175
+ map((t) => ` ${t.value.padEnd(12)} ${t.description}`).
176
+ join('\n');
177
+
178
+ return [
179
+ '',
180
+ ` spyne-cli ${version}`,
181
+ '',
182
+ ' Usage:',
183
+ ' spyne-cli interactive picker',
184
+ ' spyne-cli <command> [options]',
185
+ '',
186
+ ' Commands:',
187
+ ...commands.map((cmd) => ` ${cmd.name.padEnd(20)} ${cmd.summary}`),
188
+ '',
189
+ ' Templates (create-app):',
190
+ templateList,
191
+ '',
192
+ ' Global options:',
193
+ ' --json machine-readable output, human chrome suppressed',
194
+ ' -h, --help show help; pass a command name for its options',
195
+ ' -v, --version print version',
196
+ '',
197
+ ].join('\n');
198
+ };
@@ -0,0 +1,256 @@
1
+ // Core app scaffolding. No terminal output — callers pass an optional
2
+ // onProgress callback and render however they like.
3
+
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import {spawn} from 'child_process';
7
+ import simpleGit from 'simple-git';
8
+ import {fetchGeneratedApp} from './generate-app.js';
9
+
10
+ // Template keys mirror the distinguishing suffix of their repo names:
11
+ // spynejs/application-starter and spynejs/application-shell.
12
+ export const TEMPLATES = {
13
+ starter: {
14
+ value: 'starter',
15
+ label: 'Application Starter',
16
+ description: 'minimal: app.js + a hello-world view',
17
+ repo: 'https://github.com/spynejs/application-starter.git',
18
+ },
19
+ shell: {
20
+ value: 'shell',
21
+ label: 'Application Shell',
22
+ description: 'pages, navigation, and UI components built in',
23
+ repo: 'https://github.com/spynejs/application-shell.git',
24
+ },
25
+ };
26
+
27
+ // Accepted silently; only the canonical values are documented.
28
+ const TEMPLATE_ALIASES = {
29
+ 'app-shell': 'shell',
30
+ 'application-shell': 'shell',
31
+ 'application-starter': 'starter',
32
+ };
33
+
34
+ export const DEFAULT_TEMPLATE = 'starter';
35
+
36
+ export const resolveTemplate = (name) => {
37
+ if (!name) return DEFAULT_TEMPLATE;
38
+ const key = String(name).toLowerCase();
39
+ return TEMPLATE_ALIASES[key] || key;
40
+ };
41
+
42
+ /**
43
+ * Clone URL for a template. An env override lets a fork — or this repo before
44
+ * the templates are published — scaffold from somewhere else. Read at call
45
+ * time so it is settable per invocation.
46
+ *
47
+ * SPYNE_CLI_STARTER_REPO, SPYNE_CLI_SHELL_REPO
48
+ */
49
+ export const repoFor = (templateKey) => {
50
+ const envKey = `SPYNE_CLI_${templateKey.toUpperCase().replace(/-/g, '_')}_REPO`;
51
+ return process.env[envKey] || TEMPLATES[templateKey].repo;
52
+ };
53
+
54
+ export const sanitizePackageName = (appName) => String(appName).
55
+ toLowerCase().
56
+ replace(/\s+/g, '-').
57
+ replace(/[^a-z0-9\-_.]/g, '') || 'my-spyne-app';
58
+
59
+ const run = (command, args, cwd) => new Promise((resolve) => {
60
+ const child = spawn(command, args, {cwd, stdio: 'ignore', shell: false});
61
+ child.on('close', (code) => resolve(code));
62
+ child.on('error', () => resolve(-1));
63
+ });
64
+
65
+ const updateIndexTitle = (targetDir, appName) => {
66
+ const indexFilePath = path.join(targetDir, 'src', 'index.tmpl.html');
67
+ if (!fs.existsSync(indexFilePath)) return;
68
+ const content = fs.readFileSync(indexFilePath, 'utf-8');
69
+ fs.writeFileSync(indexFilePath,
70
+ content.replace(/<title>.*<\/title>/, `<title>${appName}</title>`),
71
+ 'utf-8');
72
+ };
73
+
74
+ const updatePackageName = (targetDir, appName) => {
75
+ const pkgPath = path.join(targetDir, 'package.json');
76
+ if (!fs.existsSync(pkgPath)) return;
77
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
78
+ pkg.name = sanitizePackageName(appName);
79
+ pkg.version = '0.1.0';
80
+ delete pkg.repository;
81
+ delete pkg.homepage;
82
+ fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf-8');
83
+ };
84
+
85
+ /**
86
+ * Rebrand a freshly cloned template as the user's app. Exported so the
87
+ * post-clone pipeline is testable without reaching the network.
88
+ */
89
+ export const applyProjectIdentity = (targetDir, appName) => {
90
+ updateIndexTitle(targetDir, appName);
91
+ updatePackageName(targetDir, appName);
92
+ };
93
+
94
+ /**
95
+ * Detect whether cwd (or an ancestor) looks like a SpyneJS project.
96
+ * Used to guard `create-app` inside a project and module generation outside one.
97
+ */
98
+ export const detectSpyneProject = (startDir = process.cwd()) => {
99
+ let dir = path.resolve(startDir);
100
+ while (true) {
101
+ const pkgPath = path.join(dir, 'package.json');
102
+ if (fs.existsSync(pkgPath)) {
103
+ try {
104
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
105
+ const deps = {...pkg.dependencies, ...pkg.devDependencies};
106
+ if (deps && deps.spyne) return {isSpyneProject: true, root: dir};
107
+ } catch {
108
+ // unreadable package.json — keep walking up
109
+ }
110
+ }
111
+ const parent = path.dirname(dir);
112
+ if (parent === dir) return {isSpyneProject: false, root: null};
113
+ dir = parent;
114
+ }
115
+ };
116
+
117
+ /**
118
+ * Create a new SpyneJS application.
119
+ *
120
+ * @param {Object} args
121
+ * @param {string} args.appName
122
+ * @param {string} [args.template] 'starter' | 'shell'
123
+ * @param {boolean} [args.install] install dependencies (default true)
124
+ * @param {boolean} [args.git] initialise a fresh git repo (default true)
125
+ * @param {Function} [args.onProgress] ({step, status, message}) => void
126
+ * @returns {Promise<Object>} structured result
127
+ */
128
+ export async function createApp(args = {}) {
129
+ const {appName, onProgress = () => {}} = args;
130
+ const install = args.install !== false;
131
+ const initGit = args.git !== false;
132
+
133
+ if (!appName) {
134
+ return {
135
+ ok: false,
136
+ error: {code: 'MISSING_APP_NAME', message: 'An app name is required.'},
137
+ };
138
+ }
139
+
140
+ const templateKey = resolveTemplate(args.template);
141
+ const template = TEMPLATES[templateKey];
142
+
143
+ if (!template) {
144
+ return {
145
+ ok: false,
146
+ error: {
147
+ code: 'UNKNOWN_TEMPLATE',
148
+ message: `Unknown template "${args.template}". Expected one of: ${Object.keys(
149
+ TEMPLATES).join(', ')}.`,
150
+ },
151
+ };
152
+ }
153
+
154
+ const targetDir = path.resolve(process.cwd(), appName);
155
+
156
+ if (fs.existsSync(targetDir)) {
157
+ return {
158
+ ok: false,
159
+ error: {
160
+ code: 'DIRECTORY_EXISTS',
161
+ message: `${targetDir} already exists.`,
162
+ },
163
+ path: targetDir,
164
+ };
165
+ }
166
+
167
+ // A site description routes the shell template through the AI generator
168
+ // instead of a plain clone; everything after acquisition is shared.
169
+ const sitePrompt = args.prompt && String(args.prompt).trim();
170
+ if (sitePrompt && templateKey !== 'shell') {
171
+ return {
172
+ ok: false,
173
+ error: {
174
+ code: 'PROMPT_UNSUPPORTED',
175
+ message: 'A site description generates from the shell template. ' +
176
+ 'Use --template shell (or drop --prompt).',
177
+ },
178
+ };
179
+ }
180
+
181
+ const repo = repoFor(templateKey);
182
+ let generated;
183
+
184
+ if (sitePrompt) {
185
+ generated = await fetchGeneratedApp({sitePrompt, targetDir, onProgress});
186
+ if (!generated.ok) return generated;
187
+ } else {
188
+ onProgress({step: 'clone', status: 'start', message: 'Cloning template...'});
189
+ try {
190
+ await simpleGit().clone(repo, targetDir,
191
+ ['--branch=main', '--single-branch', '--depth=1']);
192
+ onProgress({step: 'clone', status: 'success', message: 'Template cloned.'});
193
+ } catch (err) {
194
+ onProgress({step: 'clone', status: 'fail', message: 'Clone failed.'});
195
+ return {
196
+ ok: false,
197
+ error: {
198
+ code: 'CLONE_FAILED',
199
+ message: `Could not clone ${repo}: ${err.message}`,
200
+ },
201
+ };
202
+ }
203
+
204
+ // No .git leakage from the template repo.
205
+ fs.rmSync(path.join(targetDir, '.git'), {recursive: true, force: true});
206
+ }
207
+
208
+ applyProjectIdentity(targetDir, appName);
209
+
210
+ let gitInitialized = false;
211
+ if (initGit) {
212
+ onProgress({step: 'git', status: 'start', message: 'Initialising git...'});
213
+ gitInitialized = await run('git', ['init', '--quiet'], targetDir) === 0;
214
+ onProgress({
215
+ step: 'git',
216
+ status: gitInitialized ? 'success' : 'skip',
217
+ message: gitInitialized ? 'Git repository initialised.' : 'Skipped git init.',
218
+ });
219
+ }
220
+
221
+ let dependenciesInstalled = false;
222
+ if (install) {
223
+ onProgress({
224
+ step: 'install',
225
+ status: 'start',
226
+ message: 'Installing dependencies...',
227
+ });
228
+ dependenciesInstalled =
229
+ await run('npm', ['install', '--silent'], targetDir) === 0;
230
+ onProgress({
231
+ step: 'install',
232
+ status: dependenciesInstalled ? 'success' : 'fail',
233
+ message: dependenciesInstalled
234
+ ? 'Dependencies installed.'
235
+ : 'Dependency install failed — run npm install manually.',
236
+ });
237
+ }
238
+
239
+ return {
240
+ ok: true,
241
+ appName,
242
+ path: targetDir,
243
+ template: templateKey,
244
+ ...(generated
245
+ ? {generated: true, appId: generated.appId}
246
+ : {templateRepo: repo}),
247
+ gitInitialized,
248
+ dependenciesInstalled,
249
+ nextSteps: [
250
+ `cd ${appName}`,
251
+ ...(dependenciesInstalled ? [] : ['npm install']),
252
+ 'npm start',
253
+ ...(generated ? ['see GETTING-STARTED.md for the CMS claim + AI editing'] : []),
254
+ ],
255
+ };
256
+ }
@@ -0,0 +1,155 @@
1
+ // AI-generated app scaffolding: the shell template, but with pages, routes,
2
+ // content, and images produced by the SpyneJS generator service from one
3
+ // site description. Same contract as the clone path — no terminal output,
4
+ // progress via callback, structured result.
5
+
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import {unzipSync} from 'fflate';
9
+
10
+ /**
11
+ * Endpoint for the generator service (POST /generate). Env override mirrors
12
+ * the template-repo override pattern; read at call time.
13
+ */
14
+ export const DEFAULT_GENERATOR_URL =
15
+ 'https://ntvylgarzwinj3lcwtyctwlclu0ldpjp.lambda-url.us-east-1.on.aws/';
16
+
17
+ export const generatorUrl = () =>
18
+ process.env.SPYNE_CLI_GENERATOR_URL || DEFAULT_GENERATOR_URL;
19
+
20
+ const errorFor = (status, body) => {
21
+ if (status === 422) {
22
+ return {
23
+ code: 'GENERATION_INVALID',
24
+ message: 'The generator could not produce a valid site from that ' +
25
+ 'description. Try rephrasing it.',
26
+ };
27
+ }
28
+ if (status === 429) {
29
+ return {
30
+ code: 'RATE_LIMITED',
31
+ message: 'The anonymous generation quota for this network was reached. ' +
32
+ 'Try again later.',
33
+ };
34
+ }
35
+ if (status === 503 || status === 504) {
36
+ return {
37
+ code: 'GENERATION_TIMEOUT',
38
+ message: 'The generator took too long to respond. Try again — complex ' +
39
+ 'descriptions occasionally need a second attempt.',
40
+ };
41
+ }
42
+ return {
43
+ code: 'GENERATION_FAILED',
44
+ message: `Generator request failed (${status}): ` +
45
+ `${(body && body.error) || 'unexpected response'}.`,
46
+ };
47
+ };
48
+
49
+ /**
50
+ * Extract a generated package zip into targetDir, stripping the single
51
+ * top-level directory the package wraps its files in. Entries that would
52
+ * escape targetDir are rejected.
53
+ */
54
+ export const extractPackage = (zipBuffer, targetDir) => {
55
+ const entries = unzipSync(new Uint8Array(zipBuffer));
56
+ const resolvedTarget = path.resolve(targetDir);
57
+
58
+ for (const [entryPath, data] of Object.entries(entries)) {
59
+ if (entryPath.endsWith('/')) continue;
60
+ const stripped = entryPath.split('/').slice(1).join('/');
61
+ if (!stripped) continue;
62
+ const dest = path.resolve(resolvedTarget, stripped);
63
+ if (!dest.startsWith(resolvedTarget + path.sep)) {
64
+ throw new Error(`Unsafe path in package: ${entryPath}`);
65
+ }
66
+ fs.mkdirSync(path.dirname(dest), {recursive: true});
67
+ fs.writeFileSync(dest, data);
68
+ }
69
+ };
70
+
71
+ /**
72
+ * Generate an application from a site description. Called by createApp once
73
+ * appName/targetDir are validated; performs the network stages and extraction,
74
+ * leaving identity, git, and install to the shared pipeline.
75
+ *
76
+ * @returns {Promise<Object>} {ok, ...} — on success includes appId and
77
+ * claimToken so renderers can surface the CMS claim.
78
+ */
79
+ export async function fetchGeneratedApp({sitePrompt, targetDir, onProgress}) {
80
+ onProgress({
81
+ step: 'generate',
82
+ status: 'start',
83
+ message: 'Generating site with AI (this can take up to a minute)...',
84
+ });
85
+
86
+ let res;
87
+ let body;
88
+ try {
89
+ res = await fetch(generatorUrl(), {
90
+ method: 'POST',
91
+ headers: {'Content-Type': 'application/json'},
92
+ body: JSON.stringify({prompt: sitePrompt}),
93
+ signal: AbortSignal.timeout(120000),
94
+ });
95
+ body = await res.json().catch(() => ({}));
96
+ } catch (err) {
97
+ onProgress({step: 'generate', status: 'fail', message: 'Generation failed.'});
98
+ return {
99
+ ok: false,
100
+ error: {
101
+ code: 'GENERATION_UNREACHABLE',
102
+ message: `Could not reach the generator: ${err.message}`,
103
+ },
104
+ };
105
+ }
106
+
107
+ if (!res.ok || !body.packageUrl) {
108
+ onProgress({step: 'generate', status: 'fail', message: 'Generation failed.'});
109
+ return {ok: false, error: errorFor(res.status, body)};
110
+ }
111
+
112
+ const pageCount = Array.isArray(body.appModel && body.appModel.content)
113
+ ? body.appModel.content.length
114
+ : undefined;
115
+ onProgress({
116
+ step: 'generate',
117
+ status: 'success',
118
+ message: pageCount
119
+ ? `Site generated (${pageCount} pages).`
120
+ : 'Site generated.',
121
+ });
122
+
123
+ onProgress({step: 'download', status: 'start', message: 'Downloading package...'});
124
+ let zipBuffer;
125
+ try {
126
+ const zipRes = await fetch(body.packageUrl,
127
+ {signal: AbortSignal.timeout(120000)});
128
+ if (!zipRes.ok) throw new Error(`HTTP ${zipRes.status}`);
129
+ zipBuffer = Buffer.from(await zipRes.arrayBuffer());
130
+ } catch (err) {
131
+ onProgress({step: 'download', status: 'fail', message: 'Download failed.'});
132
+ return {
133
+ ok: false,
134
+ error: {
135
+ code: 'DOWNLOAD_FAILED',
136
+ message: `Could not download the package: ${err.message}`,
137
+ },
138
+ };
139
+ }
140
+ onProgress({step: 'download', status: 'success', message: 'Package downloaded.'});
141
+
142
+ try {
143
+ extractPackage(zipBuffer, targetDir);
144
+ } catch (err) {
145
+ return {
146
+ ok: false,
147
+ error: {
148
+ code: 'EXTRACT_FAILED',
149
+ message: `Could not extract the package: ${err.message}`,
150
+ },
151
+ };
152
+ }
153
+
154
+ return {ok: true, appId: body.appId, claimToken: body.claimToken};
155
+ }