spyne-cli 0.6.7 → 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.
Files changed (93) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +90 -38
  3. package/index.js +123 -21
  4. package/package.json +24 -6
  5. package/src/cli/args.js +197 -0
  6. package/src/cli/render.js +184 -0
  7. package/src/core/create-app.js +232 -0
  8. package/src/core/generate-module.js +146 -0
  9. package/src/registry.js +174 -0
  10. package/src/spyne-file-prompt.js +77 -30
  11. package/src/templates/generate-prompt-input-fields.js +1 -1
  12. package/src/ui.js +5 -1
  13. package/lib/ansi.js +0 -116
  14. package/lib/combos.js +0 -75
  15. package/lib/completer.js +0 -52
  16. package/lib/interpolate.js +0 -266
  17. package/lib/keypress.js +0 -243
  18. package/lib/placeholder.js +0 -63
  19. package/lib/prompt.js +0 -485
  20. package/lib/prompts/autocomplete.js +0 -113
  21. package/lib/prompts/basicauth.js +0 -41
  22. package/lib/prompts/confirm.js +0 -13
  23. package/lib/prompts/editable.js +0 -136
  24. package/lib/prompts/form.js +0 -196
  25. package/lib/prompts/index.js +0 -28
  26. package/lib/prompts/input.js +0 -55
  27. package/lib/prompts/invisible.js +0 -11
  28. package/lib/prompts/list.js +0 -36
  29. package/lib/prompts/multiselect.js +0 -11
  30. package/lib/prompts/numeral.js +0 -1
  31. package/lib/prompts/password.js +0 -18
  32. package/lib/prompts/quiz.js +0 -37
  33. package/lib/prompts/scale.js +0 -237
  34. package/lib/prompts/select.js +0 -139
  35. package/lib/prompts/snippet.js +0 -185
  36. package/lib/prompts/sort.js +0 -37
  37. package/lib/prompts/survey.js +0 -163
  38. package/lib/prompts/text.js +0 -1
  39. package/lib/prompts/toggle.js +0 -109
  40. package/lib/render.js +0 -33
  41. package/lib/roles.js +0 -46
  42. package/lib/state.js +0 -69
  43. package/lib/styles.js +0 -144
  44. package/lib/symbols.js +0 -66
  45. package/lib/theme.js +0 -11
  46. package/lib/timer.js +0 -38
  47. package/lib/types/array.js +0 -658
  48. package/lib/types/auth.js +0 -29
  49. package/lib/types/boolean.js +0 -88
  50. package/lib/types/index.js +0 -7
  51. package/lib/types/number.js +0 -86
  52. package/lib/types/string.js +0 -185
  53. package/lib/utils.js +0 -268
  54. package/mocha.conf.cjs +0 -16
  55. package/src/app/channels/.gitkeep +0 -0
  56. package/src/app/channels/channel-custom.js +0 -1
  57. package/src/app/components/.gitkeep +0 -0
  58. package/src/app/traits/.gitkeep +0 -0
  59. package/src/hello-world-app-source/README.md +0 -15
  60. package/src/hello-world-app-source/apache-htaccess +0 -20
  61. package/src/hello-world-app-source/karma.conf.js +0 -75
  62. package/src/hello-world-app-source/package.json +0 -50
  63. package/src/hello-world-app-source/src/app/app-view.js +0 -38
  64. package/src/hello-world-app-source/src/app/channels/.gitkeep +0 -0
  65. package/src/hello-world-app-source/src/app/components/.gitkeep +0 -0
  66. package/src/hello-world-app-source/src/app/traits/.gitkeep +0 -0
  67. package/src/hello-world-app-source/src/index.js +0 -16
  68. package/src/hello-world-app-source/src/index.tmpl.html +0 -12
  69. package/src/hello-world-app-source/src/scss/01-vendors/normalize.css +0 -349
  70. package/src/hello-world-app-source/src/scss/02-variables/mixins.scss +0 -0
  71. package/src/hello-world-app-source/src/scss/03-components/.gitkeep +0 -0
  72. package/src/hello-world-app-source/src/scss/main.scss +0 -15
  73. package/src/hello-world-app-source/src/static/data/.gitkeep +0 -0
  74. package/src/hello-world-app-source/src/static/fonts/.gitkeep +0 -0
  75. package/src/hello-world-app-source/src/static/imgs/.gitkeep +0 -0
  76. package/src/hello-world-app-source/src/tests/unit-tests/index.test.js +0 -10
  77. package/src/hello-world-app-source/webpack.config.js +0 -178
  78. package/src/spyne-app-create.js +0 -209
  79. package/src/spyne-app-creator.js +0 -119
  80. package/src/spyne-starter-app-create.js +0 -147
  81. package/tests/create-spyne-app.test.js +0 -63
  82. package/tests/generate-file-prompt.test.js +0 -45
  83. package/tests/generate-file-string.test.js +0 -66
  84. package/tests/generate-prompt-input-fields-methods.test.js +0 -177
  85. package/tests/generate-prompt-input-fields.test.js +0 -179
  86. package/tests/generate-prompt-input-object.test.js +0 -39
  87. package/tests/generate-prompt-output.test.js +0 -58
  88. package/tests/index.test.js +0 -11
  89. package/tests/mocks/answers.js +0 -33
  90. package/tests/mocks/answers.json +0 -33
  91. package/tests/mocks/enquirer-data.js +0 -411
  92. package/tests/mocks/enquirer-data.json +0 -409
  93. 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
+ }