@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octanejs/cli",
3
- "version": "0.0.0",
3
+ "version": "0.0.2",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22"
@@ -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'), `${JSON.stringify(tsconfig, null, 2)}\n`),
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 (mode === 'fullstack' && scaffoldsVite) {
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
- if (!existsSync(at('src/App.tsrx'))) {
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: 'create the route entry referenced by octane.config.ts',
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
- const mode = /** @type {keyof typeof MODES} */ (
195
- input.flags.mode ??
196
- (await ctx.ui.select({
197
- message: 'What are you building?',
198
- flag: '--mode',
199
- initial: 'spa',
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
  });