@ahmednawaz/crank 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -103,6 +103,38 @@ See **[REPLIT.md](./REPLIT.md)** and `.crank/replit.md` (URLs after first run).
103
103
 
104
104
  ---
105
105
 
106
+ ## Panel injection (Retune pattern)
107
+
108
+ **Preferred for React apps** — install the package and mount once:
109
+
110
+ ```bash
111
+ pnpm add -D @ahmednawaz/crank
112
+ # or: npm i -D @ahmednawaz/crank
113
+ ```
114
+
115
+ ```tsx
116
+ import { Crank } from '@ahmednawaz/crank/react'
117
+
118
+ // main.tsx
119
+ <App />
120
+ <Crank />
121
+ ```
122
+
123
+ Keep companion running: `npx @ahmednawaz/crank start`
124
+
125
+ On Replit set public companion URL if needed: `window.__CRANK_URL__`, `<Crank companionUrl="…" />`, or `VITE_CRANK_URL`.
126
+
127
+ | Stack | Integration |
128
+ |-------|-------------|
129
+ | **React + Vite** | `<Crank />` from `@ahmednawaz/crank/react` (bundled like Retune — **no Vite plugin**) |
130
+ | **Next.js** | `<Crank />` from `@ahmednawaz/crank/next` |
131
+ | **Vue / Svelte / vanilla Vite** | Optional `@ahmednawaz/crank/vite` plugin |
132
+ | **Any** | Bookmarklet from companion URL |
133
+
134
+ Agents: do **not** invent Vite-plugin path workarounds for React apps. See `.crank/react-mount.md` / `.crank/injection.md` after setup.
135
+
136
+ ---
137
+
106
138
  ## MCP tools (agent pull workflow)
107
139
 
108
140
  When the user clicks **Agent** on a copy variant or says *“Apply the pending Crank change”*:
package/README.md CHANGED
@@ -102,6 +102,35 @@ No external sibling tool required. Crank ships:
102
102
 
103
103
  Optional: `CRANK_ASSET_ROOT` overrides where packaged UI assets are loaded from (defaults to this install).
104
104
 
105
+ ### Panel injection (Retune-style React)
106
+
107
+ **Preferred** — install and mount like Retune:
108
+
109
+ ```bash
110
+ pnpm add -D @ahmednawaz/crank
111
+ ```
112
+
113
+ ```tsx
114
+ import { Crank } from '@ahmednawaz/crank/react'
115
+
116
+ createRoot(document.getElementById('root')!).render(
117
+ <>
118
+ <App />
119
+ <Crank />
120
+ </>
121
+ )
122
+ ```
123
+
124
+ Companion: `npx @ahmednawaz/crank start`
125
+ Replit public URL: `window.__CRANK_URL__` or `<Crank companionUrl="https://3344-….replit.dev" />` or `VITE_CRANK_URL`.
126
+
127
+ | Stack | Integration |
128
+ |-------|-------------|
129
+ | **React** | `<Crank />` — bundled by Vite, **no plugin** |
130
+ | **Next.js** | `import { Crank } from '@ahmednawaz/crank/next'` |
131
+ | **Non-React Vite** | Optional `import { crank } from '@ahmednawaz/crank/vite'` |
132
+ | **Fallback** | Companion bookmarklet |
133
+
105
134
  Useful variants:
106
135
 
