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.
- package/LICENSE +661 -0
- package/README.md +80 -30
- package/index.js +131 -21
- package/package.json +31 -6
- package/src/cli/args.js +197 -0
- package/src/cli/render.js +198 -0
- package/src/core/create-app.js +256 -0
- package/src/core/generate-app.js +155 -0
- package/src/core/generate-module.js +146 -0
- package/src/registry.js +180 -0
- package/src/spyne-file-prompt.js +77 -30
- package/src/templates/generate-prompt-input-fields.js +1 -1
- package/src/ui.js +5 -1
- package/lib/ansi.js +0 -116
- package/lib/combos.js +0 -75
- package/lib/completer.js +0 -52
- package/lib/interpolate.js +0 -266
- package/lib/keypress.js +0 -243
- package/lib/placeholder.js +0 -63
- package/lib/prompt.js +0 -485
- package/lib/prompts/autocomplete.js +0 -113
- package/lib/prompts/basicauth.js +0 -41
- package/lib/prompts/confirm.js +0 -13
- package/lib/prompts/editable.js +0 -136
- package/lib/prompts/form.js +0 -196
- package/lib/prompts/index.js +0 -28
- package/lib/prompts/input.js +0 -55
- package/lib/prompts/invisible.js +0 -11
- package/lib/prompts/list.js +0 -36
- package/lib/prompts/multiselect.js +0 -11
- package/lib/prompts/numeral.js +0 -1
- package/lib/prompts/password.js +0 -18
- package/lib/prompts/quiz.js +0 -37
- package/lib/prompts/scale.js +0 -237
- package/lib/prompts/select.js +0 -139
- package/lib/prompts/snippet.js +0 -185
- package/lib/prompts/sort.js +0 -37
- package/lib/prompts/survey.js +0 -163
- package/lib/prompts/text.js +0 -1
- package/lib/prompts/toggle.js +0 -109
- package/lib/render.js +0 -33
- package/lib/roles.js +0 -46
- package/lib/state.js +0 -69
- package/lib/styles.js +0 -144
- package/lib/symbols.js +0 -66
- package/lib/theme.js +0 -11
- package/lib/timer.js +0 -38
- package/lib/types/array.js +0 -658
- package/lib/types/auth.js +0 -29
- package/lib/types/boolean.js +0 -88
- package/lib/types/index.js +0 -7
- package/lib/types/number.js +0 -86
- package/lib/types/string.js +0 -185
- package/lib/utils.js +0 -268
- package/mocha.conf.cjs +0 -16
- package/src/app/channels/.gitkeep +0 -0
- package/src/app/channels/channel-custom.js +0 -1
- package/src/app/components/.gitkeep +0 -0
- package/src/app/traits/.gitkeep +0 -0
- package/src/hello-world-app-source/README.md +0 -15
- package/src/hello-world-app-source/apache-htaccess +0 -20
- package/src/hello-world-app-source/karma.conf.js +0 -75
- package/src/hello-world-app-source/package.json +0 -50
- package/src/hello-world-app-source/src/app/app-view.js +0 -38
- package/src/hello-world-app-source/src/app/channels/.gitkeep +0 -0
- package/src/hello-world-app-source/src/app/components/.gitkeep +0 -0
- package/src/hello-world-app-source/src/app/traits/.gitkeep +0 -0
- package/src/hello-world-app-source/src/index.js +0 -16
- package/src/hello-world-app-source/src/index.tmpl.html +0 -12
- package/src/hello-world-app-source/src/scss/01-vendors/normalize.css +0 -349
- package/src/hello-world-app-source/src/scss/02-variables/mixins.scss +0 -0
- package/src/hello-world-app-source/src/scss/03-components/.gitkeep +0 -0
- package/src/hello-world-app-source/src/scss/main.scss +0 -15
- package/src/hello-world-app-source/src/static/data/.gitkeep +0 -0
- package/src/hello-world-app-source/src/static/fonts/.gitkeep +0 -0
- package/src/hello-world-app-source/src/static/imgs/.gitkeep +0 -0
- package/src/hello-world-app-source/src/tests/unit-tests/index.test.js +0 -10
- package/src/hello-world-app-source/webpack.config.js +0 -178
- package/src/spyne-app-create.js +0 -209
- package/src/spyne-app-creator.js +0 -119
- package/src/spyne-starter-app-create.js +0 -147
- package/tests/create-spyne-app.test.js +0 -63
- package/tests/generate-file-prompt.test.js +0 -45
- package/tests/generate-file-string.test.js +0 -66
- package/tests/generate-prompt-input-fields-methods.test.js +0 -177
- package/tests/generate-prompt-input-fields.test.js +0 -179
- package/tests/generate-prompt-input-object.test.js +0 -39
- package/tests/generate-prompt-output.test.js +0 -58
- package/tests/index.test.js +0 -11
- package/tests/mocks/answers.js +0 -33
- package/tests/mocks/answers.json +0 -33
- package/tests/mocks/enquirer-data.js +0 -411
- package/tests/mocks/enquirer-data.json +0 -409
- 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
|
+
}
|