@ahmednawaz/crank 0.2.0 → 0.2.3

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
@@ -105,33 +105,30 @@ See **[REPLIT.md](./REPLIT.md)** and `.crank/replit.md` (URLs after first run).
105
105
 
106
106
  ## Panel injection (Retune pattern)
107
107
 
108
- **Preferred for React apps** — install the package and mount once:
108
+ **Preferred** — CLI inject (no manual edits):
109
109
 
110
110
  ```bash
111
- pnpm add -D @ahmednawaz/crank
112
- # or: npm i -D @ahmednawaz/crank
111
+ npx @ahmednawaz/crank@latest setup
112
+ npx @ahmednawaz/crank inject
113
+ npx @ahmednawaz/crank start
113
114
  ```
114
115
 
116
+ `inject` finds `main.tsx` / `createRoot` / `ReactDOM.render`, adds:
117
+
115
118
  ```tsx
116
119
  import { Crank } from '@ahmednawaz/crank/react'
117
-
118
- // main.tsx
119
- <App />
120
+ // ...
120
121
  <Crank />
121
122
  ```
122
123
 
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`.
124
+ Same idea as Sentry/Clerk setup CLIs. See `.crank/react-mount.md` after setup.
126
125
 
127
126
  | Stack | Integration |
128
127
  |-------|-------------|
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.
128
+ | **React** | `npx @ahmednawaz/crank inject` |
129
+ | **Next.js** | same inject, or `import { Crank } from '@ahmednawaz/crank/next'` |
130
+ | **Non-React Vite** | Optional `@ahmednawaz/crank/vite` plugin |
131
+ | **Fallback** | Companion bookmarklet |
135
132
 
136
133
  ---
137
134
 
package/README.md CHANGED
@@ -102,35 +102,18 @@ 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:
105
+ ### Panel injection (Retune-style)
108
106
 
109
107
  ```bash
110
- pnpm add -D @ahmednawaz/crank
108
+ npx @ahmednawaz/crank@latest setup
109
+ npx @ahmednawaz/crank inject # patches main.tsx — no manual edits
110
+ npx @ahmednawaz/crank start
111
111
  ```
112
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
- ```
113
+ `inject` installs the package and adds `import { Crank }` + `<Crank />` (Sentry/Clerk-style).
123
114
 
124
- Companion: `npx @ahmednawaz/crank start`
125
115
  Replit public URL: `window.__CRANK_URL__` or `<Crank companionUrl="https://3344-….replit.dev" />` or `VITE_CRANK_URL`.
126
116
 
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
-
134
117
  Useful variants:
135
118
 
136
119
  ```bash
package/bin/crank.js CHANGED
@@ -31,6 +31,7 @@ const {
31
31
  readRuntimeManifest
32
32
  } = require('../lib/urls');
33
33
  const { resolveProjectRoot, describeEnvironment } = require('../lib/resolve-project');
34
+ const { injectCrank } = require('../lib/wire-react');
34
35
 
35
36
  const LOG_FILE = env('LOG', '/tmp/crank-companion.log');
36
37
  const PID_FILE = env('PID_FILE', '/tmp/crank-companion.pid');
@@ -39,11 +40,12 @@ function printHelp() {
39
40
  process.stdout.write(`Usage: crank [command] [project-root]
40
41
 
41
42
  Commands:
42
- (default) Init + start everything (companion, MCP, dev server when present)
43
- setup Same as init Retune-style one-time config (alias for init)
44
- init Only write IDE / Vite / MCP setup into the project
45
- mcp Start stdio MCP server (used by npx crank mcp in IDE configs)
46
- start Start companion (assumes init already done)
43
+ (default) Init + start companion (and npm run dev when present)
44
+ setup One-time config (MCP, skill, package.json) alias for init
45
+ init Same as setup
46
+ inject Patch main.tsx / entry with import { Crank } + <Crank /> (Retune-style)
47
+ mcp Start stdio MCP server (used by IDE MCP configs)
48
+ start Start companion (assumes setup already done)
47
49
  stop Stop daemon companion + HTTP MCP listeners
48
50
  doctor Print detected environment + runtime URLs
49
51
  status Print whether companion /health responds
@@ -60,11 +62,16 @@ Flags:
60
62
  --http-mcp Force HTTP MCP (Replit auto-enables this)
61
63
  -h, --help Show help
62
64
 