107
136
  ```bash
@@ -1,8 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Injected by Vite plugin (or bookmarklet fallback).
5
- * Loads the Crank panel from the local companion.
4
+ * Injected by <Crank /> (React) or the optional Vite plugin / bookmarklet.
5
+ * Loads the Crank panel from the companion that served this script.
6
6
  */
7
7
  (function () {
8
8
  if (window.__CRANK_LOADER__) {
package/lib/detect.js CHANGED
@@ -4,6 +4,41 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
6
 
7
+ const VITE_CONFIG_NAMES = [
8
+ 'vite.config.ts',
9
+ 'vite.config.js',
10
+ 'vite.config.mjs',
11
+ 'vite.config.cjs',
12
+ 'vite.config.mts',
13
+ 'vite.config.cts'
14
+ ];
15
+
16
+ const APP_DIR_CANDIDATES = [
17
+ '.',
18
+ 'app',
19
+ 'client',
20
+ 'web',
21
+ 'frontend',
22
+ 'ui',
23
+ 'src',
24
+ 'packages/app',
25
+ 'packages/web',
26
+ 'apps/web',
27
+ 'apps/frontend'
28
+ ];
29
+
30
+ const SKIP_DIRS = new Set([
31
+ 'node_modules',
32
+ '.git',
33
+ 'dist',
34
+ 'build',
35
+ '.next',
36
+ 'coverage',
37
+ '.turbo',
38
+ '.cache',
39
+ '.crank'
40
+ ]);
41
+
7
42
  function exists(p) {
8
43
  try {
9
44
  return fs.existsSync(p);
@@ -25,21 +60,97 @@ function writeJson(file, data) {
25
60
  fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
26
61
  }
27
62
 
63
+ function findViteConfigInDir(dir) {
64
+ for (const name of VITE_CONFIG_NAMES) {
65
+ const full = path.join(dir, name);
66
+ if (exists(full)) {
67
+ return full;
68
+ }
69
+ }
70
+ return null;
71
+ }
72
+
73
+ /**
74
+ * Find vite.config.* at project root, common app subdirs, then shallow BFS.
75
+ */
76
+ function findViteConfig(projectRoot) {
77
+ const root = path.resolve(projectRoot);
78
+ for (const sub of APP_DIR_CANDIDATES) {
79
+ const dir = sub === '.' ? root : path.join(root, sub);
80
+ const hit = findViteConfigInDir(dir);
81
+ if (hit) {
82
+ return hit;
83
+ }
84
+ }
85
+
86
+ // Shallow walk (depth 2) for monorepos / Replit layouts
87
+ const queue = [{ dir: root, depth: 0 }];
88
+ while (queue.length) {
89
+ const { dir, depth } = queue.shift();
90
+ if (depth > 0) {
91
+ const hit = findViteConfigInDir(dir);
92
+ if (hit) {
93
+ return hit;
94
+ }
95
+ }
96
+ if (depth >= 2) {
97
+ continue;
98
+ }
99
+ let entries;
100
+ try {
101
+ entries = fs.readdirSync(dir, { withFileTypes: true });
102
+ } catch {
103
+ continue;
104
+ }
105
+ for (const entry of entries) {
106
+ if (!entry.isDirectory() || SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) {
107
+ continue;
108
+ }
109
+ queue.push({ dir: path.join(dir, entry.name), depth: depth + 1 });
110
+ }
111
+ }
112
+ return null;
113
+ }
114
+
115
+ function packageHasDep(pkg, name) {
116
+ if (!pkg || typeof pkg !== 'object') {
117
+ return false;
118
+ }
119
+ return Boolean(
120
+ pkg.dependencies?.[name] ||
121
+ pkg.devDependencies?.[name] ||
122
+ pkg.peerDependencies?.[name]
123
+ );
124
+ }
125
+
28
126
  function detectEnvironment(projectRoot) {
29
127
  const env = process.env;
30
128
  const isReplit = Boolean(
31
129
  env.REPL_ID || env.REPL_SLUG || env.REPLIT_DEV_DOMAIN || env.REPLIT_DB_URL
32
130
  );
33
- const hasVite =
34
- exists(path.join(projectRoot, 'vite.config.ts')) ||
35
- exists(path.join(projectRoot, 'vite.config.js')) ||
36
- exists(path.join(projectRoot, 'vite.config.mjs')) ||
37
- exists(path.join(projectRoot, 'vite.config.cjs'));
38
131
  const hasPackageJson = exists(path.join(projectRoot, 'package.json'));
39
- const hasCursor = exists(path.join(projectRoot, '.cursor')) ||
40
- exists(path.join(os.homedir(), '.cursor'));
41
- const hasVsCode = exists(path.join(projectRoot, '.vscode')) ||
42
- exists(path.join(projectRoot, '.vscode'.replace('s', 's')));
132
+ const packageJson = hasPackageJson
133
+ ? readJson(path.join(projectRoot, 'package.json'), {})
134
+ : null;
135
+ const viteConfig = findViteConfig(projectRoot);
136
+ const hasVite = Boolean(viteConfig) || packageHasDep(packageJson, 'vite');
137
+ const hasReact =
138
+ packageHasDep(packageJson, 'react') ||
139
+ exists(path.join(projectRoot, 'next.config.js')) ||
140
+ exists(path.join(projectRoot, 'next.config.mjs')) ||
141
+ exists(path.join(projectRoot, 'next.config.ts'));
142
+ const hasNext =
143
+ packageHasDep(packageJson, 'next') ||
144
+ exists(path.join(projectRoot, 'next.config.js')) ||
145
+ exists(path.join(projectRoot, 'next.config.mjs')) ||
146
+ exists(path.join(projectRoot, 'next.config.ts'));
147
+ const hasWebpack =
148
+ packageHasDep(packageJson, 'webpack') ||
149
+ exists(path.join(projectRoot, 'webpack.config.js')) ||
150
+ exists(path.join(projectRoot, 'webpack.config.ts'));
151
+
152
+ const hasCursor =
153
+ exists(path.join(projectRoot, '.cursor')) || exists(path.join(os.homedir(), '.cursor'));
43
154
  const claudeConfig = path.join(
44
155
  os.homedir(),
45
156
  'Library',
@@ -52,37 +163,26 @@ function detectEnvironment(projectRoot) {
52
163
  return {
53
164
  isReplit,
54
165
  hasVite,
166
+ viteConfig,
167
+ hasReact,
168
+ hasNext,
169
+ hasWebpack,
55
170
  hasPackageJson,
56
171
  hasCursor,
57
172
  hasVsCode: exists(path.join(projectRoot, '.vscode')),
58
173
  hasClaudeDesktop,
59
174
  claudeConfig,
60
175
  replitDomain: env.REPLIT_DEV_DOMAIN || null,
61
- packageJson: hasPackageJson
62
- ? readJson(path.join(projectRoot, 'package.json'), {})
63
- : null
176
+ packageJson
64
177
  };
65
178
  }
66
179
 
67
- function findViteConfig(projectRoot) {
68
- for (const name of [
69
- 'vite.config.ts',
70
- 'vite.config.js',
71
- 'vite.config.mjs',
72
- 'vite.config.cjs'
73
- ]) {
74
- const full = path.join(projectRoot, name);
75
- if (exists(full)) {
76
- return full;
77
- }
78
- }
79
- return null;
80
- }
81
-
82
180
  module.exports = {
83
181
  exists,
84
182
  readJson,
85
183
  writeJson,
86
184
  detectEnvironment,
87
- findViteConfig
185
+ findViteConfig,
186
+ VITE_CONFIG_NAMES,
187
+ APP_DIR_CANDIDATES
88
188
  };
package/lib/init.js CHANGED
@@ -94,59 +94,214 @@ function mergeVsCodeMcp(file, projectRoot) {
94
94
  return file;
95
95
  }
96
96
 
97
+ function writeVitePluginStub(projectRoot) {
98
+ const dir = path.join(projectRoot, '.crank');
99
+ fs.mkdirSync(dir, { recursive: true });
100
+ const stubPath = path.join(dir, 'vite-plugin.cjs');
101
+ const stub = `'use strict';
102
+
103
+ /**
104
+ * Crank Vite plugin loader — Vizpatch-style graceful resolve.
105
+ * Generated by \`npx @ahmednawaz/crank setup\`. Does not throw if Crank is missing.
106
+ *
107
+ * Resolves from (in order):
108
+ * 1. .crank/config.json packageRoot (npx cache / local checkout)
109
+ * 2. node_modules/@ahmednawaz/crank or node_modules/crank
110
+ * 3. require('@ahmednawaz/crank/vite')
111
+ */
112
+ const fs = require('fs');
113
+ const path = require('path');
114
+
115
+ function load() {
116
+ const candidates = [];
117
+ try {
118
+ const configPath = path.join(__dirname, 'config.json');
119
+ if (fs.existsSync(configPath)) {
120
+ const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
121
+ if (cfg && cfg.packageRoot) {
122
+ candidates.push(path.join(cfg.packageRoot, 'vite.js'));
123
+ }
124
+ }
125
+ } catch {
126
+ // ignore
127
+ }
128
+
129
+ const cwd = process.cwd();
130
+ candidates.push(
131
+ path.join(cwd, 'node_modules', '@ahmednawaz', 'crank', 'vite.js'),
132
+ path.join(cwd, 'node_modules', 'crank', 'vite.js'),
133
+ path.join(__dirname, '..', 'node_modules', '@ahmednawaz', 'crank', 'vite.js'),
134
+ path.join(__dirname, '..', 'node_modules', 'crank', 'vite.js')
135
+ );
136
+
137
+ for (const file of candidates) {
138
+ try {
139
+ if (file && fs.existsSync(file)) {
140
+ return require(file);
141
+ }
142
+ } catch {
143
+ // try next
144
+ }
145
+ }
146
+
147
+ try {
148
+ return require('@ahmednawaz/crank/vite');
149
+ } catch {
150
+ // ignore
151
+ }
152
+ try {
153
+ return require('@ahmednawaz/crank');
154
+ } catch {
155
+ // ignore
156
+ }
157
+ try {
158
+ return require('crank/vite');
159
+ } catch {
160
+ // ignore
161
+ }
162
+ return null;
163
+ }
164
+
165
+ function noopPlugin() {
166
+ return {
167
+ name: 'crank-dev-loader-noop',
168
+ apply: 'serve',
169
+ transformIndexHtml(html) {
170
+ return html;
171
+ }
172
+ };
173
+ }
174
+
175
+ const mod = load();
176
+ const crankFn =
177
+ mod && typeof mod.crank === 'function' ? mod.crank : noopPlugin;
178
+
179
+ module.exports = {
180
+ crank: crankFn,
181
+ crankDevLoader: crankFn,
182
+ resolveCompanionLoaderUrl: mod && mod.resolveCompanionLoaderUrl
183
+ };
184
+ `;
185
+ fs.writeFileSync(stubPath, stub);
186
+ return stubPath;
187
+ }
188
+
97
189
  function patchViteConfig(projectRoot) {
98
190
  const configPath = findViteConfig(projectRoot);
191
+ const stubPath = writeVitePluginStub(projectRoot);
192
+
99
193
  if (!configPath) {
100
- return { patched: false, reason: 'no-vite-config' };
194
+ return { patched: false, reason: 'no-vite-config', plugin: stubPath };
101
195
  }
196
+
102
197
  let source = fs.readFileSync(configPath, 'utf8');
103
- if (/(\bcrank)\s*\(/.test(source) && /plugins/.test(source)) {
104
- return { patched: false, reason: 'already-present', file: configPath };
198
+ if (
199
+ /vite-plugin\.cjs/.test(source) ||
200
+ (/(\bcrank)\s*\(/.test(source) && /plugins/.test(source))
201
+ ) {
202
+ return { patched: false, reason: 'already-present', file: configPath, plugin: stubPath };
105
203
  }
106
204
 
107
- // Copy plugin into the project so Vite can resolve a relative import everywhere.
108
- const localPluginDir = path.join(projectRoot, '.crank');
109
- fs.mkdirSync(localPluginDir, { recursive: true });
110
- const localPlugin = path.join(localPluginDir, 'vite-plugin.cjs');
111
- fs.copyFileSync(path.join(PACKAGE_ROOT, 'vite.js'), localPlugin);
205
+ let relStub = path.relative(path.dirname(configPath), stubPath).replace(/\\/g, '/');
206
+ if (!relStub.startsWith('.')) {
207
+ relStub = `./${relStub}`;
208
+ }
112
209
 
113
- const rel = './.crank/vite-plugin.cjs';
114
210
  const isEsm =
115
211
  configPath.endsWith('.mjs') ||
116
- (/^import\s/m.test(source) && !configPath.endsWith('.cjs'));
117
- const importLine = isEsm
118
- ? `import { crank } from '${rel}';\n`
119
- : `const { crank } = require('${rel}');\n`;
120
-
121
- if (!/vite-plugin\.cjs|from ['"]crank/.test(source)) {
122
- source = importLine + source;
123
- }
124
-
125
- if (/plugins\s*:\s*\[\s*\]/.test(source)) {
126
- source = source.replace(/plugins\s*:\s*\[\s*\]/, 'plugins: [crank()]');
127
- } else if (/plugins\s*:\s*\[/.test(source)) {
128
- source = source.replace(/plugins\s*:\s*\[/, 'plugins: [crank(), ');
129
- } else if (/defineConfig\s*\(\s*\{/.test(source)) {
130
- source = source.replace(
131
- /defineConfig\s*\(\s*\{/,
132
- 'defineConfig({\n plugins: [crank()],'
133
- );
134
- } else if (/export\s+default\s+\{/.test(source)) {
135
- source = source.replace(
136
- /export\s+default\s+\{/,
137
- 'export default {\n plugins: [crank()],'
138
- );
139
- } else if (/module\.exports\s*=\s*\{/.test(source)) {
140
- source = source.replace(
141
- /module\.exports\s*=\s*\{/,
142
- 'module.exports = {\n plugins: [crank()],'
143
- );
212
+ configPath.endsWith('.mts') ||
213
+ (/^import\s/m.test(source) && !configPath.endsWith('.cjs') && !configPath.endsWith('.cts'));
214
+
215
+ if (isEsm) {
216
+ const isTs = /\.tsx?$|\.mts$/.test(configPath);
217
+ const crankDecl = isTs
218
+ ? 'let crank: null | (() => unknown) = null'
219
+ : 'let crank = null';
220
+ const gracefulBlock = `import { createRequire } from 'node:module'
221
+ import { existsSync } from 'node:fs'
222
+ import { fileURLToPath } from 'node:url'
223
+
224
+ // Crank Vite plugin — graceful load (same pattern as Vizpatch); skip if unavailable
225
+ ${crankDecl}
226
+ try {
227
+ const _crankPluginPath = fileURLToPath(new URL('${relStub}', import.meta.url))
228
+ if (existsSync(_crankPluginPath)) {
229
+ const require = createRequire(import.meta.url)
230
+ crank = require(_crankPluginPath).crank
231
+ }
232
+ } catch {}
233
+
234
+ `;
235
+ if (!/_crankPluginPath/.test(source)) {
236
+ source = gracefulBlock + source;
237
+ }
238
+
239
+ if (/plugins\s*:\s*\[\s*\]/.test(source)) {
240
+ source = source.replace(
241
+ /plugins\s*:\s*\[\s*\]/,
242
+ 'plugins: [...(crank ? [crank()] : [])]'
243
+ );
244
+ } else if (/plugins\s*:\s*\[/.test(source)) {
245
+ source = source.replace(
246
+ /plugins\s*:\s*\[/,
247
+ 'plugins: [...(crank ? [crank()] : []), '
248
+ );
249
+ } else if (/defineConfig\s*\(\s*\{/.test(source)) {
250
+ source = source.replace(
251
+ /defineConfig\s*\(\s*\{/,
252
+ 'defineConfig({\n plugins: [...(crank ? [crank()] : [])],'
253
+ );
254
+ } else if (/export\s+default\s+\{/.test(source)) {
255
+ source = source.replace(
256
+ /export\s+default\s+\{/,
257
+ 'export default {\n plugins: [...(crank ? [crank()] : [])],'
258
+ );
259
+ } else {
260
+ return {
261
+ patched: false,
262
+ reason: 'unrecognized-vite-shape',
263
+ file: configPath,
264
+ plugin: stubPath
265
+ };
266
+ }
144
267
  } else {
145
- return { patched: false, reason: 'unrecognized-vite-shape', file: configPath };
268
+ const gracefulBlock = `// Crank Vite plugin graceful load (same pattern as Vizpatch)
269
+ let crank = null;
270
+ try {
271
+ crank = require('${relStub}').crank;
272
+ } catch {}
273
+
274
+ `;
275
+ if (!/vite-plugin\.cjs/.test(source)) {
276
+ source = gracefulBlock + source;
277
+ }
278
+ if (/plugins\s*:\s*\[\s*\]/.test(source)) {
279
+ source = source.replace(
280
+ /plugins\s*:\s*\[\s*\]/,
281
+ 'plugins: [...(crank ? [crank()] : [])]'
282
+ );
283
+ } else if (/plugins\s*:\s*\[/.test(source)) {
284
+ source = source.replace(
285
+ /plugins\s*:\s*\[/,
286
+ 'plugins: [...(crank ? [crank()] : []), '
287
+ );
288
+ } else if (/module\.exports\s*=\s*\{/.test(source)) {
289
+ source = source.replace(
290
+ /module\.exports\s*=\s*\{/,
291
+ 'module.exports = {\n plugins: [...(crank ? [crank()] : [])],'
292
+ );
293
+ } else {
294
+ return {
295
+ patched: false,
296
+ reason: 'unrecognized-vite-shape',
297
+ file: configPath,
298
+ plugin: stubPath
299
+ };
300
+ }
146
301
  }
147
302
 
148
303
  fs.writeFileSync(configPath, source);
149
- return { patched: true, file: configPath, plugin: localPlugin };
304
+ return { patched: true, file: configPath, plugin: stubPath };
150
305
  }
151
306
 
152
307
  function writeProjectEnvExample(projectRoot) {
@@ -278,7 +433,7 @@ function mergeUserCursorMcp(projectRoot, results) {
278
433
  }
279
434
 
280
435
  /**
281
- * Retune-style: add crank to devDependencies so `npx crank` works with no path.
436
+ * Retune-style: add @ahmednawaz/crank to devDependencies (scoped works with pnpm).
282
437
  */
283
438
  function ensureCrankDependency(projectRoot, results) {
284
439
  const pkgPath = path.join(projectRoot, 'package.json');
@@ -293,20 +448,28 @@ function ensureCrankDependency(projectRoot, results) {
293
448
  const crankPkg = readJson(path.join(PACKAGE_ROOT, 'package.json'), {});
294
449
  pkg.devDependencies = pkg.devDependencies || {};
295
450
 
296
- let spec;
451
+ const scoped = '@ahmednawaz/crank';
452
+ let versionSpec;
297
453
  if (process.env.CRANK_NPM_SPEC) {
298
- spec = process.env.CRANK_NPM_SPEC;
454
+ versionSpec = process.env.CRANK_NPM_SPEC;
299
455
  } else {
300
- spec = `@ahmednawaz/crank@^${crankPkg.version || '0.1.0'}`;
456
+ versionSpec = `^${crankPkg.version || '0.2.0'}`;
301
457
  }
302
458
 
303
- if (pkg.devDependencies.crank === spec) {
459
+ const prev = pkg.devDependencies[scoped];
460
+ const hadLegacyAlias = Boolean(pkg.devDependencies.crank);
461
+ pkg.devDependencies[scoped] = versionSpec;
462
+ if (hadLegacyAlias) {
463
+ delete pkg.devDependencies.crank;
464
+ }
465
+
466
+ if (prev === versionSpec && !hadLegacyAlias) {
304
467
  return;
305
468
  }
306
- pkg.devDependencies.crank = spec;
469
+
307
470
  writeJson(pkgPath, pkg);
308
- results.wrote.push('package.json#devDependencies.crank');
309
- results.crankDependencyAdded = spec;
471
+ results.wrote.push(`package.json#devDependencies.${scoped}`);
472
+ results.crankDependencyAdded = `${scoped}@${versionSpec}`;
310
473
  }
