spyne-cli 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -61,8 +61,8 @@ npx spyne-cli create-app my-app --template shell
61
61
 
62
62
  | Template | Contents |
63
63
  | --- | --- |
64
- | `starter` (default) | `app.js` and a hello-world view |
65
- | `shell` | pages, navigation, and UI components |
64
+ | `shell` (default) | pages, navigation, and UI components |
65
+ | `starter` | `app.js` and a hello-world view |
66
66
 
67
67
  Options: `-t, --template`, `--no-install` to skip dependency installation,
68
68
  `--no-git` to skip git initialisation.
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.0",
7
+ "version": "0.7.2",
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
- `Template: ${result.template}`,
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}) => {
@@ -5,22 +5,23 @@ 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.
11
12
  export const TEMPLATES = {
12
- starter: {
13
- value: 'starter',
14
- label: 'Application Starter',
15
- description: 'minimal: app.js + a hello-world view',
16
- repo: 'https://github.com/spynejs/application-starter.git',
17
- },
18
13
  shell: {
19
14
  value: 'shell',
20
15
  label: 'Application Shell',
21
16
  description: 'pages, navigation, and UI components built in',
22
17
  repo: 'https://github.com/spynejs/application-shell.git',
23
18
  },
19
+ starter: {
20
+ value: 'starter',
21
+ label: 'Application Starter',
22
+ description: 'minimal: app.js + a hello-world view',
23
+ repo: 'https://github.com/spynejs/application-starter.git',
24
+ },
24
25
  };
25
26
 
26
27
  // Accepted silently; only the canonical values are documented.
@@ -30,7 +31,7 @@ const TEMPLATE_ALIASES = {
30
31
  'application-starter': 'starter',
31
32
  };
32
33
 
33
- export const DEFAULT_TEMPLATE = 'starter';
34
+ export const DEFAULT_TEMPLATE = 'shell';
34
35
 
35
36
  export const resolveTemplate = (name) => {
36
37
  if (!name) return DEFAULT_TEMPLATE;
@@ -163,26 +164,46 @@ export async function createApp(args = {}) {
163
164
  };
164
165
  }
165
166
 
166
- const repo = repoFor(templateKey);
167
-
168
- onProgress({step: 'clone', status: 'start', message: 'Cloning template...'});
169
- try {
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: 'CLONE_FAILED',
179
- message: `Could not clone ${repo}: ${err.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
- // No .git leakage from the template repo.
185
- fs.rmSync(path.join(targetDir, '.git'), {recursive: true, force: true});
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
- templateRepo: repo,
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 + AI editing'] : []),
230
254
  ],
231
255
  };
232
256
  }
@@ -0,0 +1,161 @@
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: {
92
+ 'Content-Type': 'application/json',
93
+ // Private testing bypass for the anonymous quota (optional).
94
+ ...(process.env.SPYNE_CLI_GENERATOR_KEY
95
+ ? {'x-generation-key': process.env.SPYNE_CLI_GENERATOR_KEY}
96
+ : {}),
97
+ },
98
+ body: JSON.stringify({prompt: sitePrompt}),
99
+ signal: AbortSignal.timeout(120000),
100
+ });
101
+ body = await res.json().catch(() => ({}));
102
+ } catch (err) {
103
+ onProgress({step: 'generate', status: 'fail', message: 'Generation failed.'});
104
+ return {
105
+ ok: false,
106
+ error: {
107
+ code: 'GENERATION_UNREACHABLE',
108
+ message: `Could not reach the generator: ${err.message}`,
109
+ },
110
+ };
111
+ }
112
+
113
+ if (!res.ok || !body.packageUrl) {
114
+ onProgress({step: 'generate', status: 'fail', message: 'Generation failed.'});
115
+ return {ok: false, error: errorFor(res.status, body)};
116
+ }
117
+
118
+ const pageCount = Array.isArray(body.appModel && body.appModel.content)
119
+ ? body.appModel.content.length
120
+ : undefined;
121
+ onProgress({
122
+ step: 'generate',
123
+ status: 'success',
124
+ message: pageCount
125
+ ? `Site generated (${pageCount} pages).`
126
+ : 'Site generated.',
127
+ });
128
+
129
+ onProgress({step: 'download', status: 'start', message: 'Downloading package...'});
130
+ let zipBuffer;
131
+ try {
132
+ const zipRes = await fetch(body.packageUrl,
133
+ {signal: AbortSignal.timeout(120000)});
134
+ if (!zipRes.ok) throw new Error(`HTTP ${zipRes.status}`);
135
+ zipBuffer = Buffer.from(await zipRes.arrayBuffer());
136
+ } catch (err) {
137
+ onProgress({step: 'download', status: 'fail', message: 'Download failed.'});
138
+ return {
139
+ ok: false,
140
+ error: {
141
+ code: 'DOWNLOAD_FAILED',
142
+ message: `Could not download the package: ${err.message}`,
143
+ },
144
+ };
145
+ }
146
+ onProgress({step: 'download', status: 'success', message: 'Package downloaded.'});
147
+
148
+ try {
149
+ extractPackage(zipBuffer, targetDir);
150
+ } catch (err) {
151
+ return {
152
+ ok: false,
153
+ error: {
154
+ code: 'EXTRACT_FAILED',
155
+ message: `Could not extract the package: ${err.message}`,
156
+ },
157
+ };
158
+ }
159
+
160
+ return {ok: true, appId: body.appId, claimToken: body.claimToken};
161
+ }
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.0'},
140
+ new: {command: 'create-app', deprecated: true, since: '0.7.2'},
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.0',
149
+ removedIn: '0.7.1',
144
150
  message: '"--spa" has been removed. Pick a template with --template starter|shell.',
145
151
  },
146
152
  };