@octanejs/cli 0.0.0 → 0.0.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/package.json +1 -1
- package/src/commands/create.js +206 -0
- package/src/commands/init/index.js +282 -91
- package/src/commands/init/templates.js +153 -32
- package/src/data/octane-data.json +200 -50
- package/src/kernel/install.js +7 -0
- package/src/kernel/registry.js +5 -0
- package/src/kernel/ui.js +36 -0
package/package.json
CHANGED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { defineCommand } from '../kernel/command.js';
|
|
4
|
+
import { detectProject } from '../kernel/project.js';
|
|
5
|
+
import { CliError, EXIT } from '../kernel/errors.js';
|
|
6
|
+
import { PACKAGE_MANAGERS } from '../kernel/install.js';
|
|
7
|
+
import { applyInit } from './init/index.js';
|
|
8
|
+
import { MODES } from './init/templates.js';
|
|
9
|
+
|
|
10
|
+
/** What the project is called when someone accepts the prompt as offered. */
|
|
11
|
+
export const DEFAULT_DIRECTORY = 'octane-app';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Which package manager ran this command.
|
|
15
|
+
*
|
|
16
|
+
* `npm create`, `pnpm create` and `yarn create` all set `npm_config_user_agent`
|
|
17
|
+
* to something starting `name/version`. It is the only signal there is: the
|
|
18
|
+
* directory is new, so there is no lockfile to detect, and without this a
|
|
19
|
+
* `pnpm create` would be handed to npm and answered with the wrong lockfile.
|
|
20
|
+
*
|
|
21
|
+
* @param {NodeJS.ProcessEnv} env
|
|
22
|
+
* @returns {string | undefined}
|
|
23
|
+
*/
|
|
24
|
+
export function invokedThrough(env) {
|
|
25
|
+
const name = (env.npm_config_user_agent ?? '').split('/')[0];
|
|
26
|
+
return PACKAGE_MANAGERS.includes(name) ? name : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Write a directory name so the shell the next step gets pasted into reads it
|
|
31
|
+
* as a name. It is whatever someone typed at the prompt, and this command
|
|
32
|
+
* accepts spaces and leading dashes in it.
|
|
33
|
+
*
|
|
34
|
+
* A leading dash needs more than quoting: `cd` parses its argument for options
|
|
35
|
+
* first, so `cd '-app'` fails the same way `cd -app` does, and a lone `cd -`
|
|
36
|
+
* silently goes somewhere else entirely. `./` in front makes it a path again.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} value
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
export function shellArgument(value) {
|
|
42
|
+
const name = value.startsWith('-') ? `./${value}` : value;
|
|
43
|
+
return /^[\w./@-]+$/.test(name) ? name : `'${name.replaceAll("'", String.raw`'\''`)}'`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* npm rejects a name with uppercase letters or most punctuation, and the
|
|
48
|
+
* directory someone types is not bound by that.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} directory
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
export function packageNameFor(directory) {
|
|
54
|
+
const name = path
|
|
55
|
+
.basename(directory)
|
|
56
|
+
.toLowerCase()
|
|
57
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
58
|
+
.replace(/^[._-]+|-+$/g, '');
|
|
59
|
+
return name === '' ? DEFAULT_DIRECTORY : name;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default defineCommand({
|
|
63
|
+
// The whole point is to run where no project exists yet.
|
|
64
|
+
requiresProject: false,
|
|
65
|
+
description:
|
|
66
|
+
'Create an Octane app in a new directory: the files, the config, and the dependencies.\n' +
|
|
67
|
+
'This is what `npm create octane` runs.',
|
|
68
|
+
positionals: [{ name: 'directory', description: 'Directory to create the app in.' }],
|
|
69
|
+
flags: {
|
|
70
|
+
template: {
|
|
71
|
+
type: 'string',
|
|
72
|
+
short: 't',
|
|
73
|
+
choices: Object.keys(MODES),
|
|
74
|
+
placeholder: '<name>',
|
|
75
|
+
description: 'spa for a client-only app, fullstack for routing and SSR.',
|
|
76
|
+
},
|
|
77
|
+
install: {
|
|
78
|
+
type: 'boolean',
|
|
79
|
+
default: true,
|
|
80
|
+
description: 'Install dependencies (--no-install skips).',
|
|
81
|
+
},
|
|
82
|
+
'package-manager': {
|
|
83
|
+
type: 'string',
|
|
84
|
+
choices: PACKAGE_MANAGERS,
|
|
85
|
+
placeholder: '<name>',
|
|
86
|
+
description: 'Install with this one instead of the one that ran the command.',
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
async run(ctx, input) {
|
|
91
|
+
if (input.positionals.length > 1) {
|
|
92
|
+
throw new CliError(`Unexpected argument ${input.positionals[1]}.`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const directory =
|
|
96
|
+
input.positionals[0] ??
|
|
97
|
+
(await ctx.ui.text({
|
|
98
|
+
message: 'Project name',
|
|
99
|
+
flag: '<directory>',
|
|
100
|
+
placeholder: DEFAULT_DIRECTORY,
|
|
101
|
+
initial: DEFAULT_DIRECTORY,
|
|
102
|
+
}));
|
|
103
|
+
|
|
104
|
+
const target = path.resolve(ctx.cwd, directory);
|
|
105
|
+
const existed = existsSync(target);
|
|
106
|
+
// A name that lands on a file is a mistake worth saying out loud. Reading
|
|
107
|
+
// it as a directory throws ENOTDIR, and a raw stack tells someone who
|
|
108
|
+
// mistyped a name nothing about what to do next.
|
|
109
|
+
if (existed && !statSync(target).isDirectory()) {
|
|
110
|
+
throw new CliError(`${directory} already exists and is not a directory.`);
|
|
111
|
+
}
|
|
112
|
+
// A repository with nothing in it is not a project. `mkdir app && cd app &&
|
|
113
|
+
// git init` is a common enough way to start that refusing it would leave
|
|
114
|
+
// this check protecting work that is not there, which is all it is for.
|
|
115
|
+
if (existed && readdirSync(target).some((entry) => entry !== '.git')) {
|
|
116
|
+
// Emptying someone's directory is not this command's call to make.
|
|
117
|
+
throw new CliError(`${directory} already exists and is not empty.`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// `mkdirSync` with `recursive` makes every missing parent too, so a name
|
|
121
|
+
// like `apps/web` creates two directories. Undoing only the leaf would
|
|
122
|
+
// leave an empty tree standing, which is not nothing. Leaf first, and only
|
|
123
|
+
// the ones that were not already there.
|
|
124
|
+
/** @type {string[]} */
|
|
125
|
+
const made = [];
|
|
126
|
+
for (let dir = target; !existsSync(dir) && path.dirname(dir) !== dir; dir = path.dirname(dir)) {
|
|
127
|
+
made.push(dir);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
mkdirSync(target, { recursive: true });
|
|
131
|
+
writeFileSync(
|
|
132
|
+
path.join(target, 'package.json'),
|
|
133
|
+
`${JSON.stringify(
|
|
134
|
+
{ name: packageNameFor(target), private: true, version: '0.0.0', type: 'module' },
|
|
135
|
+
null,
|
|
136
|
+
2,
|
|
137
|
+
)}\n`,
|
|
138
|
+
);
|
|
139
|
+
// What sits there before the scaffold runs: the manifest just written, and
|
|
140
|
+
// a `.git` if the directory came with one. The cleanup below asks what was
|
|
141
|
+
// added rather than counting entries, so it cannot disagree with the rule
|
|
142
|
+
// above about what an empty directory may already hold.
|
|
143
|
+
const before = new Set(readdirSync(target));
|
|
144
|
+
|
|
145
|
+
// Whoever ran `create` owns this project's tooling, and there is no
|
|
146
|
+
// lockfile to work that out from in a directory this new.
|
|
147
|
+
const packageManager = input.flags['package-manager'] ?? invokedThrough(ctx.env);
|
|
148
|
+
|
|
149
|
+
let result;
|
|
150
|
+
let wrote = false;
|
|
151
|
+
try {
|
|
152
|
+
result = await applyInit(ctx, detectProject(target), {
|
|
153
|
+
title: 'octane create',
|
|
154
|
+
mode: input.flags.template,
|
|
155
|
+
modeFlag: '--template',
|
|
156
|
+
install: input.flags.install,
|
|
157
|
+
packageManager,
|
|
158
|
+
});
|
|
159
|
+
} finally {
|
|
160
|
+
// Nothing written means no project, and a lone package.json is not one.
|
|
161
|
+
// Declining the confirm is a normal answer and a failure is not, but
|
|
162
|
+
// both leave the same nothing behind, and the directory left over would
|
|
163
|
+
// make the obvious retry fail as already occupied. Only what this
|
|
164
|
+
// command made goes, so a directory that was already there survives
|
|
165
|
+
// with its `.git`, which is also why this cannot be inferred afterwards
|
|
166
|
+
// from whether the directory is still standing.
|
|
167
|
+
wrote = readdirSync(target).some((entry) => !before.has(entry));
|
|
168
|
+
if (!wrote) {
|
|
169
|
+
rmSync(path.join(target, 'package.json'), { force: true });
|
|
170
|
+
for (const dir of made) {
|
|
171
|
+
// Anything left in one of these by now is not ours to remove.
|
|
172
|
+
if (readdirSync(dir).length > 0) break;
|
|
173
|
+
rmSync(dir, { recursive: true, force: true });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!wrote) {
|
|
179
|
+
ctx.ui.log('');
|
|
180
|
+
ctx.ui.log('Nothing was created.');
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
if (result.exitCode !== undefined && result.exitCode !== EXIT.OK) return result;
|
|
184
|
+
|
|
185
|
+
// Spelled for the manager they used, since telling a pnpm user to run npm
|
|
186
|
+
// is how a project ends up with two lockfiles. `run` is the one spelling
|
|
187
|
+
// every manager accepts.
|
|
188
|
+
const runner = packageManager ?? 'npm';
|
|
189
|
+
// Skipping the install leaves the manifest with no dependencies in it at
|
|
190
|
+
// all, because dependencies are recorded by handing them to the package
|
|
191
|
+
// manager rather than by writing ranges. A bare `install` would resolve an
|
|
192
|
+
// empty manifest, succeed at nothing, and leave `run dev` to fail on a
|
|
193
|
+
// missing octane. The exact packages were printed above, so send them
|
|
194
|
+
// there rather than spelling a command that does not do the job.
|
|
195
|
+
ctx.ui.log('');
|
|
196
|
+
ctx.ui.note('Next', [
|
|
197
|
+
`cd ${shellArgument(directory)}`,
|
|
198
|
+
...(input.flags.install === false
|
|
199
|
+
? ['install the packages listed under "Do this by hand"']
|
|
200
|
+
: []),
|
|
201
|
+
`${runner} run dev`,
|
|
202
|
+
]);
|
|
203
|
+
|
|
204
|
+
return { json: { ...result.json, directory, packageManager: runner } };
|
|
205
|
+
},
|
|
206
|
+
});
|
|
@@ -1,16 +1,21 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { defineCommand } from '../../kernel/command.js';
|
|
4
4
|
import { setCompilerOption } from '../../kernel/edit.js';
|
|
5
5
|
import { CliError, EXIT } from '../../kernel/errors.js';
|
|
6
|
-
import { installPackages } from '../../kernel/install.js';
|
|
6
|
+
import { PACKAGE_MANAGERS, installPackages } from '../../kernel/install.js';
|
|
7
7
|
import {
|
|
8
8
|
MODES,
|
|
9
|
+
PRETTIER_CONFIG_FILES,
|
|
9
10
|
SCRIPTS,
|
|
11
|
+
SSR_MARKERS,
|
|
10
12
|
VITE_SCRIPTS,
|
|
11
13
|
appComponent,
|
|
14
|
+
clientEntry,
|
|
15
|
+
indexHtml,
|
|
12
16
|
integrationFor,
|
|
13
17
|
octaneConfig,
|
|
18
|
+
prettierConfig,
|
|
14
19
|
tsconfig,
|
|
15
20
|
viteConfig,
|
|
16
21
|
} from './templates.js';
|
|
@@ -45,6 +50,67 @@ const writeFile = (file, body) => () => {
|
|
|
45
50
|
writeFileSync(file, body);
|
|
46
51
|
};
|
|
47
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Where a project keeps its Prettier settings, and what they say, so a caller
|
|
55
|
+
* can tell whether the plugin is already registered there.
|
|
56
|
+
*
|
|
57
|
+
* The `prettier` field of package.json holds either the settings themselves or
|
|
58
|
+
* a path to them, and the two have to be read differently. `declared` is null
|
|
59
|
+
* when the settings are real but out of reach: the field may name a shareable
|
|
60
|
+
* config, and resolving a bare module specifier is the package manager's job.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} root
|
|
63
|
+
* @param {string[]} files config files found in the project, in Prettier's search order
|
|
64
|
+
* @param {unknown} fromManifest the `prettier` field of package.json
|
|
65
|
+
* @returns {{ where: string, declared: string | null }}
|
|
66
|
+
*/
|
|
67
|
+
function prettierSettings(root, files, fromManifest) {
|
|
68
|
+
// package.json comes first in Prettier's own search order, ahead of every
|
|
69
|
+
// config file, so when a project has both it is the field that decides. Read
|
|
70
|
+
// the file instead and init can approve a config Prettier never loads.
|
|
71
|
+
if (fromManifest === undefined) {
|
|
72
|
+
return { where: files[0], declared: readFileSync(path.join(root, files[0]), 'utf8') };
|
|
73
|
+
}
|
|
74
|
+
if (typeof fromManifest !== 'string') {
|
|
75
|
+
return {
|
|
76
|
+
where: 'the prettier field of package.json',
|
|
77
|
+
declared: JSON.stringify(fromManifest),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
// A path is relative to the package.json naming it. Anything that does not
|
|
81
|
+
// land on a file is a specifier for the resolver, not a path.
|
|
82
|
+
const file = path.resolve(root, fromManifest);
|
|
83
|
+
return existsSync(file) && statSync(file).isFile()
|
|
84
|
+
? { where: path.relative(root, file), declared: readFileSync(file, 'utf8') }
|
|
85
|
+
: { where: fromManifest, declared: null };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The TypeScript range `tsrx-tsc` will start under, read from the plugin that
|
|
90
|
+
* ships it once that plugin is on disk.
|
|
91
|
+
*
|
|
92
|
+
* It is a required peer there, not a dependency, so nothing carries a compiler
|
|
93
|
+
* of its own. npm and pnpm install a required peer themselves; yarn does not,
|
|
94
|
+
* and leaves a project whose `typecheck` script dies on `Cannot find module
|
|
95
|
+
* 'typescript'`. Naming the package with no range is not the fix either: that
|
|
96
|
+
* takes the newest major, which `tsrx-tsc` cannot start under. So the range
|
|
97
|
+
* comes from the toolchain's own declaration, never from a guess here.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} root
|
|
100
|
+
* @returns {string | null} null when the plugin is not installed yet
|
|
101
|
+
*/
|
|
102
|
+
function toolchainTypescriptRange(root) {
|
|
103
|
+
try {
|
|
104
|
+
const manifest = JSON.parse(
|
|
105
|
+
readFileSync(path.join(root, 'node_modules/@tsrx/typescript-plugin/package.json'), 'utf8'),
|
|
106
|
+
);
|
|
107
|
+
const range = manifest.peerDependencies?.typescript;
|
|
108
|
+
return typeof range === 'string' && range !== '' ? range : null;
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
48
114
|
/**
|
|
49
115
|
* @param {import('../../kernel/project.js').Project} project
|
|
50
116
|
* @param {keyof typeof MODES} mode
|
|
@@ -63,7 +129,7 @@ function plan(project, mode, integration) {
|
|
|
63
129
|
changes.push({
|
|
64
130
|
file: 'tsconfig.json',
|
|
65
131
|
summary: 'create, configured for .tsrx',
|
|
66
|
-
apply: writeFile(at('tsconfig.json'),
|
|
132
|
+
apply: writeFile(at('tsconfig.json'), tsconfig),
|
|
67
133
|
});
|
|
68
134
|
} else {
|
|
69
135
|
const file = project.tsconfig.path;
|
|
@@ -96,6 +162,30 @@ function plan(project, mode, integration) {
|
|
|
96
162
|
}
|
|
97
163
|
}
|
|
98
164
|
|
|
165
|
+
// Formatting is nobody's bundler's business, so this runs whichever one the
|
|
166
|
+
// project uses. Without the plugin Prettier cannot parse `.tsrx` at all.
|
|
167
|
+
const prettierFiles = PRETTIER_CONFIG_FILES.filter((file) => existsSync(at(file)));
|
|
168
|
+
if (prettierFiles.length === 0 && project.manifest.prettier === undefined) {
|
|
169
|
+
changes.push({
|
|
170
|
+
file: '.prettierrc',
|
|
171
|
+
summary: 'create, registering @tsrx/prettier-plugin',
|
|
172
|
+
apply: writeFile(at('.prettierrc'), prettierConfig),
|
|
173
|
+
});
|
|
174
|
+
} else {
|
|
175
|
+
// Their config is theirs, and it can be JSON, YAML, or JavaScript. Reading
|
|
176
|
+
// it as text is enough to tell whether the plugin is already named, which
|
|
177
|
+
// is all that decides between staying quiet and stating the edit.
|
|
178
|
+
const { where, declared } = prettierSettings(root, prettierFiles, project.manifest.prettier);
|
|
179
|
+
if (declared === null) {
|
|
180
|
+
// Saying "add it" would be a guess, and so would saying nothing.
|
|
181
|
+
manual.push(
|
|
182
|
+
`Prettier settings come from ${where}, which this command cannot read: check that "@tsrx/prettier-plugin" is among its plugins.`,
|
|
183
|
+
);
|
|
184
|
+
} else if (!declared.includes('@tsrx/prettier-plugin')) {
|
|
185
|
+
manual.push(`Add "@tsrx/prettier-plugin" to the Prettier plugins in ${where}.`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
99
189
|
const bundlerConfigPath = project.bundlerConfigPath ?? at('vite.config.ts');
|
|
100
190
|
if (!project.bundlerConfigPath) {
|
|
101
191
|
changes.push({
|
|
@@ -127,18 +217,63 @@ function plan(project, mode, integration) {
|
|
|
127
217
|
);
|
|
128
218
|
}
|
|
129
219
|
|
|
130
|
-
if (
|
|
131
|
-
if (!project.octaneConfigPath) {
|
|
220
|
+
if (scaffoldsVite) {
|
|
221
|
+
if (mode === 'fullstack' && !project.octaneConfigPath) {
|
|
132
222
|
changes.push({
|
|
133
223
|
file: 'octane.config.ts',
|
|
134
224
|
summary: 'create, with one route at /',
|
|
135
225
|
apply: writeFile(at('octane.config.ts'), octaneConfig),
|
|
136
226
|
});
|
|
137
227
|
}
|
|
138
|
-
|
|
228
|
+
|
|
229
|
+
// Without a shell and an entry there is nothing to serve: `vite` has no
|
|
230
|
+
// page to open and a fullstack build fails outright, because the plugin
|
|
231
|
+
// requires an index.html once octane.config.ts declares routes. Each file
|
|
232
|
+
// is written only when it is absent, so an existing project keeps its own.
|
|
233
|
+
const writesShell = !existsSync(at('index.html'));
|
|
234
|
+
// spa is plain Vite, so the page loads the entry itself and the two files
|
|
235
|
+
// only make sense together. fullstack needs no entry here: the plugin
|
|
236
|
+
// injects hydration, in dev through its middleware and in production at
|
|
237
|
+
// transformIndexHtml.
|
|
238
|
+
// Guarded like every other file here: a project can have lost its
|
|
239
|
+
// index.html and still own the entry that used to be loaded from it.
|
|
240
|
+
const writesClientEntry = writesShell && mode === 'spa' && !existsSync(at('src/main.ts'));
|
|
241
|
+
if (writesShell) {
|
|
242
|
+
changes.push({
|
|
243
|
+
file: 'index.html',
|
|
244
|
+
summary:
|
|
245
|
+
mode === 'fullstack' ? 'create, carrying the SSR markers' : 'create, loading src/main.ts',
|
|
246
|
+
apply: writeFile(at('index.html'), indexHtml(mode)),
|
|
247
|
+
});
|
|
248
|
+
if (writesClientEntry) {
|
|
249
|
+
changes.push({
|
|
250
|
+
file: 'src/main.ts',
|
|
251
|
+
summary: 'create, mounting App into #root',
|
|
252
|
+
apply: writeFile(at('src/main.ts'), clientEntry),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
} else if (mode === 'fullstack') {
|
|
256
|
+
// Their index.html is theirs to edit, and splicing markers into
|
|
257
|
+
// arbitrary HTML is the same guesswork as rewriting a bundler config.
|
|
258
|
+
const html = readFileSync(at('index.html'), 'utf8');
|
|
259
|
+
const missing = SSR_MARKERS.filter((marker) => !html.includes(marker));
|
|
260
|
+
if (missing.length > 0) {
|
|
261
|
+
manual.push(`In index.html, add ${missing.join(' and ')}: SSR renders into those markers.`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// fullstack always needs it, because octane.config.ts names it as the
|
|
266
|
+
// route entry. spa needs it only alongside the entry that imports it, so a
|
|
267
|
+
// project with its own page does not collect an orphan component. That is
|
|
268
|
+
// the entry this run writes, not the shell: keeping someone's src/main.ts
|
|
269
|
+
// means keeping whatever it imports, which is not this file.
|
|
270
|
+
if ((mode === 'fullstack' || writesClientEntry) && !existsSync(at('src/App.tsrx'))) {
|
|
139
271
|
changes.push({
|
|
140
272
|
file: 'src/App.tsrx',
|
|
141
|
-
summary:
|
|
273
|
+
summary:
|
|
274
|
+
mode === 'fullstack'
|
|
275
|
+
? 'create the route entry referenced by octane.config.ts'
|
|
276
|
+
: 'create the entry component',
|
|
142
277
|
apply: writeFile(at('src/App.tsrx'), appComponent),
|
|
143
278
|
});
|
|
144
279
|
}
|
|
@@ -162,6 +297,134 @@ function plan(project, mode, integration) {
|
|
|
162
297
|
return { changes, manual };
|
|
163
298
|
}
|
|
164
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Wire Octane into the project at `project.root`: plan the files, report them,
|
|
302
|
+
* apply them, install what is missing.
|
|
303
|
+
*
|
|
304
|
+
* Shared with `octane create`, which is the same work against a directory it
|
|
305
|
+
* has just made rather than one that was already there. Driving this from
|
|
306
|
+
* outside, by re-entering `main` with an argv, meant a second copy of every
|
|
307
|
+
* question about whether the CLI can prompt; one `ctx` removes that.
|
|
308
|
+
*
|
|
309
|
+
* @param {import('../../kernel/context.js').Ctx} ctx
|
|
310
|
+
* @param {import('../../kernel/project.js').Project} project
|
|
311
|
+
* @param {{ title: string, mode?: string, modeFlag?: string, install?: boolean,
|
|
312
|
+
* packageManager?: string }} options
|
|
313
|
+
* @returns {Promise<{ exitCode?: number, json: Record<string, unknown> }>}
|
|
314
|
+
*/
|
|
315
|
+
export async function applyInit(ctx, project, options) {
|
|
316
|
+
const mode = /** @type {keyof typeof MODES} */ (
|
|
317
|
+
options.mode ??
|
|
318
|
+
(await ctx.ui.select({
|
|
319
|
+
message: 'What are you building?',
|
|
320
|
+
flag: options.modeFlag ?? '--mode',
|
|
321
|
+
initial: 'spa',
|
|
322
|
+
options: Object.entries(MODES).map(([value, entry]) => ({
|
|
323
|
+
value,
|
|
324
|
+
label: entry.label,
|
|
325
|
+
hint: entry.hint,
|
|
326
|
+
})),
|
|
327
|
+
}))
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
// Detection reads a lockfile, and a project that has never been installed
|
|
331
|
+
// has none to read. A caller that knows which manager it was invoked
|
|
332
|
+
// through can say so, rather than watching the fallback write the wrong
|
|
333
|
+
// kind of lockfile into a brand-new project.
|
|
334
|
+
const installing =
|
|
335
|
+
options.packageManager === undefined
|
|
336
|
+
? project
|
|
337
|
+
: {
|
|
338
|
+
...project,
|
|
339
|
+
packageManager: /** @type {typeof project.packageManager} */ (options.packageManager),
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const integration = integrationFor(project.bundler, mode);
|
|
343
|
+
const { changes, manual } = plan(project, mode, integration);
|
|
344
|
+
const declared = project.declaredDependencies;
|
|
345
|
+
const dependencies = integration.dependencies.filter((name) => !declared[name]);
|
|
346
|
+
const devDependencies = integration.devDependencies.filter((name) => !declared[name]);
|
|
347
|
+
|
|
348
|
+
ctx.ui.intro(options.title);
|
|
349
|
+
|
|
350
|
+
if (changes.length === 0 && dependencies.length === 0 && devDependencies.length === 0) {
|
|
351
|
+
ctx.ui.outro('Already set up. Run `octane doctor` to confirm.');
|
|
352
|
+
return { json: { ok: true, mode, changes: [], installed: [] } };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
ctx.ui.note(
|
|
356
|
+
ctx.dryRun ? 'Would change' : 'Will change',
|
|
357
|
+
changes.length > 0
|
|
358
|
+
? changes.map((c) => `${c.file} ${ctx.ui.colors.dim(c.summary)}`)
|
|
359
|
+
: ['nothing'],
|
|
360
|
+
);
|
|
361
|
+
if (dependencies.length + devDependencies.length > 0) {
|
|
362
|
+
ctx.ui.note('Will install', [
|
|
363
|
+
...dependencies,
|
|
364
|
+
...devDependencies.map((name) => `${name} (dev)`),
|
|
365
|
+
]);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (ctx.dryRun) {
|
|
369
|
+
return { json: { ok: true, dryRun: true, mode, changes: changes.map((c) => c.file), manual } };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const confirmed = await ctx.ui.confirm({ message: 'Apply?', flag: '--yes', initial: true });
|
|
373
|
+
if (!confirmed)
|
|
374
|
+
return { exitCode: EXIT.OK, json: { ok: true, mode, changes: [], installed: [] } };
|
|
375
|
+
|
|
376
|
+
for (const change of changes) change.apply();
|
|
377
|
+
|
|
378
|
+
/** @type {string[]} */
|
|
379
|
+
const installed = [];
|
|
380
|
+
if (options.install !== false && dependencies.length + devDependencies.length > 0) {
|
|
381
|
+
const spinner = ctx.ui.spinner(`Installing with ${installing.packageManager ?? 'npm'}`);
|
|
382
|
+
try {
|
|
383
|
+
for (const [names, dev] of [
|
|
384
|
+
[dependencies, false],
|
|
385
|
+
[devDependencies, true],
|
|
386
|
+
]) {
|
|
387
|
+
if (/** @type {string[]} */ (names).length === 0) continue;
|
|
388
|
+
await installPackages(ctx, installing, /** @type {string[]} */ (names), {
|
|
389
|
+
dev: Boolean(dev),
|
|
390
|
+
});
|
|
391
|
+
installed.push(.../** @type {string[]} */ (names));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Now that the plugin is on disk it can say which TypeScript it runs
|
|
395
|
+
// under. A package manager that installs required peers has already
|
|
396
|
+
// put one there and this pins the same range explicitly; one that does
|
|
397
|
+
// not, like yarn, would otherwise leave `typecheck` unable to start.
|
|
398
|
+
if (!declared.typescript) {
|
|
399
|
+
const range = toolchainTypescriptRange(project.root);
|
|
400
|
+
if (range === null) {
|
|
401
|
+
manual.push('Install typescript, at the range @tsrx/typescript-plugin declares.');
|
|
402
|
+
} else {
|
|
403
|
+
await installPackages(ctx, installing, [`typescript@${range}`], { dev: true });
|
|
404
|
+
installed.push('typescript');
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
} catch (error) {
|
|
408
|
+
spinner.stop('Install failed');
|
|
409
|
+
throw error;
|
|
410
|
+
}
|
|
411
|
+
spinner.stop(`Installed ${installed.length} package(s)`);
|
|
412
|
+
} else if (dependencies.length + devDependencies.length > 0) {
|
|
413
|
+
// The range lives in the plugin, which is not installed yet, so it cannot
|
|
414
|
+
// be quoted here. Naming the package without one would send people to the
|
|
415
|
+
// newest major, which `tsrx-tsc` cannot start under.
|
|
416
|
+
manual.push(
|
|
417
|
+
`Install: ${[...dependencies, ...devDependencies].join(' ')}`,
|
|
418
|
+
'then typescript, at the range @tsrx/typescript-plugin declares as its peer.',
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (manual.length > 0) ctx.ui.note('Do this by hand', manual);
|
|
423
|
+
ctx.ui.outro('Run `octane doctor` to verify the result.');
|
|
424
|
+
|
|
425
|
+
return { json: { ok: true, mode, changes: changes.map((c) => c.file), installed, manual } };
|
|
426
|
+
}
|
|
427
|
+
|
|
165
428
|
export default defineCommand({
|
|
166
429
|
requiresProject: true,
|
|
167
430
|
description:
|
|
@@ -179,99 +442,27 @@ export default defineCommand({
|
|
|
179
442
|
default: true,
|
|
180
443
|
description: 'Install dependencies (--no-install skips).',
|
|
181
444
|
},
|
|
445
|
+
'package-manager': {
|
|
446
|
+
type: 'string',
|
|
447
|
+
choices: PACKAGE_MANAGERS,
|
|
448
|
+
placeholder: '<name>',
|
|
449
|
+
description: 'Install with this one instead of the project’s own.',
|
|
450
|
+
},
|
|
182
451
|
force: { type: 'boolean', description: 'Proceed even with uncommitted changes.' },
|
|
183
452
|
},
|
|
184
453
|
|
|
185
454
|
async run(ctx, input) {
|
|
186
|
-
const project = ctx.project();
|
|
187
|
-
|
|
188
455
|
if (!input.flags.force && !ctx.dryRun && (await isDirty(ctx))) {
|
|
189
456
|
throw new CliError('This project has uncommitted changes.', {
|
|
190
457
|
hint: 'Commit or stash first so you can review what init writes, or pass --force.',
|
|
191
458
|
});
|
|
192
459
|
}
|
|
193
460
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
options: Object.entries(MODES).map(([value, entry]) => ({
|
|
201
|
-
value,
|
|
202
|
-
label: entry.label,
|
|
203
|
-
hint: entry.hint,
|
|
204
|
-
})),
|
|
205
|
-
}))
|
|
206
|
-
);
|
|
207
|
-
|
|
208
|
-
const integration = integrationFor(project.bundler, mode);
|
|
209
|
-
const { changes, manual } = plan(project, mode, integration);
|
|
210
|
-
const declared = project.declaredDependencies;
|
|
211
|
-
const dependencies = integration.dependencies.filter((name) => !declared[name]);
|
|
212
|
-
const devDependencies = integration.devDependencies.filter((name) => !declared[name]);
|
|
213
|
-
|
|
214
|
-
ctx.ui.intro('octane init');
|
|
215
|
-
|
|
216
|
-
if (changes.length === 0 && dependencies.length === 0 && devDependencies.length === 0) {
|
|
217
|
-
ctx.ui.outro('Already set up. Run `octane doctor` to confirm.');
|
|
218
|
-
return { json: { ok: true, mode, changes: [], installed: [] } };
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
ctx.ui.note(
|
|
222
|
-
ctx.dryRun ? 'Would change' : 'Will change',
|
|
223
|
-
changes.length > 0
|
|
224
|
-
? changes.map((c) => `${c.file} ${ctx.ui.colors.dim(c.summary)}`)
|
|
225
|
-
: ['nothing'],
|
|
226
|
-
);
|
|
227
|
-
if (dependencies.length + devDependencies.length > 0) {
|
|
228
|
-
ctx.ui.note('Will install', [
|
|
229
|
-
...dependencies,
|
|
230
|
-
...devDependencies.map((name) => `${name} (dev)`),
|
|
231
|
-
]);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
if (ctx.dryRun) {
|
|
235
|
-
return {
|
|
236
|
-
json: { ok: true, dryRun: true, mode, changes: changes.map((c) => c.file), manual },
|
|
237
|
-
};
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
const confirmed = await ctx.ui.confirm({ message: 'Apply?', flag: '--yes', initial: true });
|
|
241
|
-
if (!confirmed)
|
|
242
|
-
return { exitCode: EXIT.OK, json: { ok: true, mode, changes: [], installed: [] } };
|
|
243
|
-
|
|
244
|
-
for (const change of changes) change.apply();
|
|
245
|
-
|
|
246
|
-
/** @type {string[]} */
|
|
247
|
-
const installed = [];
|
|
248
|
-
if (input.flags.install && dependencies.length + devDependencies.length > 0) {
|
|
249
|
-
const spinner = ctx.ui.spinner(`Installing with ${project.packageManager ?? 'npm'}`);
|
|
250
|
-
try {
|
|
251
|
-
for (const [names, dev] of [
|
|
252
|
-
[dependencies, false],
|
|
253
|
-
[devDependencies, true],
|
|
254
|
-
]) {
|
|
255
|
-
if (/** @type {string[]} */ (names).length === 0) continue;
|
|
256
|
-
await installPackages(ctx, project, /** @type {string[]} */ (names), {
|
|
257
|
-
dev: Boolean(dev),
|
|
258
|
-
});
|
|
259
|
-
installed.push(.../** @type {string[]} */ (names));
|
|
260
|
-
}
|
|
261
|
-
} catch (error) {
|
|
262
|
-
spinner.stop('Install failed');
|
|
263
|
-
throw error;
|
|
264
|
-
}
|
|
265
|
-
spinner.stop(`Installed ${installed.length} package(s)`);
|
|
266
|
-
} else if (dependencies.length + devDependencies.length > 0) {
|
|
267
|
-
manual.push(`Install: ${[...dependencies, ...devDependencies].join(' ')}`);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
if (manual.length > 0) ctx.ui.note('Do this by hand', manual);
|
|
271
|
-
ctx.ui.outro('Run `octane doctor` to verify the result.');
|
|
272
|
-
|
|
273
|
-
return {
|
|
274
|
-
json: { ok: true, mode, changes: changes.map((c) => c.file), installed, manual },
|
|
275
|
-
};
|
|
461
|
+
return applyInit(ctx, ctx.project(), {
|
|
462
|
+
title: 'octane init',
|
|
463
|
+
mode: input.flags.mode,
|
|
464
|
+
install: input.flags.install,
|
|
465
|
+
packageManager: input.flags['package-manager'],
|
|
466
|
+
});
|
|
276
467
|
},
|
|
277
468
|
});
|