65
+ First run (React / Retune-style):
66
+ npx @ahmednawaz/crank@latest setup
67
+ npx @ahmednawaz/crank inject
68
+ npx @ahmednawaz/crank start
69
+
63
70
  Examples:
64
- cd ~/code/my-app && npx crank setup && npx crank
65
- npx crank
66
- npx crank mcp
67
- npx crank stop
71
+ cd ~/code/my-app && npx @ahmednawaz/crank setup && npx @ahmednawaz/crank inject
72
+ npx @ahmednawaz/crank
73
+ npx @ahmednawaz/crank mcp
74
+ npx @ahmednawaz/crank stop
68
75
  `);
69
76
  }
70
77
 
@@ -113,7 +120,7 @@ function parseArgs(argv) {
113
120
  }
114
121
  let command = 'run';
115
122
  let projectRoot = process.cwd();
116
- const cmds = new Set(['init', 'setup', 'start', 'doctor', 'stop', 'status', 'mcp']);
123
+ const cmds = new Set(['init', 'setup', 'start', 'doctor', 'stop', 'status', 'mcp', 'inject']);
117
124
  if (positional[0] === 'setup') {
118
125
  command = 'init';
119
126
  } else if (cmds.has(positional[0])) {
@@ -617,6 +624,41 @@ async function main() {
617
624
  return;
618
625
  }
619
626
 
627
+ if (command === 'inject') {
628
+ if (!fs.existsSync(projectRoot) || !fs.statSync(projectRoot).isDirectory()) {
629
+ console.error(`Project root not found: ${projectRoot}`);
630
+ process.exit(1);
631
+ }
632
+ console.log(`[Crank] Injecting <Crank /> into ${projectRoot}`);
633
+ const result = injectCrank(projectRoot, { install: true });
634
+ if (result.installed) {
635
+ console.log(`[Crank] Installed package (${result.installCommand})`);
636
+ } else if (result.installError) {
637
+ console.warn(`[Crank] Package install warning: ${result.installError}`);
638
+ if (result.installHint) {
639
+ console.warn(`[Crank] Run manually:\n ${result.installHint}`);
640
+ } else {
641
+ console.warn('[Crank] Try: pnpm add -D @ahmednawaz/crank');
642
+ }
643
+ }
644
+ if (result.reactWire?.wired) {
645
+ console.log(`[Crank] Patched ${result.reactWire.file}`);
646
+ console.log('[Crank] Added: import { Crank } from \'@ahmednawaz/crank/react\' + <Crank />');
647
+ console.log('[Crank] Next: npx @ahmednawaz/crank start');
648
+ process.exit(0);
649
+ }
650
+ if (result.reactWire?.reason === 'already-present') {
651
+ console.log(`[Crank] Already injected in ${result.reactWire.file}`);
652
+ process.exit(0);
653
+ }
654
+ console.error(
655
+ `[Crank] Could not inject (${result.reactWire?.reason || 'unknown'}).` +
656
+ (result.reactWire?.file ? ` Looked at ${result.reactWire.file}.` : '') +
657
+ ' Add manually:\n\n import { Crank } from \'@ahmednawaz/crank/react\'\n <Crank />\n'
658
+ );
659
+ process.exit(1);
660
+ }
661
+
620
662
  if (!fs.existsSync(projectRoot) || !fs.statSync(projectRoot).isDirectory()) {
621
663
  console.error(`Project root not found: ${projectRoot}`);
622
664
  process.exit(1);
package/lib/init.js CHANGED
@@ -12,6 +12,7 @@ const {
12
12
 
13
13
  const PACKAGE_ROOT = path.resolve(__dirname, '..');
14
14
  const { resolveUrls } = require('./urls');
15
+ const { installCrankPackage } = require('./wire-react');
15
16
 
16
17
  function ensureGitignore(projectRoot) {
17
18
  const file = path.join(projectRoot, '.gitignore');
@@ -527,9 +528,7 @@ function initProject(projectRoot, options = {}) {
527
528
 
528
529
  // MCP + skill (Retune-style: global IDE config, automatic skill sync)
529
530
  if (options.mcp !== false) {
530
- if (!env.isReplit) {
531
- ensureCrankDependency(projectRoot, results);
532
- }
531
+ ensureCrankDependency(projectRoot, results);
533
532
  configureMcpRetuneStyle(env, projectRoot, results, options);
534
533
  }
535
534
 
@@ -537,9 +536,10 @@ function initProject(projectRoot, options = {}) {
537
536
  installCrankSkill(projectRoot, results);
538
537
  }
539
538
 
540
- // Retune-style: React apps mount <Crank /> do not rely on Vite plugin path hacks.
539
+ // React apps: install dep + write out inject (Sentry/Clerk-style). Do not patch source here.
541
540
  if (env.hasReact && options.react !== false) {
542
541
  writeReactMountHint(projectRoot, env, results);
542
+ results.reactMount = true;
543
543
  } else if (env.hasVite && options.vite !== false) {
544
544
  const viteResult = patchViteConfig(projectRoot);
545
545
  results.vite = viteResult;
@@ -563,7 +563,7 @@ function initProject(projectRoot, options = {}) {
563
563
  const pkg = readJson(pkgPath, {});
564
564
  pkg.scripts = pkg.scripts || {};
565
565
  if (!pkg.scripts.crank) {
566
- pkg.scripts.crank = 'crank';
566
+ pkg.scripts.crank = 'npx @ahmednawaz/crank';
567
567
  writeJson(pkgPath, pkg);
568
568
  results.wrote.push('package.json#scripts.crank');
569
569
  }
@@ -573,14 +573,9 @@ function initProject(projectRoot, options = {}) {
573
573
  writeReplitPlatformGuide(projectRoot, results);
574
574
  }
575
575
 
576
- if (results.crankDependencyAdded && options.install !== false && !env.isReplit) {
577
- try {
578
- const { execSync } = require('child_process');
579
- execSync('npm install', { cwd: projectRoot, stdio: 'inherit' });
580
- results.installed = true;
581
- } catch (err) {
582
- results.installError = err.message;
583
- }
576
+ if (options.install !== false && results.crankDependencyAdded) {
577
+ const install = installCrankPackage(projectRoot);
578
+ Object.assign(results, install);
584
579
  }
585
580
 
586
581
  return results;
@@ -717,27 +712,21 @@ function writeReactMountHint(projectRoot, env, results) {
717
712
  [
718
713
  '# Mount Crank (Retune-style)',
719
714
  '',
720
- '1. Install (from the app package in a monorepo):',
715
+ 'Run (preferred no manual edits):',
721
716
  '',
722
717
  '```bash',
