spyne-cli 0.6.8 → 0.7.0
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 +123 -21
- package/package.json +24 -6
- package/src/cli/args.js +197 -0
- package/src/cli/render.js +184 -0
- package/src/core/create-app.js +232 -0
- package/src/core/generate-module.js +146 -0
- package/src/registry.js +174 -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,184 @@
|
|
|
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
|
+
`Template: ${result.template}`,
|
|
38
|
+
'',
|
|
39
|
+
'Next steps:',
|
|
40
|
+
...result.nextSteps.map((step) => ` ${c.cyan(step)}`),
|
|
41
|
+
];
|
|
42
|
+
return lines.join('\n');
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const renderError = (error) => `${c.red('Error:')} ${error.message}`;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Enquirer clamps a select's visible rows with Math.min(limit, height), where
|
|
49
|
+
* height comes from options.rows and falls back to process.stdout.rows. When
|
|
50
|
+
* that reports 0 — short panes, some CI and multiplexer setups — height wins at
|
|
51
|
+
* 0 and the list renders as "No matching choices". Passing rows explicitly is
|
|
52
|
+
* the only way to raise the floor; a real terminal taller than the list keeps
|
|
53
|
+
* its own value, so this only ever kicks in when the default would break.
|
|
54
|
+
*/
|
|
55
|
+
export const selectSizing = (choiceCount) => {
|
|
56
|
+
const needed = choiceCount + 2;
|
|
57
|
+
const limit = choiceCount;
|
|
58
|
+
return {limit, rows: Math.max(process.stdout.rows || 0, needed)};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Template choices for the enquirer select. `name` must be the template key,
|
|
63
|
+
* not the label — it is what reaches createApp.
|
|
64
|
+
*/
|
|
65
|
+
export const templateChoices = () => Object.values(TEMPLATES).map((t) => ({
|
|
66
|
+
name: t.value,
|
|
67
|
+
message: c.greenBright(t.label),
|
|
68
|
+
hint: c.dim(`— ${t.description}`),
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
export const templatePrompt = () => {
|
|
72
|
+
const choices = templateChoices();
|
|
73
|
+
return {
|
|
74
|
+
type: 'select',
|
|
75
|
+
name: 'template',
|
|
76
|
+
message: c.blueBright('Choose a template'),
|
|
77
|
+
choices,
|
|
78
|
+
...selectSizing(choices.length),
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const appNamePrompt = () => ({
|
|
83
|
+
type: 'input',
|
|
84
|
+
name: 'appName',
|
|
85
|
+
message: c.blueBright('Enter the application name'),
|
|
86
|
+
initial: 'my-spyne-app',
|
|
87
|
+
validate: (value) => String(value).trim().length > 0 ||
|
|
88
|
+
'An app name is required.',
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
export const createProgressReporter = () => {
|
|
92
|
+
let spinner;
|
|
93
|
+
return ({status, message}) => {
|
|
94
|
+
if (status === 'start') {
|
|
95
|
+
spinner = ora({text: message, spinner: 'dots'}).start();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (!spinner) return;
|
|
99
|
+
if (status === 'success') spinner.succeed(message);
|
|
100
|
+
else if (status === 'fail') spinner.fail(message);
|
|
101
|
+
else spinner.info(message);
|
|
102
|
+
spinner = undefined;
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const isPositional = (def) => typeof def.positional === 'number';
|
|
107
|
+
|
|
108
|
+
const describeArg = (def) => {
|
|
109
|
+
const parts = [def.description];
|
|
110
|
+
if (def.enum) parts.push(`Values: ${def.enum.join(' | ')}.`);
|
|
111
|
+
if (def.default !== undefined && def.type !== 'boolean') {
|
|
112
|
+
parts.push(`Default: ${def.default}.`);
|
|
113
|
+
}
|
|
114
|
+
return parts.filter(Boolean).join(' ');
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const formatOption = ([name, def]) => {
|
|
118
|
+
// A boolean that defaults true is only actionable in its --no- form, so that
|
|
119
|
+
// is what help shows. `description` stays true to the positive form for
|
|
120
|
+
// programmatic consumers of the registry.
|
|
121
|
+
const showNegated = def.type === 'boolean' && def.default === true;
|
|
122
|
+
const flag = showNegated
|
|
123
|
+
? `--no-${name}`
|
|
124
|
+
: (def.alias ? `-${def.alias}, --${name}` : `--${name}`);
|
|
125
|
+
const description = showNegated
|
|
126
|
+
? (def.negatedDescription || `Skip: ${def.description}`)
|
|
127
|
+
: describeArg(def);
|
|
128
|
+
return ` ${flag.padEnd(20)} ${description}`;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const formatPositional = ([name, def]) =>
|
|
132
|
+
` ${`<${name}>`.padEnd(20)} ${describeArg(def)}`;
|
|
133
|
+
|
|
134
|
+
export const renderCommandHelp = (command) => {
|
|
135
|
+
const entries = Object.entries(command.args);
|
|
136
|
+
const positionals = entries.filter(([, def]) => isPositional(def));
|
|
137
|
+
const options = entries.filter(([, def]) => !isPositional(def));
|
|
138
|
+
const usage = [
|
|
139
|
+
'spyne-cli',
|
|
140
|
+
command.name,
|
|
141
|
+
...positionals.map(([name, def]) => def.required ? `<${name}>` : `[${name}]`),
|
|
142
|
+
options.length ? '[options]' : '',
|
|
143
|
+
].filter(Boolean).join(' ');
|
|
144
|
+
|
|
145
|
+
return [
|
|
146
|
+
'',
|
|
147
|
+
` ${c.bold(command.name)} — ${command.summary}`,
|
|
148
|
+
'',
|
|
149
|
+
' Usage:',
|
|
150
|
+
` ${usage}`,
|
|
151
|
+
...(positionals.length
|
|
152
|
+
? ['', ' Arguments:', ...positionals.map(formatPositional)]
|
|
153
|
+
: []),
|
|
154
|
+
...(options.length ? ['', ' Options:', ...options.map(formatOption)] : []),
|
|
155
|
+
'',
|
|
156
|
+
].join('\n');
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export const renderHelp = (version) => {
|
|
160
|
+
const templateList = Object.values(TEMPLATES).
|
|
161
|
+
map((t) => ` ${t.value.padEnd(12)} ${t.description}`).
|
|
162
|
+
join('\n');
|
|
163
|
+
|
|
164
|
+
return [
|
|
165
|
+
'',
|
|
166
|
+
` spyne-cli ${version}`,
|
|
167
|
+
'',
|
|
168
|
+
' Usage:',
|
|
169
|
+
' spyne-cli interactive picker',
|
|
170
|
+
' spyne-cli <command> [options]',
|
|
171
|
+
'',
|
|
172
|
+
' Commands:',
|
|
173
|
+
...commands.map((cmd) => ` ${cmd.name.padEnd(20)} ${cmd.summary}`),
|
|
174
|
+
'',
|
|
175
|
+
' Templates (create-app):',
|
|
176
|
+
templateList,
|
|
177
|
+
'',
|
|
178
|
+
' Global options:',
|
|
179
|
+
' --json machine-readable output, human chrome suppressed',
|
|
180
|
+
' -h, --help show help; pass a command name for its options',
|
|
181
|
+
' -v, --version print version',
|
|
182
|
+
'',
|
|
183
|
+
].join('\n');
|
|
184
|
+
};
|
|
@@ -0,0 +1,232 @@
|
|
|
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
|
+
|
|
9
|
+
// Template keys mirror the distinguishing suffix of their repo names:
|
|
10
|
+
// spynejs/application-starter and spynejs/application-shell.
|
|
11
|
+
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
|
+
shell: {
|
|
19
|
+
value: 'shell',
|
|
20
|
+
label: 'Application Shell',
|
|
21
|
+
description: 'pages, navigation, and UI components built in',
|
|
22
|
+
repo: 'https://github.com/spynejs/application-shell.git',
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Accepted silently; only the canonical values are documented.
|
|
27
|
+
const TEMPLATE_ALIASES = {
|
|
28
|
+
'app-shell': 'shell',
|
|
29
|
+
'application-shell': 'shell',
|
|
30
|
+
'application-starter': 'starter',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const DEFAULT_TEMPLATE = 'starter';
|
|
34
|
+
|
|
35
|
+
export const resolveTemplate = (name) => {
|
|
36
|
+
if (!name) return DEFAULT_TEMPLATE;
|
|
37
|
+
const key = String(name).toLowerCase();
|
|
38
|
+
return TEMPLATE_ALIASES[key] || key;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Clone URL for a template. An env override lets a fork — or this repo before
|
|
43
|
+
* the templates are published — scaffold from somewhere else. Read at call
|
|
44
|
+
* time so it is settable per invocation.
|
|
45
|
+
*
|
|
46
|
+
* SPYNE_CLI_STARTER_REPO, SPYNE_CLI_SHELL_REPO
|
|
47
|
+
*/
|
|
48
|
+
export const repoFor = (templateKey) => {
|
|
49
|
+
const envKey = `SPYNE_CLI_${templateKey.toUpperCase().replace(/-/g, '_')}_REPO`;
|
|
50
|
+
return process.env[envKey] || TEMPLATES[templateKey].repo;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const sanitizePackageName = (appName) => String(appName).
|
|
54
|
+
toLowerCase().
|
|
55
|
+
replace(/\s+/g, '-').
|
|
56
|
+
replace(/[^a-z0-9\-_.]/g, '') || 'my-spyne-app';
|
|
57
|
+
|
|
58
|
+
const run = (command, args, cwd) => new Promise((resolve) => {
|
|
59
|
+
const child = spawn(command, args, {cwd, stdio: 'ignore', shell: false});
|
|
60
|
+
child.on('close', (code) => resolve(code));
|
|
61
|
+
child.on('error', () => resolve(-1));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const updateIndexTitle = (targetDir, appName) => {
|
|
65
|
+
const indexFilePath = path.join(targetDir, 'src', 'index.tmpl.html');
|
|
66
|
+
if (!fs.existsSync(indexFilePath)) return;
|
|
67
|
+
const content = fs.readFileSync(indexFilePath, 'utf-8');
|
|
68
|
+
fs.writeFileSync(indexFilePath,
|
|
69
|
+
content.replace(/<title>.*<\/title>/, `<title>${appName}</title>`),
|
|
70
|
+
'utf-8');
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const updatePackageName = (targetDir, appName) => {
|
|
74
|
+
const pkgPath = path.join(targetDir, 'package.json');
|
|
75
|
+
if (!fs.existsSync(pkgPath)) return;
|
|
76
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
77
|
+
pkg.name = sanitizePackageName(appName);
|
|
78
|
+
pkg.version = '0.1.0';
|
|
79
|
+
delete pkg.repository;
|
|
80
|
+
delete pkg.homepage;
|
|
81
|
+
fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf-8');
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Rebrand a freshly cloned template as the user's app. Exported so the
|
|
86
|
+
* post-clone pipeline is testable without reaching the network.
|
|
87
|
+
*/
|
|
88
|
+
export const applyProjectIdentity = (targetDir, appName) => {
|
|
89
|
+
updateIndexTitle(targetDir, appName);
|
|
90
|
+
updatePackageName(targetDir, appName);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Detect whether cwd (or an ancestor) looks like a SpyneJS project.
|
|
95
|
+
* Used to guard `create-app` inside a project and module generation outside one.
|
|
96
|
+
*/
|
|
97
|
+
export const detectSpyneProject = (startDir = process.cwd()) => {
|
|
98
|
+
let dir = path.resolve(startDir);
|
|
99
|
+
while (true) {
|
|
100
|
+
const pkgPath = path.join(dir, 'package.json');
|
|
101
|
+
if (fs.existsSync(pkgPath)) {
|
|
102
|
+
try {
|
|
103
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
104
|
+
const deps = {...pkg.dependencies, ...pkg.devDependencies};
|
|
105
|
+
if (deps && deps.spyne) return {isSpyneProject: true, root: dir};
|
|
106
|
+
} catch {
|
|
107
|
+
// unreadable package.json — keep walking up
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const parent = path.dirname(dir);
|
|
111
|
+
if (parent === dir) return {isSpyneProject: false, root: null};
|
|
112
|
+
dir = parent;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Create a new SpyneJS application.
|
|
118
|
+
*
|
|
119
|
+
* @param {Object} args
|
|
120
|
+
* @param {string} args.appName
|
|
121
|
+
* @param {string} [args.template] 'starter' | 'shell'
|
|
122
|
+
* @param {boolean} [args.install] install dependencies (default true)
|
|
123
|
+
* @param {boolean} [args.git] initialise a fresh git repo (default true)
|
|
124
|
+
* @param {Function} [args.onProgress] ({step, status, message}) => void
|
|
125
|
+
* @returns {Promise<Object>} structured result
|
|
126
|
+
*/
|
|
127
|
+
export async function createApp(args = {}) {
|
|
128
|
+
const {appName, onProgress = () => {}} = args;
|
|
129
|
+
const install = args.install !== false;
|
|
130
|
+
const initGit = args.git !== false;
|
|
131
|
+
|
|
132
|
+
if (!appName) {
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
error: {code: 'MISSING_APP_NAME', message: 'An app name is required.'},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const templateKey = resolveTemplate(args.template);
|
|
140
|
+
const template = TEMPLATES[templateKey];
|
|
141
|
+
|
|
142
|
+
if (!template) {
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
error: {
|
|
146
|
+
code: 'UNKNOWN_TEMPLATE',
|
|
147
|
+
message: `Unknown template "${args.template}". Expected one of: ${Object.keys(
|
|
148
|
+
TEMPLATES).join(', ')}.`,
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const targetDir = path.resolve(process.cwd(), appName);
|
|
154
|
+
|
|
155
|
+
if (fs.existsSync(targetDir)) {
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
error: {
|
|
159
|
+
code: 'DIRECTORY_EXISTS',
|
|
160
|
+
message: `${targetDir} already exists.`,
|
|
161
|
+
},
|
|
162
|
+
path: targetDir,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
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.'});
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
error: {
|
|
178
|
+
code: 'CLONE_FAILED',
|
|
179
|
+
message: `Could not clone ${repo}: ${err.message}`,
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// No .git leakage from the template repo.
|
|
185
|
+
fs.rmSync(path.join(targetDir, '.git'), {recursive: true, force: true});
|
|
186
|
+
|
|
187
|
+
applyProjectIdentity(targetDir, appName);
|
|
188
|
+
|
|
189
|
+
let gitInitialized = false;
|
|
190
|
+
if (initGit) {
|
|
191
|
+
onProgress({step: 'git', status: 'start', message: 'Initialising git...'});
|
|
192
|
+
gitInitialized = await run('git', ['init', '--quiet'], targetDir) === 0;
|
|
193
|
+
onProgress({
|
|
194
|
+
step: 'git',
|
|
195
|
+
status: gitInitialized ? 'success' : 'skip',
|
|
196
|
+
message: gitInitialized ? 'Git repository initialised.' : 'Skipped git init.',
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let dependenciesInstalled = false;
|
|
201
|
+
if (install) {
|
|
202
|
+
onProgress({
|
|
203
|
+
step: 'install',
|
|
204
|
+
status: 'start',
|
|
205
|
+
message: 'Installing dependencies...',
|
|
206
|
+
});
|
|
207
|
+
dependenciesInstalled =
|
|
208
|
+
await run('npm', ['install', '--silent'], targetDir) === 0;
|
|
209
|
+
onProgress({
|
|
210
|
+
step: 'install',
|
|
211
|
+
status: dependenciesInstalled ? 'success' : 'fail',
|
|
212
|
+
message: dependenciesInstalled
|
|
213
|
+
? 'Dependencies installed.'
|
|
214
|
+
: 'Dependency install failed — run npm install manually.',
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
ok: true,
|
|
220
|
+
appName,
|
|
221
|
+
path: targetDir,
|
|
222
|
+
template: templateKey,
|
|
223
|
+
templateRepo: repo,
|
|
224
|
+
gitInitialized,
|
|
225
|
+
dependenciesInstalled,
|
|
226
|
+
nextSteps: [
|
|
227
|
+
`cd ${appName}`,
|
|
228
|
+
...(dependenciesInstalled ? [] : ['npm install']),
|
|
229
|
+
'npm start',
|
|
230
|
+
],
|
|
231
|
+
};
|
|
232
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Core module generation. No terminal output — returns structured results.
|
|
2
|
+
// The CLI layer is responsible for rendering these.
|
|
3
|
+
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import GenerateFileString from '../templates/generate-file-string.js';
|
|
6
|
+
import {detectSpyneProject} from './create-app.js';
|
|
7
|
+
import {getLocalFileDirectory, onSaveSpyneFileToDir} from '../utils/file-utils.js';
|
|
8
|
+
import {insertChannelStrings} from '../utils/insert-channel-strings-to-index-file.js';
|
|
9
|
+
import {Data} from '../spyne-template-prompts.js';
|
|
10
|
+
|
|
11
|
+
const {fileTypesArr} = Data;
|
|
12
|
+
|
|
13
|
+
const toPascalCase = (str) => String(str).
|
|
14
|
+
replace(/\.js$/, '').
|
|
15
|
+
split(/[-_\s]+/).
|
|
16
|
+
filter(Boolean).
|
|
17
|
+
map((s) => s.charAt(0).toUpperCase() + s.slice(1)).
|
|
18
|
+
join('');
|
|
19
|
+
|
|
20
|
+
const toConstantCase = (str) => {
|
|
21
|
+
const name = String(str).toLowerCase().startsWith('channel')
|
|
22
|
+
? str
|
|
23
|
+
: `Channel${str}`;
|
|
24
|
+
return name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').
|
|
25
|
+
replace(/[-\s]+/g, '_').
|
|
26
|
+
toUpperCase();
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const toMethodPrefix = (className) => {
|
|
30
|
+
const words = String(className).
|
|
31
|
+
replace(/([a-z0-9])([A-Z])/g, '$1 $2').
|
|
32
|
+
split(/[\s-_]+/).
|
|
33
|
+
filter(Boolean).
|
|
34
|
+
slice(0, 2);
|
|
35
|
+
const joined = words.map(
|
|
36
|
+
(w, i) => i === 0
|
|
37
|
+
? w.toLowerCase()
|
|
38
|
+
: w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join('');
|
|
39
|
+
return `${joined}$`;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const withTrailingSlash = (dir) => dir.endsWith('/') ? dir : `${dir}/`;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Fill in the same defaults the interactive prompts would have offered, so a
|
|
46
|
+
* flags-only invocation produces identical output to the picker.
|
|
47
|
+
*/
|
|
48
|
+
export const resolveModuleArgs = (args = {}) => {
|
|
49
|
+
const {fileType} = args;
|
|
50
|
+
const fileName = String(args.fileName).endsWith('.js')
|
|
51
|
+
? args.fileName
|
|
52
|
+
: `${args.fileName}.js`;
|
|
53
|
+
const className = args.className || toPascalCase(fileName);
|
|
54
|
+
const fileDirectory = withTrailingSlash(
|
|
55
|
+
args.fileDirectory || getLocalFileDirectory(fileType));
|
|
56
|
+
|
|
57
|
+
const resolved = {fileType, fileName, className, fileDirectory};
|
|
58
|
+
|
|
59
|
+
if (fileType === 'Channel') {
|
|
60
|
+
resolved.channelName = args.channelName || toConstantCase(className);
|
|
61
|
+
resolved.replayLastPayload = args.replayLastPayload === true ||
|
|
62
|
+
args.replayLastPayload === 'true';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (fileType === 'SpyneTrait') {
|
|
66
|
+
resolved.methodPrefix = args.methodPrefix || toMethodPrefix(className);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return resolved;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Generate a single Spyne module file.
|
|
74
|
+
*
|
|
75
|
+
* @param {Object} args
|
|
76
|
+
* @returns {Promise<Object>} structured result — never throws for expected
|
|
77
|
+
* failures (missing name, existing file); those surface on the result.
|
|
78
|
+
*/
|
|
79
|
+
export async function generateModule(args = {}) {
|
|
80
|
+
if (!fileTypesArr.includes(args.fileType)) {
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
error: {
|
|
84
|
+
code: 'INVALID_FILE_TYPE',
|
|
85
|
+
message: `Unknown file type "${args.fileType}". Expected one of: ${fileTypesArr.join(
|
|
86
|
+
', ')}.`,
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!args.fileName) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
error: {code: 'MISSING_FILE_NAME', message: 'A file name is required.'},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Modules generate into an existing project; an app does not exist yet.
|
|
99
|
+
if (args.requireProject !== false && !detectSpyneProject().isSpyneProject) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
error: {
|
|
103
|
+
code: 'NOT_IN_SPYNE_PROJECT',
|
|
104
|
+
message: 'Run this inside a SpyneJS project. To start a new one: spyne-cli create-app <app-name>',
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const resolved = resolveModuleArgs(args);
|
|
110
|
+
const {fileType, fileName, className, fileDirectory} = resolved;
|
|
111
|
+
const {fileString} = new GenerateFileString(fileType, resolved);
|
|
112
|
+
const saved = onSaveSpyneFileToDir(fileString, fileName, fileDirectory);
|
|
113
|
+
|
|
114
|
+
// The CLI prints the generated source for copy-paste when a write fails, so
|
|
115
|
+
// failure results carry the same payload a success would.
|
|
116
|
+
if (!saved.fileHasSaved) {
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
error: {
|
|
120
|
+
code: saved.fileExists ? 'FILE_ALREADY_EXISTS' : 'WRITE_FAILED',
|
|
121
|
+
message: saved.fileExists
|
|
122
|
+
? `${saved.fileDirPath} already exists.`
|
|
123
|
+
: `Could not write ${saved.fileDirPath}.`,
|
|
124
|
+
},
|
|
125
|
+
...resolved,
|
|
126
|
+
filePath: saved.fileDirPath,
|
|
127
|
+
errorType: saved.errorType,
|
|
128
|
+
channelRegistered: false,
|
|
129
|
+
fileString,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let channelRegistered = false;
|
|
134
|
+
if (fileType === 'Channel') {
|
|
135
|
+
channelRegistered = insertChannelStrings(className, fileName).fileHasSaved;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
ok: true,
|
|
140
|
+
...resolved,
|
|
141
|
+
filePath: saved.fileDirPath,
|
|
142
|
+
relativePath: path.relative(process.cwd(), saved.fileDirPath),
|
|
143
|
+
channelRegistered,
|
|
144
|
+
fileString,
|
|
145
|
+
};
|
|
146
|
+
}
|