@hover-dev/cli 0.3.0 → 0.3.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.
@@ -15,7 +15,7 @@
15
15
  * `configCandidates` is the list of filenames the mutator will look for,
16
16
  * in priority order. The first one that exists in cwd wins.
17
17
  */
18
- export type FrameworkId = 'astro' | 'nuxt' | 'webpack' | 'vite';
18
+ export type FrameworkId = 'astro' | 'nuxt' | 'next' | 'webpack' | 'vite';
19
19
  export interface Framework {
20
20
  /** Short id used as the --<id> CLI flag and the `Detected: <id>` output. */
21
21
  id: FrameworkId;
@@ -1 +1 @@
1
- {"version":3,"file":"frameworks.d.ts","sourceRoot":"","sources":["../src/frameworks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,CAAC;AAEhE,MAAM,WAAW,SAAS;IACxB,4EAA4E;IAC5E,EAAE,EAAE,WAAW,CAAC;IAChB,2CAA2C;IAC3C,KAAK,EAAE,MAAM,CAAC;IACd,gEAAgE;IAChE,YAAY,EAAE,MAAM,CAAC;IACrB;yDACqD;IACrD,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,4EAA4E;IAC5E,gBAAgB,EAAE,MAAM,EAAE,CAAC;CAC5B;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,UAAU,EAAE,SAAS,EAiCjC,CAAC;AAEF,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,WAAW,GAAG,SAAS,GAAG,SAAS,CAExE"}
1
+ {"version":3,"file":"frameworks.d.ts","sourceRoot":"","sources":["../src/frameworks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,CAAC;AAEzE,MAAM,WAAW,SAAS;IACxB,4EAA4E;IAC5E,EAAE,EAAE,WAAW,CAAC;IAChB,2CAA2C;IAC3C,KAAK,EAAE,MAAM,CAAC;IACd,gEAAgE;IAChE,YAAY,EAAE,MAAM,CAAC;IACrB;yDACqD;IACrD,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,4EAA4E;IAC5E,gBAAgB,EAAE,MAAM,EAAE,CAAC;CAC5B;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,UAAU,EAAE,SAAS,EAiDjC,CAAC;AAEF,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,WAAW,GAAG,SAAS,GAAG,SAAS,CAExE"}
@@ -23,14 +23,30 @@ export const FRAMEWORKS = [
23
23
  detectDeps: ['nuxt'],
24
24
  configCandidates: ['nuxt.config.ts', 'nuxt.config.js', 'nuxt.config.mjs'],
25
25
  },
26
+ {
27
+ id: 'next',
28
+ label: 'Next.js',
29
+ hoverPackage: '@hover-dev/next',
30
+ // Must check before `webpack` — Next 16+ defaults to Turbopack, and a
31
+ // Next project's `next` dep should land on `@hover-dev/next`, not the
32
+ // webpack plugin (which only covers `next dev --webpack`).
33
+ detectDeps: ['next'],
34
+ // `.mjs` and `.js` first because Next loads them via native `import()`
35
+ // and our `mutateNext` can magicast them in place. `.ts` is checked
36
+ // last and triggers a `manual` result that tells the user to rename to
37
+ // `.mjs` first — Next loads `.ts` configs through a CJS require step
38
+ // that can't resolve `@hover-dev/next`'s ESM-only `exports` map.
39
+ configCandidates: ['next.config.mjs', 'next.config.js', 'next.config.ts'],
40
+ },
26
41
  {
27
42
  id: 'webpack',
28
43
  label: 'Webpack',
29
44
  hoverPackage: 'webpack-plugin-hover',
30
- // `webpack-cli` is the user-facing wrapper; `next` ships its own webpack
31
- // (and our plugin works under `next dev --webpack`). Pure `webpack` as a
45
+ // `webpack-cli` is the user-facing wrapper for vanilla webpack-dev-server,
46
+ // Rspack / Rsbuild, CRA, Vue CLI. We no longer detect on `next` here
47
+ // Next projects route to `@hover-dev/next` above. Pure `webpack` as a
32
48
  // transitive dep is too noisy to detect on.
33
- detectDeps: ['webpack-cli', 'next'],
49
+ detectDeps: ['webpack-cli'],
34
50
  configCandidates: ['webpack.config.js', 'webpack.config.mjs', 'webpack.config.ts'],
35
51
  },
36
52
  {
package/dist/index.js CHANGED
@@ -50,13 +50,14 @@ Usage:
50
50
  npx @hover-dev/cli add --vite ${dim('# force a specific bundler')}
51
51
  npx @hover-dev/cli add --astro
52
52
  npx @hover-dev/cli add --nuxt
53
+ npx @hover-dev/cli add --next
53
54
  npx @hover-dev/cli add --webpack
54
55
  npx @hover-dev/cli add --dry-run ${dim('# show what would happen, change nothing')}
55
56
  npx @hover-dev/cli --help
56
57
  npx @hover-dev/cli --version
57
58
 
58
59
  What it does:
59
- 1. Detects your bundler (Vite / Astro / Nuxt / Webpack) from package.json.
60
+ 1. Detects your bundler (Vite / Astro / Nuxt / Next / Webpack) from package.json.
60
61
  2. Detects your package manager (pnpm / yarn / bun / npm) from your lockfile.
61
62
  3. Installs the matching Hover integration as a dev dependency.
62
63
  4. Adds the plugin/integration to your config file.
@@ -79,7 +80,7 @@ async function runAdd(args) {
79
80
  if (!framework) {
80
81
  err(`Couldn't detect a supported bundler in package.json.`);
81
82
  info(`Supported: ${FRAMEWORKS.map(f => f.id).join(', ')}.`);
82
- info(`Force one with --vite / --astro / --nuxt / --webpack.`);
83
+ info(`Force one with --vite / --astro / --nuxt / --next / --webpack.`);
83
84
  return 1;
84
85
  }
85
86
  if (args.framework) {
@@ -127,6 +128,27 @@ async function runAdd(args) {
127
128
  console.log(result.instructions);
128
129
  break;
129
130
  }
131
+ // Next.js needs one extra manual step the CLI cannot safely do: render
132
+ // `<HoverScript />` in `app/layout.tsx`. Modifying JSX in user code with
133
+ // ASTs invites whitespace drift and Server Component shape surprises;
134
+ // the instruction is short, so we print it and let the human paste it.
135
+ if (framework.id === 'next' && result.kind === 'ok' && !result.alreadyWired) {
136
+ info(`One last step — add ${cyan('<HoverScript />')} to your ${cyan('app/layout.tsx')}:`);
137
+ console.log(`
138
+ import { HoverScript } from '@hover-dev/next';
139
+
140
+ export default function RootLayout({ children }) {
141
+ return (
142
+ <html>
143
+ <body>
144
+ {children}
145
+ <HoverScript />
146
+ </body>
147
+ </html>
148
+ );
149
+ }
150
+ `);
151
+ }
130
152
  spark(`Done. Run your dev server and click the floating ✨.`);
131
153
  return 0;
132
154
  }
@@ -1 +1 @@
1
- {"version":3,"file":"mutate.d.ts","sourceRoot":"","sources":["../src/mutate.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAE9D;;;;;;;GAOG;AACH,MAAM,MAAM,YAAY,GACpB;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,OAAO,CAAA;CAAE,GACzD;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,GACxD;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,CAAC;AAE5D;;;;;GAKG;AACH,wBAAsB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,SAAS,GAAG,OAAO,CAAC,YAAY,CAAC,CA+B/F;AAoHD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,WAAW,GAAG,MAAM,CAiC1D"}
1
+ {"version":3,"file":"mutate.d.ts","sourceRoot":"","sources":["../src/mutate.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAE9D;;;;;;;GAOG;AACH,MAAM,MAAM,YAAY,GACpB;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,OAAO,CAAA;CAAE,GACzD;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,GACxD;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,CAAC;AAE5D;;;;;GAKG;AACH,wBAAsB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,SAAS,GAAG,OAAO,CAAC,YAAY,CAAC,CAiC/F;AAuRD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,WAAW,GAAG,MAAM,CAmD1D"}
package/dist/mutate.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync } from 'node:fs';
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { builders, loadFile, writeFile } from 'magicast';
4
4
  /**
@@ -26,6 +26,8 @@ export async function mutateConfig(rootDir, framework) {
26
26
  return await mutateAstro(configPath);
27
27
  case 'nuxt':
28
28
  return await mutateNuxt(configPath);
29
+ case 'next':
30
+ return await mutateNext(configPath, rootDir);
29
31
  case 'webpack':
30
32
  return await mutateWebpack(configPath);
31
33
  }
@@ -86,6 +88,160 @@ async function mutateNuxt(configPath) {
86
88
  await writeFile(mod, configPath);
87
89
  return { kind: 'ok', configPath, alreadyWired: false };
88
90
  }
91
+ // ─── Next.js: withHover() wrap + instrumentation.ts merge ──────────────
92
+ /**
93
+ * Next is the only framework where wiring touches two files:
94
+ *
95
+ * 1. `next.config.{ts,mjs,js}` — wrap the user's exported config in
96
+ * `withHover(...)`. Idempotent: detect an existing import from
97
+ * `@hover-dev/next` and bail.
98
+ * 2. `instrumentation.ts` — Next's blessed hook for dev-only server-side
99
+ * init. We MUST NOT boot the Hover service in `next.config.ts` because
100
+ * that file is also loaded by `next build`, which would leak an orphan
101
+ * service into CI. The instrumentation hook only fires for
102
+ * `next dev` / `next start`.
103
+ *
104
+ * The user's `app/layout.tsx` still needs a `<HoverScript />` import after
105
+ * `{children}` — we can't safely AST-mutate JSX in user code (RSC,
106
+ * Server Component conventions, formatting), so the CLI prints a manual
107
+ * one-liner for that step instead of touching the file.
108
+ */
109
+ async function mutateNext(configPath, rootDir) {
110
+ // Next 16 loads `.ts` configs through a CJS `transpile-config` step that
111
+ // does `require()` on the compiled output. That path does NOT honour
112
+ // the `"import"` condition in ESM-only `exports`, so it cannot load
113
+ // `@hover-dev/next` (which is ESM). The user has to rename to `.mjs`
114
+ // (or `.js` with `"type": "module"` in package.json) — `.mjs` goes
115
+ // through Node's native `import()` which resolves ESM correctly.
116
+ // Spike + verification in CLAUDE.md "Edge runtime isolation" section.
117
+ if (configPath.endsWith('.ts')) {
118
+ return {
119
+ kind: 'manual',
120
+ reason: 'next.config.ts cannot load @hover-dev/next (Next loads .ts configs via CJS require, which does not honour ESM exports)',
121
+ instructions: tsConfigManualInstructions(configPath),
122
+ };
123
+ }
124
+ const mod = await loadFile(configPath);
125
+ // Step 1: wrap next.config export in withHover(...) — idempotent.
126
+ let configAlreadyWired = false;
127
+ if (alreadyImported(mod, '@hover-dev/next')) {
128
+ configAlreadyWired = true;
129
+ }
130
+ else {
131
+ mod.imports.$add({ from: '@hover-dev/next', imported: 'withHover' });
132
+ // Wrap whatever the user has as `export default`. Works for plain object,
133
+ // for `defineConfig({...})` (no-op upstream — Next never had that
134
+ // helper), and for already-wrapped configs (which we skip via the
135
+ // `alreadyImported` check above).
136
+ const previous = mod.exports.default;
137
+ mod.exports.default = builders.functionCall('withHover', previous);
138
+ await writeFile(mod, configPath);
139
+ }
140
+ // Step 2: instrumentation.ts — create or merge.
141
+ const instrumentationPath = findOrPickInstrumentationPath(rootDir);
142
+ const instrumentationAlreadyWired = ensureInstrumentationRegistersHover(instrumentationPath);
143
+ const alreadyWired = configAlreadyWired && instrumentationAlreadyWired;
144
+ return { kind: 'ok', configPath, alreadyWired };
145
+ }
146
+ /**
147
+ * Tailored manual-instructions message for the "you have a next.config.ts"
148
+ * case. Tells the user exactly what to rename and what to paste, with the
149
+ * same shape as if the CLI had mutated successfully — so a `mv && paste`
150
+ * leaves them in the same end state as the auto-magicast path.
151
+ */
152
+ function tsConfigManualInstructions(configPath) {
153
+ const tsName = configPath.split('/').pop() ?? 'next.config.ts';
154
+ const mjsName = tsName.replace(/\.ts$/, '.mjs');
155
+ return [
156
+ `Next 16 can't load @hover-dev/next from a ${tsName}.`,
157
+ `(Next loads .ts configs via a CJS \`require()\` step that can't resolve`,
158
+ `the ESM-only \`exports\` map @hover-dev/next ships.) Rename to ${mjsName}`,
159
+ `and paste this:`,
160
+ ``,
161
+ ` // ${mjsName}`,
162
+ ` import { withHover } from '@hover-dev/next';`,
163
+ ``,
164
+ ` /** @type {import('next').NextConfig} */`,
165
+ ` const nextConfig = {`,
166
+ ` // your existing config`,
167
+ ` };`,
168
+ ``,
169
+ ` export default withHover(nextConfig);`,
170
+ ``,
171
+ `Then continue with steps 2 and 3 below.`,
172
+ ``,
173
+ manualInstructions('next'),
174
+ ].join('\n');
175
+ }
176
+ /**
177
+ * Locate the user's existing instrumentation file, or pick a default path
178
+ * to create one at. Next looks for `instrumentation.{ts,js}` at the project
179
+ * root or under `src/`. We prefer `src/` if it exists (consistent with the
180
+ * Next default scaffold), otherwise drop one at the project root.
181
+ */
182
+ function findOrPickInstrumentationPath(rootDir) {
183
+ const candidates = [
184
+ join(rootDir, 'instrumentation.ts'),
185
+ join(rootDir, 'instrumentation.js'),
186
+ join(rootDir, 'src', 'instrumentation.ts'),
187
+ join(rootDir, 'src', 'instrumentation.js'),
188
+ ];
189
+ for (const c of candidates) {
190
+ if (existsSync(c))
191
+ return c;
192
+ }
193
+ const useSrc = existsSync(join(rootDir, 'src'));
194
+ return useSrc ? join(rootDir, 'src', 'instrumentation.ts') : join(rootDir, 'instrumentation.ts');
195
+ }
196
+ /**
197
+ * Ensure the instrumentation file calls `register` from
198
+ * `@hover-dev/next/instrumentation`. Returns true if the file already
199
+ * had it wired (so the caller can report "already wired" honestly).
200
+ *
201
+ * We do this as a plain text edit (not magicast) because instrumentation
202
+ * files are usually tiny and the user might have written them in one of
203
+ * many idiomatic shapes (named function, arrow, async). String-level
204
+ * editing keeps formatting stable; magicast's stringifier would reformat.
205
+ */
206
+ function ensureInstrumentationRegistersHover(filePath) {
207
+ const HOVER_IMPORT = "import { register as registerHover } from '@hover-dev/next/instrumentation';";
208
+ const HOVER_CALL = 'await registerHover();';
209
+ if (!existsSync(filePath)) {
210
+ // Greenfield — write a full instrumentation file.
211
+ const fresh = [
212
+ HOVER_IMPORT,
213
+ '',
214
+ 'export async function register() {',
215
+ ` ${HOVER_CALL}`,
216
+ '}',
217
+ '',
218
+ ].join('\n');
219
+ writeFileSync(filePath, fresh, 'utf-8');
220
+ return false;
221
+ }
222
+ const existing = readFileSync(filePath, 'utf-8');
223
+ if (existing.includes('@hover-dev/next/instrumentation')) {
224
+ return true;
225
+ }
226
+ // The file exists but doesn't reference us. We want to (a) add our
227
+ // import at the top, (b) inject `await registerHover();` into the
228
+ // user's existing `register` function if we can find it, or
229
+ // (c) bail to a comment if we can't.
230
+ let next = `${HOVER_IMPORT}\n${existing}`;
231
+ const registerMatch = /(export\s+async\s+function\s+register\s*\([^)]*\)\s*\{)/.exec(next);
232
+ if (registerMatch) {
233
+ next = next.replace(registerMatch[0], `${registerMatch[0]}\n ${HOVER_CALL}`);
234
+ }
235
+ else {
236
+ // Couldn't find a function signature to splice into — append a new
237
+ // register export. If the user already has one in a non-standard
238
+ // shape Next will warn at startup, which is fine — better than us
239
+ // silently doing nothing.
240
+ next = `${next}\n\nexport async function register() {\n ${HOVER_CALL}\n}\n`;
241
+ }
242
+ writeFileSync(filePath, next, 'utf-8');
243
+ return false;
244
+ }
89
245
  // ─── Webpack: new HoverPlugin() in plugins array ────────────────────────
90
246
  async function mutateWebpack(configPath) {
91
247
  const mod = await loadFile(configPath);
@@ -172,6 +328,24 @@ export function manualInstructions(id) {
172
328
  ``,
173
329
  ` modules: ['@hover-dev/nuxt'],`,
174
330
  ].join('\n');
331
+ case 'next':
332
+ return [
333
+ `Three steps for Next.js:`,
334
+ ``,
335
+ `1. Wrap your next.config:`,
336
+ ` import { withHover } from '@hover-dev/next';`,
337
+ ` export default withHover({ /* your config */ });`,
338
+ ``,
339
+ `2. Create instrumentation.ts at your project root:`,
340
+ ` import { register as registerHover } from '@hover-dev/next/instrumentation';`,
341
+ ` export async function register() {`,
342
+ ` await registerHover();`,
343
+ ` }`,
344
+ ``,
345
+ `3. Render <HoverScript /> in your app/layout.tsx, after {children}:`,
346
+ ` import { HoverScript } from '@hover-dev/next';`,
347
+ ` // ... inside <body>: {children}<HoverScript />`,
348
+ ].join('\n');
175
349
  case 'webpack':
176
350
  return [
177
351
  `Add to your webpack config:`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hover-dev/cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "CLI for Hover. Detects your bundler, installs the right Hover integration package, and wires it into your config — one command.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hyperyond",