723
- 'pnpm add -D @ahmednawaz/crank',
724
- '# or: npm i -D @ahmednawaz/crank',
718
+ 'npx @ahmednawaz/crank inject',
725
719
  '```',
726
720
  '',
727
- '2. Mount once near your app root (e.g. `main.tsx` / `App.tsx`):',
721
+ 'That installs `@ahmednawaz/crank` and patches your entry file with:',
728
722
  '',
729
723
  '```tsx',
730
724
  "import { Crank } from '@ahmednawaz/crank/react'",
731
725
  '',
732
- 'createRoot(document.getElementById("root")!).render(',
733
- ' <>',
734
- ' <App />',
735
- ' <Crank />',
736
- ' </>',
737
- ')',
726
+ '<Crank />',
738
727
  '```',
739
728
  '',
740
- '3. Keep the companion running:',
729
+ 'Then start the companion:',
741
730
  '',
742
731
  '```bash',
743
732
  'npx @ahmednawaz/crank start',
@@ -765,25 +754,23 @@ function writeInjectionGuide(projectRoot, env, results) {
765
754
  const lines = [
766
755
  '# Crank panel injection',
767
756
  '',
768
- '## Preferred — React component (Retune pattern)',
757
+ '## Preferred — `npx @ahmednawaz/crank inject`',
769
758
  '',
770
- 'Crank ships a normal npm React export. Vite bundles it like any other dependency.',
771
- '**No Vite plugin. No npx-cache paths.**',
759
+ 'Patches your entry file automatically (Sentry/Clerk-style). No manual edits.',
772
760
  '',
773
761
  '```bash',
774
- 'pnpm add -D @ahmednawaz/crank',
762
+ 'npx @ahmednawaz/crank@latest setup',
763
+ 'npx @ahmednawaz/crank inject',
764
+ 'npx @ahmednawaz/crank start',
775
765
  '```',
776
766
  '',
767
+ 'Adds:',
768
+ '',
777
769
  '```tsx',
778
770
  "import { Crank } from '@ahmednawaz/crank/react'",
779
- '',
780
- '// main.tsx — mount once',
781
- '<App />',
782
771
  '<Crank />',
783
772
  '```',
784
773
  '',
785
- 'Companion must be running: `npx @ahmednawaz/crank start`',
786
- '',
787
774
  'URL resolution (first match):',
788
775
  '1. `window.__CRANK_URL__`',
789
776
  '2. `<Crank companionUrl="…" />` / `url` prop',
@@ -794,9 +781,8 @@ function writeInjectionGuide(projectRoot, env, results) {
794
781
  '',
795
782
  '## Next.js',
796
783
  '',
797
- '```tsx',
798
- "import { Crank } from '@ahmednawaz/crank/next'",
799
- '<Crank />',
784
+ '```bash',
785
+ 'npx @ahmednawaz/crank inject',
800
786
  '```',
801
787
  '',
802
788
  '## Non-React Vite (Vue / Svelte / vanilla)',
@@ -830,14 +816,15 @@ function printSetupNextSteps(env, runtime, results) {
830
816
  console.log(' • Skill crank-apply-changes installed (auto-syncs on start)');
831
817
  }
832
818
  if (results?.reactMount || env.hasReact) {
833
- console.log(' • React: mount <Crank /> from @ahmednawaz/crank/react (see .crank/react-mount.md)');
819
+ console.log(' • Next: npx @ahmednawaz/crank inject # patches main.tsx with <Crank />');
834
820
  } else if (results?.vite?.patched) {
835
821
  console.log(' • Vite plugin patched (non-React stack)');
836
822
  } else if (env.hasVite) {
837
823
  console.log(' • Vite detected — see .crank/injection.md');
838
824
  } else if (env.hasNext) {
839
- console.log(' • Next.js — mount <Crank /> from @ahmednawaz/crank/next');
825
+ console.log(' • Next.js — run: npx @ahmednawaz/crank inject');
840
826
  }
827
+ console.log(' • Then: npx @ahmednawaz/crank start');
841
828
  console.log(' • Injection guide: .crank/injection.md');
842
829
  if (env.isReplit) {
843
830
  console.log(' • Workflow command: npx @ahmednawaz/crank start');
@@ -0,0 +1,408 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Auto-wire Retune-style <Crank /> into the app entry file (Sentry/Clerk-style inject).
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { exists } = require('./detect');
10
+
11
+ const ENTRY_CANDIDATES = [
12
+ 'src/main.tsx',
13
+ 'src/main.jsx',
14
+ 'src/main.ts',
15
+ 'src/main.js',
16
+ 'src/index.tsx',
17
+ 'src/index.jsx',
18
+ 'src/index.ts',
19
+ 'src/index.js',
20
+ 'client/src/main.tsx',
21
+ 'client/src/main.jsx',
22
+ 'client/src/index.tsx',
23
+ 'app/main.tsx',
24
+ 'src/App.tsx',
25
+ 'src/App.jsx',
26
+ 'App.tsx',
27
+ 'App.jsx',
28
+ 'main.tsx',
29
+ 'main.jsx',
30
+ 'index.tsx',
31
+ 'index.jsx'
32
+ ];
33
+
34
+ const SKIP_DIRS = new Set([
35
+ 'node_modules',
36
+ '.git',
37
+ 'dist',
38
+ 'build',
39
+ '.next',
40
+ 'coverage',
41
+ '.turbo',
42
+ '.cache',
43
+ '.crank'
44
+ ]);
45
+
46
+ function findEntryByCreateRoot(projectRoot) {
47
+ const queue = [{ dir: path.resolve(projectRoot), depth: 0 }];
48
+ const hits = [];
49
+ while (queue.length) {
50
+ const { dir, depth } = queue.shift();
51
+ if (depth > 3) continue;
52
+ let entries;
53
+ try {
54
+ entries = fs.readdirSync(dir, { withFileTypes: true });
55
+ } catch {
56
+ continue;
57
+ }
58
+ for (const entry of entries) {
59
+ const full = path.join(dir, entry.name);
60
+ if (entry.isDirectory()) {
61
+ if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
62
+ queue.push({ dir: full, depth: depth + 1 });
63
+ }
64
+ continue;
65
+ }
66
+ if (!/\.(tsx|jsx|ts|js)$/.test(entry.name)) continue;
67
+ let src;
68
+ try {
69
+ src = fs.readFileSync(full, 'utf8');
70
+ } catch {
71
+ continue;
72
+ }
73
+ if (
74
+ /createRoot\s*\(/.test(src) ||
75
+ /ReactDOM\.render\s*\(/.test(src) ||
76
+ /hydrateRoot\s*\(/.test(src)
77
+ ) {
78
+ hits.push(full);
79
+ }
80
+ }
81
+ }
82
+ // Prefer main/index over App
83
+ hits.sort((a, b) => {
84
+ const score = (p) => {
85
+ const base = path.basename(p).toLowerCase();
86
+ if (base.startsWith('main')) return 0;
87
+ if (base.startsWith('index')) return 1;
88
+ return 2;
89
+ };
90
+ return score(a) - score(b);
91
+ });
92
+ return hits[0] || null;
93
+ }
94
+
95
+ function findEntry(projectRoot) {
96
+ for (const rel of ENTRY_CANDIDATES) {
97
+ const full = path.join(projectRoot, rel);
98
+ if (exists(full)) {
99
+ return full;
100
+ }
101
+ }
102
+ return findEntryByCreateRoot(projectRoot);
103
+ }
104
+
105
+ function alreadyWired(source) {
106
+ return (
107
+ /@ahmednawaz\/crank\/react/.test(source) ||
108
+ /from ['"]@ahmednawaz\/crank['"]/.test(source) ||
109
+ /<Crank[\s/>]/.test(source)
110
+ );
111
+ }
112
+
113
+ function insertImport(source) {
114
+ const importLine = `import { Crank } from '@ahmednawaz/crank/react';`;
115
+ const matches = [...source.matchAll(/^import\s.+;?\s*$/gm)];
116
+ if (matches.length) {
117
+ const last = matches[matches.length - 1];
118
+ const idx = last.index + last[0].length;
119
+ return source.slice(0, idx) + `\n${importLine}` + source.slice(idx);
120
+ }
121
+ return `${importLine}\n${source}`;
122
+ }
123
+
124
+ function insertCrankJsx(source) {
125
+ if (/<Crank[\s/>]/.test(source)) {
126
+ return { source, patched: true };
127
+ }
128
+
129
+ if (/<StrictMode>/.test(source)) {
130
+ return {
131
+ source: source.replace(/(<StrictMode>\s*)/, '$1\n <Crank />\n '),
132
+ patched: true
133
+ };
134
+ }
135
+
136
+ if (/\.render\s*\(\s*<>/.test(source) || /\.render\s*\(\s*<React\.Fragment>/.test(source)) {
137
+ return {
138
+ source: source.replace(/(<>\s*|<React\.Fragment>\s*)/, '$1\n <Crank />\n '),
139
+ patched: true
140
+ };
141
+ }
142
+
143
+ // createRoot(...).render(<App />) or multi-line JSX root
144
+ if (/\.render\s*\(/.test(source)) {
145
+ // Single-line: .render(<App />)
146
+ let next = source.replace(
147
+ /\.render\s*\(\s*(<(?!>)[^]*?>)\s*\)/m,
148
+ '.render(\n <>\n $1\n <Crank />\n </>\n)'
149
+ );
150
+ if (next !== source && /<Crank[\s/>]/.test(next)) {
151
+ return { source: next, patched: true };
152
+ }
153
+
154
+ // Multi-line root starting with <Something
155
+ next = source.replace(
156
+ /(\.render\s*\(\s*\n\s*)(<[A-Za-z/])/,
157
+ '$1<>\n <Crank />\n $2'
158
+ );
159
+ if (next !== source) {
160
+ // close fragment before the render's closing paren — best effort
161
+ next = next.replace(
162
+ /(\.render\s*\([\s\S]*?)(\n\s*\))/,
163
+ (match, body, close) => {
164
+ if (body.includes('<Crank') && !body.includes('</>')) {
165
+ return `${body}\n </>${close}`;
166
+ }
167
+ return match;
168
+ }
169
+ );
170
+ if (/<Crank[\s/>]/.test(next)) {
171
+ return { source: next, patched: true };
172
+ }
173
+ }
174
+ }
175
+
176
+ if (/ReactDOM\.render\s*\(/.test(source)) {
177
+ const next = source.replace(
178
+ /ReactDOM\.render\s*\(\s*(<(?!>)[^]*?>)\s*,/,
179
+ 'ReactDOM.render(\n <>\n $1\n <Crank />\n </>,'
180
+ );
181
+ if (next !== source && /<Crank[\s/>]/.test(next)) {
182
+ return { source: next, patched: true };
183
+ }
184
+ }
185
+
186
+ if (/<App\b[^/]*?\/>/.test(source)) {
187
+ return {
188
+ source: source.replace(/(<App\b[^/]*?\/>)/, '<Crank />\n $1'),
189
+ patched: true
190
+ };
191
+ }
192
+ if (/<App\b[^>]*>/.test(source)) {
193
+ return {
194
+ source: source.replace(/(<App\b[^>]*>)/, '<Crank />\n $1'),
195
+ patched: true
196
+ };
197
+ }
198
+
199
+ return { source, patched: false };
200
+ }
201
+
202
+ /**
203
+ * Patch the React entry to mount <Crank />. Idempotent.
204
+ * @returns {{ wired: boolean, reason?: string, file?: string }}
205
+ */
206
+ function wireReactEntry(projectRoot) {
207
+ const entryPath = findEntry(projectRoot);
208
+ if (!entryPath) {
209
+ return { wired: false, reason: 'no-entry' };
210
+ }
211
+
212
+ let source = fs.readFileSync(entryPath, 'utf8');
213
+ if (alreadyWired(source)) {
214
+ return { wired: false, reason: 'already-present', file: entryPath };
215
+ }
216
+
217
+ source = insertImport(source);
218
+ const { source: next, patched } = insertCrankJsx(source);
219
+ source = next;
220
+
221
+ if (!patched) {
222
+ return { wired: false, reason: 'unrecognized-entry-shape', file: entryPath };
223
+ }
224
+
225
+ fs.writeFileSync(entryPath, source);
226
+ return { wired: true, file: entryPath };
227
+ }
228
+
229
+ function findPnpmWorkspaceRoot(startDir) {
230
+ let dir = path.resolve(startDir);
231
+ for (let i = 0; i < 12; i++) {
232
+ if (
233
+ exists(path.join(dir, 'pnpm-workspace.yaml')) ||
234
+ exists(path.join(dir, 'pnpm-workspace.yml'))
235
+ ) {
236
+ return dir;
237
+ }
238
+ // lockfile alone can mean pnpm without workspaces
239
+ if (exists(path.join(dir, 'pnpm-lock.yaml')) && i === 0) {
240
+ return dir;
241
+ }
242
+ const parent = path.dirname(dir);
243
+ if (parent === dir) break;
244
+ dir = parent;
245
+ }
246
+ // Prefer ancestor with lockfile if no workspace file
247
+ dir = path.resolve(startDir);
248
+ for (let i = 0; i < 12; i++) {
249
+ if (exists(path.join(dir, 'pnpm-lock.yaml'))) {
250
+ return dir;
251
+ }
252
+ const parent = path.dirname(dir);
253
+ if (parent === dir) break;
254
+ dir = parent;
255
+ }
256
+ return null;
257
+ }
258
+
259
+ function readPackageName(projectRoot) {
260
+ try {
261
+ const pkg = JSON.parse(
262
+ fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')
263
+ );
264
+ return pkg.name || null;
265
+ } catch {
266
+ return null;
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Build install attempts for this project (pnpm monorepo-aware).
272
+ * Never uses npm as a fallback inside a pnpm workspace.
273
+ */
274
+ function detectInstallAttempts(projectRoot) {
275
+ const root = path.resolve(projectRoot);
276
+ const pnpmRoot = findPnpmWorkspaceRoot(root);
277
+ const attempts = [];
278
+
279
+ if (pnpmRoot) {
280
+ const isWorkspace = exists(path.join(pnpmRoot, 'pnpm-workspace.yaml')) ||
281
+ exists(path.join(pnpmRoot, 'pnpm-workspace.yml'));
282
+ const pkgName = readPackageName(root);
283
+ const relFilter = path.relative(pnpmRoot, root).replace(/\\/g, '/') || '.';
284
+
285
+ // 1) From the package directory (works when cwd is the workspace package)
286
+ attempts.push({
287
+ cmd: 'pnpm',
288
+ args: ['add', '-D', '@ahmednawaz/crank'],
289
+ cwd: root,
290
+ label: `pnpm add -D @ahmednawaz/crank (in ${relFilter})`
291
+ });
292
+
293
+ if (isWorkspace && pnpmRoot !== root) {
294
+ // 2) From monorepo root with --filter by package name
295
+ if (pkgName) {
296
+ attempts.push({
297
+ cmd: 'pnpm',
298
+ args: ['add', '-D', '@ahmednawaz/crank', '--filter', pkgName],
299
+ cwd: pnpmRoot,
300
+ label: `pnpm add -D @ahmednawaz/crank --filter ${pkgName}`
301
+ });
302
+ }
303
+ // 3) From monorepo root with --filter by path
304
+ attempts.push({
305
+ cmd: 'pnpm',
306
+ args: ['add', '-D', '@ahmednawaz/crank', '--filter', `./${relFilter}`],
307
+ cwd: pnpmRoot,
308
+ label: `pnpm add -D @ahmednawaz/crank --filter ./${relFilter}`
309
+ });
310
+ }
311
+
312
+ return attempts;
313
+ }
314
+
315
+ if (exists(path.join(root, 'yarn.lock'))) {
316
+ return [
317
+ {
318
+ cmd: 'yarn',
319
+ args: ['add', '-D', '@ahmednawaz/crank'],
320
+ cwd: root,
321
+ label: 'yarn add -D @ahmednawaz/crank'
322
+ }
323
+ ];
324
+ }
325
+
326
+ return [
327
+ {
328
+ cmd: 'npm',
329
+ args: ['install', '-D', '@ahmednawaz/crank'],
330
+ cwd: root,
331
+ label: 'npm install -D @ahmednawaz/crank'
332
+ }
333
+ ];
334
+ }
335
+
336
+ function detectInstallCommand(projectRoot) {
337
+ const attempts = detectInstallAttempts(projectRoot);
338
+ const first = attempts[0];
339
+ return { cmd: first.cmd, args: first.args, cwd: first.cwd };
340
+ }
341
+
342
+ function installCrankPackage(projectRoot) {
343
+ const { execFileSync } = require('child_process');
344
+ const attempts = detectInstallAttempts(projectRoot);
345
+ const result = {
346
+ installed: false,
347
+ installCommand: attempts[0]?.label || '',
348
+ installAttempts: []
349
+ };
350
+
351
+ for (const attempt of attempts) {
352
+ result.installAttempts.push(attempt.label);
353
+ try {
354
+ execFileSync(attempt.cmd, attempt.args, {
355
+ cwd: attempt.cwd,
356
+ stdio: 'inherit',
357
+ env: process.env
358
+ });
359
+ result.installed = true;
360
+ result.installCommand = attempt.label;
361
+ delete result.installError;
362
+ return result;
363
+ } catch (err) {
364
+ result.installError = err.message;
365
+ }
366
+ }
367
+
368
+ // Hint for humans / agents — do not npm-install into a pnpm monorepo
369
+ const pkgName = readPackageName(projectRoot);
370
+ if (findPnpmWorkspaceRoot(projectRoot)) {
371
+ result.installHint = pkgName
372
+ ? `pnpm add -D @ahmednawaz/crank --filter ${pkgName}`
373
+ : `pnpm add -D @ahmednawaz/crank --filter ${path.basename(projectRoot)}`;
374
+ }
375
+
376
+ return result;
377
+ }
378
+
379
+ /**
380
+ * Full inject flow: ensure dep → install → patch entry.
381
+ */
382
+ function injectCrank(projectRoot, options = {}) {
383
+ const results = {
384
+ projectRoot,
385
+ wrote: []
386
+ };
387
+
388
+ if (options.install !== false) {
389
+ Object.assign(results, installCrankPackage(projectRoot));
390
+ }
391
+
392
+ const wire = wireReactEntry(projectRoot);
393
+ results.reactWire = wire;
394
+ if (wire.wired && wire.file) {
395
+ results.wrote.push(path.relative(projectRoot, wire.file));
396
+ }
397
+ return results;
398
+ }
399
+
400
+ module.exports = {
401
+ wireReactEntry,
402
+ installCrankPackage,
403
+ injectCrank,
404
+ findEntry,
405
+ detectInstallCommand,
406
+ detectInstallAttempts,
407
+ findPnpmWorkspaceRoot
408
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahmednawaz/crank",
3
- "version": "0.2.0",
3
+ "version": "0.2.3",
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",
package/skill/SKILL.md CHANGED
@@ -33,7 +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
+ 5. **Panel:** `npx @ahmednawaz/crank inject` patches `main.tsx` with `<Crank />` (no manual edits). Then keep companion Workflow running with `npx @ahmednawaz/crank start`.
37
37
 
38
38
  ## When the user says "start Crank" or pastes `npx crank`
39
39