311
474
 
312
475
  function writeConfig(projectRoot, env) {
@@ -327,7 +490,11 @@ function writeConfig(projectRoot, env) {
327
490
  createdAt: new Date().toISOString(),
328
491
  environment: {
329
492
  isReplit: env.isReplit,
330
- hasVite: env.hasVite
493
+ hasVite: env.hasVite,
494
+ viteConfig: env.viteConfig || null,
495
+ hasReact: env.hasReact,
496
+ hasNext: env.hasNext,
497
+ hasWebpack: env.hasWebpack
331
498
  }
332
499
  };
333
500
  writeJson(configPath, config);
@@ -370,14 +537,26 @@ function initProject(projectRoot, options = {}) {
370
537
  installCrankSkill(projectRoot, results);
371
538
  }
372
539
 
373
- if (env.hasVite && options.vite !== false) {
540
+ // Retune-style: React apps mount <Crank /> — do not rely on Vite plugin path hacks.
541
+ if (env.hasReact && options.react !== false) {
542
+ writeReactMountHint(projectRoot, env, results);
543
+ } else if (env.hasVite && options.vite !== false) {
374
544
  const viteResult = patchViteConfig(projectRoot);
375
545
  results.vite = viteResult;
376
546
  if (viteResult.patched) {
377
547
  results.wrote.push(path.relative(projectRoot, viteResult.file));
378
548
  }
549
+ if (viteResult.plugin) {
550
+ results.wrote.push(path.relative(projectRoot, viteResult.plugin));
551
+ }
552
+ } else if (options.vite !== false) {
553
+ const stub = writeVitePluginStub(projectRoot);
554
+ results.vite = { patched: false, reason: 'no-vite-detected', plugin: stub };
555
+ results.wrote.push(path.relative(projectRoot, stub));
379
556
  }
380
557
 
558
+ writeInjectionGuide(projectRoot, env, results);
559
+
381
560
  // package.json script convenience
382
561
  if (env.hasPackageJson && options.scripts !== false) {
383
562
  const pkgPath = path.join(projectRoot, 'package.json');
@@ -530,6 +709,117 @@ function copyAgentsDoc(projectRoot, results) {
530
709
  }
531
710
  }
532
711
 
712
+ function writeReactMountHint(projectRoot, env, results) {
713
+ const hint = path.join(projectRoot, '.crank', 'react-mount.md');
714
+ fs.mkdirSync(path.dirname(hint), { recursive: true });
715
+ fs.writeFileSync(
716
+ hint,
717
+ [
718
+ '# Mount Crank (Retune-style)',
719
+ '',
720
+ '1. Install (from the app package in a monorepo):',
721
+ '',
722
+ '```bash',
723
+ 'pnpm add -D @ahmednawaz/crank',
724
+ '# or: npm i -D @ahmednawaz/crank',
725
+ '```',
726
+ '',
727
+ '2. Mount once near your app root (e.g. `main.tsx` / `App.tsx`):',
728
+ '',
729
+ '```tsx',
730
+ "import { Crank } from '@ahmednawaz/crank/react'",
731
+ '',
732
+ 'createRoot(document.getElementById("root")!).render(',
733
+ ' <>',
734
+ ' <App />',
735
+ ' <Crank />',
736
+ ' </>',
737
+ ')',
738
+ '```',
739
+ '',
740
+ '3. Keep the companion running:',
741
+ '',
742
+ '```bash',
743
+ 'npx @ahmednawaz/crank start',
744
+ '```',
745
+ '',
746
+ 'On Replit, set the public companion URL if the preview is not on localhost:',
747
+ '',
748
+ '```ts',
749
+ "window.__CRANK_URL__ = 'https://3344-<your-repl>.replit.dev'",
750
+ '// or: <Crank companionUrl="https://3344-….replit.dev" />',
751
+ '// or: VITE_CRANK_URL=https://3344-….replit.dev',
752
+ '```',
753
+ '',
754
+ 'No Vite plugin required.',
755
+ ''
756
+ ].join('\n')
757
+ );
758
+ results.wrote.push('.crank/react-mount.md');
759
+ results.reactMount = true;
760
+ }
761
+
762
+ function writeInjectionGuide(projectRoot, env, results) {
763
+ const guide = path.join(projectRoot, '.crank', 'injection.md');
764
+ fs.mkdirSync(path.dirname(guide), { recursive: true });
765
+ const lines = [
766
+ '# Crank panel injection',
767
+ '',
768
+ '## Preferred — React component (Retune pattern)',
769
+ '',
770
+ 'Crank ships a normal npm React export. Vite bundles it like any other dependency.',
771
+ '**No Vite plugin. No npx-cache paths.**',
772
+ '',
773
+ '```bash',
774
+ 'pnpm add -D @ahmednawaz/crank',
775
+ '```',
776
+ '',
777
+ '```tsx',
778
+ "import { Crank } from '@ahmednawaz/crank/react'",
779
+ '',
780
+ '// main.tsx — mount once',
781
+ '<App />',
782
+ '<Crank />',
783
+ '```',
784
+ '',
785
+ 'Companion must be running: `npx @ahmednawaz/crank start`',
786
+ '',
787
+ 'URL resolution (first match):',
788
+ '1. `window.__CRANK_URL__`',
789
+ '2. `<Crank companionUrl="…" />` / `url` prop',
790
+ '3. `VITE_CRANK_URL` / `VITE_CRANK_COMPANION_URL`',
791
+ '4. `http://localhost:3344`',
792
+ '',
793
+ 'See `.crank/react-mount.md` when this project has React.',
794
+ '',
795
+ '## Next.js',
796
+ '',
797
+ '```tsx',
798
+ "import { Crank } from '@ahmednawaz/crank/next'",
799
+ '<Crank />',
800
+ '```',
801
+ '',
802
+ '## Non-React Vite (Vue / Svelte / vanilla)',
803
+ '',
804
+ 'Optional Vite plugin: `import { crank } from "@ahmednawaz/crank/vite"` — only if you are not using React.',
805
+ '',
806
+ '## Bookmarklet fallback',
807
+ '',
808
+ 'Open the companion URL → drag the bookmarklet onto any preview page.',
809
+ '',
810
+ '## Detected in this project',
811
+ '',
812
+ `- hasReact: ${Boolean(env.hasReact)}`,
813
+ `- hasVite: ${Boolean(env.hasVite)}`,
814
+ `- viteConfig: ${env.viteConfig || '(none)'}`,
815
+ `- hasNext: ${Boolean(env.hasNext)}`,
816
+ `- hasWebpack: ${Boolean(env.hasWebpack)}`,
817
+ ''
818
+ ];
819
+ fs.writeFileSync(guide, lines.join('\n'));
820
+ results.wrote.push('.crank/injection.md');
821
+ }
822
+
533
823
  function printSetupNextSteps(env, runtime, results) {
534
824
  console.log('');
535
825
  console.log(' Setup complete (Retune-style):');
@@ -539,6 +829,16 @@ function printSetupNextSteps(env, runtime, results) {
539
829
  if (results?.wrote?.some((w) => String(w).includes('skills'))) {
540
830
  console.log(' • Skill crank-apply-changes installed (auto-syncs on start)');
541
831
  }
832
+ if (results?.reactMount || env.hasReact) {
833
+ console.log(' • React: mount <Crank /> from @ahmednawaz/crank/react (see .crank/react-mount.md)');
834
+ } else if (results?.vite?.patched) {
835
+ console.log(' • Vite plugin patched (non-React stack)');
836
+ } else if (env.hasVite) {
837
+ console.log(' • Vite detected — see .crank/injection.md');
838
+ } else if (env.hasNext) {
839
+ console.log(' • Next.js — mount <Crank /> from @ahmednawaz/crank/next');
840
+ }
841
+ console.log(' • Injection guide: .crank/injection.md');
542
842
  if (env.isReplit) {
543
843
  console.log(' • Workflow command: npx @ahmednawaz/crank start');
544
844
  console.log(' • Add CRANK_TEAM_TOKEN in Secrets (not in the command)');
@@ -591,8 +891,10 @@ function writeReplitGuide(projectRoot, detectEnv, results, runtime) {
591
891
  '## Panel in preview',
592
892
  '',
593
893
  `- Companion: ${companionUrl}`,
594
- '- Vite apps: panel auto-loads in the webview.',
595
- '- Other stacks: open the companion URL and use the bookmarklet on your preview tab.',
894
+ '- **React (preferred):** `pnpm add -D @ahmednawaz/crank` then `<Crank />` from `@ahmednawaz/crank/react` (Retune pattern).',
895
+ '- Set `window.__CRANK_URL__` or `VITE_CRANK_URL` to the public companion URL on Replit.',
896
+ '- Keep companion Workflow running: `npx @ahmednawaz/crank start`.',
897
+ '- See `.crank/react-mount.md` and `.crank/injection.md`.',
596
898
  ''
597
899
  ].join('\n')
598
900
  );
@@ -608,5 +910,11 @@ module.exports = {
608
910
  configureMcpRetuneStyle,
609
911
  printSetupNextSteps,
610
912
  writeReplitGuide,
611
- patchReplitConfig
913
+ writeReplitPlatformGuide,
914
+ writeVitePluginStub,
915
+ patchViteConfig,
916
+ writeInjectionGuide,
917
+ writeReactMountHint,
918
+ patchReplitConfig,
919
+ copyAgentsDoc
612
920
  };
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared companion loader URL for Vite / React / Next / webpack adapters.
5
+ * All paths inject the same /dev-loader.js → bookmarklet panel (not a React UI tree).
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { env } = require('./env');
11
+ const { readRuntimeManifest } = require('./urls');
12
+
13
+ function findProjectRoot(startDir) {
14
+ let dir = path.resolve(startDir || process.cwd());
15
+ for (let i = 0; i < 8; i++) {
16
+ if (fs.existsSync(path.join(dir, '.crank', 'runtime.json'))) {
17
+ return dir;
18
+ }
19
+ if (fs.existsSync(path.join(dir, 'package.json'))) {
20
+ return dir;
21
+ }
22
+ const parent = path.dirname(dir);
23
+ if (parent === dir) break;
24
+ dir = parent;
25
+ }
26
+ return path.resolve(startDir || process.cwd());
27
+ }
28
+
29
+ function resolveCompanionLoaderUrl(options = {}) {
30
+ if (options.src) {
31
+ return String(options.src);
32
+ }
33
+
34
+ const projectRoot = options.projectRoot || findProjectRoot(process.cwd());
35
+ const runtime = readRuntimeManifest(projectRoot);
36
+ if (runtime?.companion?.devLoader) {
37
+ return runtime.companion.devLoader;
38
+ }
39
+
40
+ const publicUrl = (
41
+ options.companionUrl ||
42
+ env('PUBLIC_URL', '') ||
43
+ ''
44
+ ).replace(/\/$/, '');
45
+
46
+ const port = String(options.port || env('PORT', '3344')).replace(/[^0-9]/g, '') || '3344';
47
+ const host = options.host || env('HOST', '127.0.0.1');
48
+ const base = publicUrl || `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${port}`;
49
+ return `${base}/dev-loader.js`;
50
+ }
51
+
52
+ module.exports = {
53
+ findProjectRoot,
54
+ resolveCompanionLoaderUrl
55
+ };
package/next.js ADDED
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * CJS Next adapter — prefer ESM `next.mjs` via package exports.
5
+ */
6
+
7
+ const {
8
+ Crank: CrankEffect,
9
+ resolveCrankLoaderSrc,
10
+ resolveCrankCompanionUrl
11
+ } = require('./react.js');
12
+
13
+ function getReact() {
14
+ try {
15
+ return require('react');
16
+ } catch {
17
+ throw new Error('@ahmednawaz/crank/next requires react');
18
+ }
19
+ }
20
+
21
+ function Crank(props) {
22
+ const React = getReact();
23
+ props = props || {};
24
+ const src = resolveCrankLoaderSrc(props);
25
+ const strategy = props.strategy || 'afterInteractive';
26
+
27
+ let Script = null;
28
+ try {
29
+ const mod = require('next/script');
30
+ Script = mod.default || mod;
31
+ } catch {
32
+ Script = null;
33
+ }
34
+
35
+ if (Script) {
36
+ return React.createElement(Script, {
37
+ src: src.indexOf('?') >= 0 ? src : src + '?t=' + Date.now(),
38
+ strategy,
39
+ 'data-crank-loader': '1'
40
+ });
41
+ }
42
+
43
+ return React.createElement(CrankEffect, props);
44
+ }
45
+
46
+ module.exports = {
47
+ Crank,
48
+ CrankScript: Crank,
49
+ CrankLoader: Crank,
50
+ resolveCrankLoaderSrc,
51
+ resolveCrankCompanionUrl,
52
+ default: Crank
53
+ };
package/next.mjs ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Next.js entry — same Retune-style `<Crank />` (client component).
3
+ *
4
+ * import { Crank } from '@ahmednawaz/crank/next'
5
+ * <Crank />
6
+ */
7
+
8
+ 'use client';
9
+
10
+ export {
11
+ Crank,
12
+ CrankLoader,
13
+ resolveCrankCompanionUrl,
14
+ resolveCrankLoaderSrc
15
+ } from './react.mjs';
16
+
17
+ export { Crank as CrankScript, Crank as default } from './react.mjs';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahmednawaz/crank",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "description": "Internal Motive UX copy iteration: select UI text → Glean variants → MCP apply. Cursor, Replit, Claude.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,12 +10,47 @@
10
10
  "bin": {
11
11
  "crank": "bin/crank.js"
12
12
  },
13
- "main": "vite.js",
13
+ "main": "./react.js",
14
+ "module": "./react.mjs",
15
+ "types": "./react.d.ts",
14
16
  "exports": {
15
- ".": "./vite.js",
17
+ ".": {
18
+ "types": "./react.d.ts",
19
+ "import": "./react.mjs",
20
+ "require": "./react.js",
21
+ "default": "./react.mjs"
22
+ },
23
+ "./react": {
24
+ "types": "./react.d.ts",
25
+ "import": "./react.mjs",
26
+ "require": "./react.js",
27
+ "default": "./react.mjs"
28
+ },
29
+ "./next": {
30
+ "import": "./next.mjs",
31
+ "require": "./next.js",
32
+ "default": "./next.mjs"
33
+ },
16
34
  "./vite": "./vite.js",
35
+ "./webpack": "./webpack.js",
17
36
  "./package.json": "./package.json"
18
37
  },
38
+ "peerDependencies": {
39
+ "html-webpack-plugin": ">=5",
40
+ "next": ">=13",
41
+ "react": ">=17"
42
+ },
43
+ "peerDependenciesMeta": {
44
+ "react": {
45
+ "optional": true
46
+ },
47
+ "next": {
48
+ "optional": true
49
+ },
50
+ "html-webpack-plugin": {
51
+ "optional": true
52
+ }
53
+ },
19
54
  "workspaces": [
20
55
  "companion",
21
56
  "mcp-server"
@@ -57,6 +92,12 @@
57
92
  "skill",
58
93
  "panel",
59
94
  "vite.js",
95
+ "react.js",
96
+ "react.mjs",
97
+ "react.d.ts",
98
+ "next.js",
99
+ "next.mjs",
100
+ "webpack.js",
60
101
  ".env.example",
61
102
  "AGENTS.md",
62
103
  "REPLIT.md",
@@ -71,6 +112,7 @@
71
112
  "glean",
72
113
  "mcp",
73
114
  "copy",
74
- "ux-writing"
115
+ "ux-writing",
116
+ "react"
75
117
  ]
76
118
  }
package/react.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ import type { ReactElement } from 'react';
2
+
3
+ export interface CrankProps {
4
+ /** Full loader script URL (overrides companionUrl). */
5
+ src?: string;
6
+ /** Companion base URL, e.g. https://3344-….replit.dev */
7
+ companionUrl?: string;
8
+ /** Alias for companionUrl. */
9
+ url?: string;
10
+ }
11
+
12
+ export declare function resolveCrankCompanionUrl(props?: CrankProps): string;
13
+ export declare function resolveCrankLoaderSrc(props?: CrankProps): string;
14
+
15
+ /**
16
+ * Retune-style mount — injects companion `/dev-loader.js` once. Renders null.
17
+ *
18
+ * import { Crank } from '@ahmednawaz/crank/react'
19
+ * <Crank />
20
+ *
21
+ * Optional: `window.__CRANK_URL__` or `VITE_CRANK_URL` for Replit public companion host.
22
+ * Keep companion running: `npx @ahmednawaz/crank start`
23
+ */
24
+ export declare function Crank(props?: CrankProps): ReactElement | null;
25
+ export declare const CrankLoader: typeof Crank;
26
+ export default Crank;
27
+
28
+ declare global {
29
+ interface Window {
30
+ __CRANK_URL__?: string;
31
+ __CRANK_LOADER__?: boolean;
32
+ }
33
+ }
34
+
35
+ export {};
package/react.js ADDED
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * CJS build of @ahmednawaz/crank/react (browser-safe, no Node/fs).
5
+ * Vite / modern apps should import the ESM build via package exports "import" condition.
6
+ */
7
+
8
+ function getReact() {
9
+ try {
10
+ return require('react');
11
+ } catch {
12
+ throw new Error(
13
+ '@ahmednawaz/crank/react requires react as a peer dependency (npm i react / pnpm add react).'
14
+ );
15
+ }
16
+ }
17
+
18
+ function resolveCrankCompanionUrl(props) {
19
+ props = props || {};
20
+ if (typeof window !== 'undefined' && window.__CRANK_URL__) {
21
+ return String(window.__CRANK_URL__).replace(/\/$/, '');
22
+ }
23
+ if (props.companionUrl) {
24
+ return String(props.companionUrl).replace(/\/$/, '');
25
+ }
26
+ if (props.url) {
27
+ return String(props.url).replace(/\/$/, '');
28
+ }
29
+ return 'http://localhost:3344';
30
+ }
31
+
32
+ function resolveCrankLoaderSrc(props) {
33
+ props = props || {};
34
+ if (props.src) {
35
+ return String(props.src);
36
+ }
37
+ return `${resolveCrankCompanionUrl(props)}/dev-loader.js`;
38
+ }
39
+
40
+ function Crank(props) {
41
+ const React = getReact();
42
+ props = props || {};
43
+ const src = resolveCrankLoaderSrc(props);
44
+
45
+ React.useEffect(() => {
46
+ if (typeof document === 'undefined') {
47
+ return undefined;
48
+ }
49
+ if (document.querySelector('script[data-crank-loader="1"]')) {
50
+ return undefined;
51
+ }
52
+ if (window.__CRANK_LOADER__ && document.getElementById('vp-shell')) {
53
+ return undefined;
54
+ }
55
+
56
+ const script = document.createElement('script');
57
+ script.src = src.indexOf('?') >= 0 ? src : src + '?t=' + Date.now();
58
+ script.async = true;
59
+ script.dataset.crankLoader = '1';
60
+ document.documentElement.appendChild(script);
61
+ return undefined;
62
+ }, [src]);
63
+
64
+ return null;
65
+ }
66
+
67
+ module.exports = {
68
+ Crank,
69
+ CrankLoader: Crank,
70
+ resolveCrankCompanionUrl,
71
+ resolveCrankLoaderSrc,
72
+ default: Crank
73
+ };
package/react.mjs ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Retune-style React entry — browser-only, Vite-bundlable (no Node/fs).
3
+ *
4
+ * // main.tsx
5
+ * import { Crank } from '@ahmednawaz/crank/react'
6
+ * // or: import { Crank } from '@ahmednawaz/crank'
7
+ *
8
+ * <Crank />
9
+ *
10
+ * Mount near your app root. Injects companion `/dev-loader.js` once (same panel as the
11
+ * optional Vite plugin). Requires the companion process: `npx @ahmednawaz/crank start`
12
+ *
13
+ * peerDependency: react >= 17
14
+ */
15
+
16
+ import { useEffect } from 'react';
17
+
18
+ /**
19
+ * @param {{ companionUrl?: string, url?: string, src?: string }} [props]
20
+ * @returns {string}
21
+ */
22
+ export function resolveCrankCompanionUrl(props = {}) {
23
+ if (typeof window !== 'undefined' && window.__CRANK_URL__) {
24
+ return String(window.__CRANK_URL__).replace(/\/$/, '');
25
+ }
26
+ if (props.companionUrl) {
27
+ return String(props.companionUrl).replace(/\/$/, '');
28
+ }
29
+ if (props.url) {
30
+ return String(props.url).replace(/\/$/, '');
31
+ }
32
+ try {
33
+ const envUrl =
34
+ typeof import.meta !== 'undefined' &&
35
+ import.meta.env &&
36
+ (import.meta.env.VITE_CRANK_URL || import.meta.env.VITE_CRANK_COMPANION_URL);
37
+ if (envUrl) {
38
+ return String(envUrl).replace(/\/$/, '');
39
+ }
40
+ } catch {
41
+ // ignore
42
+ }
43
+ return 'http://localhost:3344';
44
+ }
45
+
46
+ /**
47
+ * @param {{ companionUrl?: string, url?: string, src?: string }} [props]
48
+ * @returns {string}
49
+ */
50
+ export function resolveCrankLoaderSrc(props = {}) {
51
+ if (props.src) {
52
+ return String(props.src);
53
+ }
54
+ return `${resolveCrankCompanionUrl(props)}/dev-loader.js`;
55
+ }
56
+
57
+ /**
58
+ * Retune-style one-liner. Renders null; injects the companion loader script on the client.
59
+ *
60
+ * Do not set window.__CRANK_LOADER__ here — `/dev-loader.js` owns that flag and loads the panel.
61
+ *
62
+ * @param {{ companionUrl?: string, url?: string, src?: string }} [props]
63
+ */
64
+ export function Crank(props = {}) {
65
+ const src = resolveCrankLoaderSrc(props);
66
+
67
+ useEffect(() => {
68
+ if (typeof document === 'undefined') {
69
+ return undefined;
70
+ }
71
+ if (document.querySelector('script[data-crank-loader="1"]')) {
72
+ return undefined;
73
+ }
74
+ // Panel already live (bookmarklet / prior mount)
75
+ if (window.__CRANK_LOADER__ && document.getElementById('vp-shell')) {
76
+ return undefined;
77
+ }
78
+
79
+ const script = document.createElement('script');
80
+ script.src = src.includes('?') ? src : `${src}?t=${Date.now()}`;
81
+ script.async = true;
82
+ script.dataset.crankLoader = '1';
83
+ document.documentElement.appendChild(script);
84
+ return undefined;
85
+ }, [src]);
86
+
87
+ return null;
88
+ }
89
+
90
+ /** Explicit alias. */
91
+ export const CrankLoader = Crank;
92
+
93
+ export default Crank;
package/skill/SKILL.md CHANGED
@@ -33,6 +33,7 @@ When the user asks to start Crank:
33
33
  ```
34
34
  `start` skips setup/init — companion on **3344** (+ HTTP MCP on **3345** on Replit). Add `CRANK_TEAM_TOKEN` in Secrets first.
35
35
  4. Call **`crank_get_environment`** after the companion is up.
36
+ 5. **Panel (React):** `pnpm add -D @ahmednawaz/crank` then mount `<Crank />` from `@ahmednawaz/crank/react` in `main.tsx` (Retune pattern — no Vite plugin). Keep companion Workflow running. See `.crank/react-mount.md`.
36
37
 
37
38
  ## When the user says "start Crank" or pastes `npx crank`
38
39
 
package/vite.js CHANGED
@@ -1,53 +1,19 @@
1
1
  'use strict';
2
2
 
3
- const fs = require('fs');
4
- const path = require('path');
5
- const { env } = require('./lib/env');
6
- const { readRuntimeManifest } = require('./lib/urls');
7
-
8
3
  /**
9
4
  * Vite / Rolldown-vite / Vitest-compatible plugin.
10
- * Injects Crank into the app during `vite` / `npm run dev` — no bookmarklet needed.
5
+ * Injects Crank into the app during `vite` / `npm run dev` — no React component needed.
11
6
  *
12
7
  * // vite.config.ts
13
- * import { crank } from 'crank/vite'
8
+ * import { crank } from '@ahmednawaz/crank/vite'
9
+ * // or: import { crank } from 'crank/vite'
14
10
  * export default { plugins: [crank()] }
11
+ *
12
+ * Prefer this for Vite apps (React, Vue, Svelte, vanilla). The panel is a script overlay,
13
+ * not a component in your React tree.
15
14
  */
16
15
 
17
- function findProjectRoot(startDir) {
18
- let dir = path.resolve(startDir || process.cwd());
19
- for (let i = 0; i < 8; i++) {
20
- if (fs.existsSync(path.join(dir, '.crank', 'runtime.json'))) {
21
- return dir;
22
- }
23
- if (fs.existsSync(path.join(dir, 'package.json'))) {
24
- return dir;
25
- }
26
- const parent = path.dirname(dir);
27
- if (parent === dir) break;
28
- dir = parent;
29
- }
30
- return path.resolve(startDir || process.cwd());
31
- }
32
-
33
- function resolveCompanionLoaderUrl(options = {}) {
34
- const projectRoot = options.projectRoot || findProjectRoot(process.cwd());
35
- const runtime = readRuntimeManifest(projectRoot);
36
- if (runtime?.companion?.devLoader) {
37
- return runtime.companion.devLoader;
38
- }
39
-
40
- const publicUrl = (
41
- options.companionUrl ||
42
- env('PUBLIC_URL', '') ||
43
- ''
44
- ).replace(/\/$/, '');
45
-
46
- const port = String(options.port || env('PORT', '3344')).replace(/[^0-9]/g, '') || '3344';
47
- const host = options.host || env('HOST', '127.0.0.1');
48
- const base = publicUrl || `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${port}`;
49
- return `${base}/dev-loader.js`;
50
- }
16
+ const { resolveCompanionLoaderUrl } = require('./lib/loader-url');
51
17
 
52
18
  function crank(options = {}) {
53
19
  const src = resolveCompanionLoaderUrl(options);
package/webpack.js ADDED
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Optional webpack adapter — injects /dev-loader.js via html-webpack-plugin.
5
+ *
6
+ * // webpack.config.js
7
+ * const { crankWebpackPlugin } = require('@ahmednawaz/crank/webpack')
8
+ * plugins: [new HtmlWebpackPlugin(...), crankWebpackPlugin()]
9
+ *
10
+ * Prefer @ahmednawaz/crank/vite when the project uses Vite.
11
+ */
12
+
13
+ const { resolveCompanionLoaderUrl } = require('./lib/loader-url');
14
+
15
+ function crankWebpackPlugin(options = {}) {
16
+ const src = resolveCompanionLoaderUrl(options);
17
+
18
+ return {
19
+ apply(compiler) {
20
+ compiler.hooks.compilation.tap('CrankWebpackPlugin', (compilation) => {
21
+ let hooks;
22
+ try {
23
+ // eslint-disable-next-line import/no-extraneous-dependencies
24
+ const HtmlWebpackPlugin = require('html-webpack-plugin');
25
+ hooks = HtmlWebpackPlugin.getHooks(compilation);
26
+ } catch {
27
+ compilation.errors.push(
28
+ new Error(
29
+ '@ahmednawaz/crank/webpack requires html-webpack-plugin. Prefer the Vite plugin when possible.'
30
+ )
31
+ );
32
+ return;
33
+ }
34
+
35
+ hooks.alterAssetTagGroups.tap('CrankWebpackPlugin', (data) => {
36
+ const already = (data.headTags || []).some(
37
+ (t) =>
38
+ t.tagName === 'script' &&
39
+ String(t.attributes?.src || '').includes('dev-loader.js')
40
+ );
41
+ if (!already) {
42
+ data.headTags.push({
43
+ tagName: 'script',
44
+ voidTag: false,
45
+ attributes: { src, defer: true, 'data-crank-loader': '1' }
46
+ });
47
+ }
48
+ return data;
49
+ });
50
+ });
51
+ }
52
+ };
53
+ }
54
+
55
+ module.exports = {
56
+ crankWebpackPlugin,
57
+ resolveCompanionLoaderUrl
58
+ };