spyne-cli 0.7.0 → 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/index.js +8 -0
- package/package.json +8 -1
- package/src/cli/render.js +15 -1
- package/src/core/create-app.js +38 -14
- package/src/core/generate-app.js +155 -0
- package/src/registry.js +8 -2
package/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
renderError,
|
|
17
17
|
renderHelp,
|
|
18
18
|
renderModuleResult,
|
|
19
|
+
sitePromptPrompt,
|
|
19
20
|
templatePrompt,
|
|
20
21
|
} from './src/cli/render.js';
|
|
21
22
|
|
|
@@ -129,6 +130,13 @@ async function resolveAppArgs() {
|
|
|
129
130
|
({template: args.template} = await prompt(templatePrompt()));
|
|
130
131
|
}
|
|
131
132
|
|
|
133
|
+
// Shell's default follow-up: a site description sends scaffolding through
|
|
134
|
+
// the AI generator; an empty answer keeps the blank shell.
|
|
135
|
+
if (args.template === 'shell' && !provided.has('prompt')) {
|
|
136
|
+
const {prompt: sitePrompt} = await prompt(sitePromptPrompt());
|
|
137
|
+
if (String(sitePrompt).trim()) args.prompt = sitePrompt;
|
|
138
|
+
}
|
|
139
|
+
|
|
132
140
|
if (!args.appName) {
|
|
133
141
|
({appName: args.appName} = await prompt(appNamePrompt()));
|
|
134
142
|
}
|
package/package.json
CHANGED
|
@@ -4,7 +4,13 @@
|
|
|
4
4
|
"spyne-cli": "index.js"
|
|
5
5
|
},
|
|
6
6
|
"type": "module",
|
|
7
|
-
"version": "0.7.
|
|
7
|
+
"version": "0.7.1",
|
|
8
|
+
"overrides": {
|
|
9
|
+
"mocha": {
|
|
10
|
+
"diff": "^8.0.3",
|
|
11
|
+
"serialize-javascript": "^7.0.5"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
8
14
|
"description": "Generates spyne objects and saves them to standard spyne.",
|
|
9
15
|
"main": "src/registry.js",
|
|
10
16
|
"engines": {
|
|
@@ -51,6 +57,7 @@
|
|
|
51
57
|
"change-case": "^5.4.4",
|
|
52
58
|
"clear": "^0.1.0",
|
|
53
59
|
"enquirer": "^2.4.1",
|
|
60
|
+
"fflate": "^0.8.3",
|
|
54
61
|
"figlet": "^1.8.0",
|
|
55
62
|
"json-stringify-safe": "^5.0.1",
|
|
56
63
|
"ora": "^8.1.1",
|
package/src/cli/render.js
CHANGED
|
@@ -34,7 +34,9 @@ export const renderAppResult = (result) => {
|
|
|
34
34
|
'',
|
|
35
35
|
c.greenBright('Success!'),
|
|
36
36
|
`Created ${c.bold(result.appName)} at ${result.path}`,
|
|
37
|
-
|
|
37
|
+
result.generated
|
|
38
|
+
? `Template: ${result.template} (AI-generated, app id ${result.appId})`
|
|
39
|
+
: `Template: ${result.template}`,
|
|
38
40
|
'',
|
|
39
41
|
'Next steps:',
|
|
40
42
|
...result.nextSteps.map((step) => ` ${c.cyan(step)}`),
|
|
@@ -88,6 +90,18 @@ export const appNamePrompt = () => ({
|
|
|
88
90
|
'An app name is required.',
|
|
89
91
|
});
|
|
90
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
|
+
|
|
91
105
|
export const createProgressReporter = () => {
|
|
92
106
|
let spinner;
|
|
93
107
|
return ({status, message}) => {
|
package/src/core/create-app.js
CHANGED
|
@@ -5,6 +5,7 @@ import fs from 'fs';
|
|
|
5
5
|
import path from 'path';
|
|
6
6
|
import {spawn} from 'child_process';
|
|
7
7
|
import simpleGit from 'simple-git';
|
|
8
|
+
import {fetchGeneratedApp} from './generate-app.js';
|
|
8
9
|
|
|
9
10
|
// Template keys mirror the distinguishing suffix of their repo names:
|
|
10
11
|
// spynejs/application-starter and spynejs/application-shell.
|
|
@@ -163,26 +164,46 @@ export async function createApp(args = {}) {
|
|
|
163
164
|
};
|
|
164
165
|
}
|
|
165
166
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
await simpleGit().clone(repo, targetDir,
|
|
171
|
-
['--branch=main', '--single-branch', '--depth=1']);
|
|
172
|
-
onProgress({step: 'clone', status: 'success', message: 'Template cloned.'});
|
|
173
|
-
} catch (err) {
|
|
174
|
-
onProgress({step: 'clone', status: 'fail', message: 'Clone failed.'});
|
|
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') {
|
|
175
171
|
return {
|
|
176
172
|
ok: false,
|
|
177
173
|
error: {
|
|
178
|
-
code: '
|
|
179
|
-
message:
|
|
174
|
+
code: 'PROMPT_UNSUPPORTED',
|
|
175
|
+
message: 'A site description generates from the shell template. ' +
|
|
176
|
+
'Use --template shell (or drop --prompt).',
|
|
180
177
|
},
|
|
181
178
|
};
|
|
182
179
|
}
|
|
183
180
|
|
|
184
|
-
|
|
185
|
-
|
|
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
|
+
}
|
|
186
207
|
|
|
187
208
|
applyProjectIdentity(targetDir, appName);
|
|
188
209
|
|
|
@@ -220,13 +241,16 @@ export async function createApp(args = {}) {
|
|
|
220
241
|
appName,
|
|
221
242
|
path: targetDir,
|
|
222
243
|
template: templateKey,
|
|
223
|
-
|
|
244
|
+
...(generated
|
|
245
|
+
? {generated: true, appId: generated.appId}
|
|
246
|
+
: {templateRepo: repo}),
|
|
224
247
|
gitInitialized,
|
|
225
248
|
dependenciesInstalled,
|
|
226
249
|
nextSteps: [
|
|
227
250
|
`cd ${appName}`,
|
|
228
251
|
...(dependenciesInstalled ? [] : ['npm install']),
|
|
229
252
|
'npm start',
|
|
253
|
+
...(generated ? ['see GETTING-STARTED.md for the CMS claim + AI editing'] : []),
|
|
230
254
|
],
|
|
231
255
|
};
|
|
232
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
|
+
}
|
package/src/registry.js
CHANGED
|
@@ -59,6 +59,12 @@ export const commands = [
|
|
|
59
59
|
default: DEFAULT_TEMPLATE,
|
|
60
60
|
description: 'Template to scaffold from.',
|
|
61
61
|
},
|
|
62
|
+
prompt: {
|
|
63
|
+
type: 'string',
|
|
64
|
+
alias: 'p',
|
|
65
|
+
description: 'Site description — AI generates pages, routes, content, ' +
|
|
66
|
+
'and images (shell template only).',
|
|
67
|
+
},
|
|
62
68
|
install: {
|
|
63
69
|
type: 'boolean',
|
|
64
70
|
default: true,
|
|
@@ -131,7 +137,7 @@ export const commands = [
|
|
|
131
137
|
|
|
132
138
|
// `new` shipped in 0.6.x and stays working; create-app is canonical.
|
|
133
139
|
export const aliases = {
|
|
134
|
-
new: {command: 'create-app', deprecated: true, since: '0.7.
|
|
140
|
+
new: {command: 'create-app', deprecated: true, since: '0.7.1'},
|
|
135
141
|
};
|
|
136
142
|
|
|
137
143
|
/**
|
|
@@ -140,7 +146,7 @@ export const aliases = {
|
|
|
140
146
|
*/
|
|
141
147
|
export const removedFlags = {
|
|
142
148
|
spa: {
|
|
143
|
-
removedIn: '0.7.
|
|
149
|
+
removedIn: '0.7.1',
|
|
144
150
|
message: '"--spa" has been removed. Pick a template with --template starter|shell.',
|
|
145
151
|
},
|
|
146
152
|
};
|