@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 +12 -15
- package/README.md +5 -22
- package/bin/crank.js +52 -10
- package/lib/init.js +25 -38
- package/lib/wire-react.js +408 -0
- package/package.json +1 -1
- package/skill/SKILL.md +1 -1
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
|
|
108
|
+
**Preferred** — CLI inject (no manual edits):
|
|
109
109
|
|
|
110
110
|
```bash
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
|
130
|
-
| **Next.js** |
|
|
131
|
-
| **
|
|
132
|
-
| **
|
|
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
|
|
106
|
-
|
|
107
|
-
**Preferred** — install and mount like Retune:
|
|
105
|
+
### Panel injection (Retune-style)
|
|
108
106
|
|
|
109
107
|
```bash
|
|
110
|
-
|
|
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
|
-
|
|
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
|
|
43
|
-
setup
|
|
44
|
-
init
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 (
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
'
|
|
715
|
+
'Run (preferred — no manual edits):',
|
|
721
716
|
'',
|
|
722
717
|
'```bash',
|
|
723
|
-
'
|
|
724
|
-
'# or: npm i -D @ahmednawaz/crank',
|
|
718
|
+
'npx @ahmednawaz/crank inject',
|
|
725
719
|
'```',
|
|
726
720
|
'',
|
|
727
|
-
'
|
|
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
|
-
'
|
|
733
|
-
' <>',
|
|
734
|
-
' <App />',
|
|
735
|
-
' <Crank />',
|
|
736
|
-
' </>',
|
|
737
|
-
')',
|
|
726
|
+
'<Crank />',
|
|
738
727
|
'```',
|
|
739
728
|
'',
|
|
740
|
-
'
|
|
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 —
|
|
757
|
+
'## Preferred — `npx @ahmednawaz/crank inject`',
|
|
769
758
|
'',
|
|
770
|
-
'
|
|
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
|
-
'
|
|
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
|
-
'```
|
|
798
|
-
|
|
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(' •
|
|
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 —
|
|
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
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
|
|
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
|
|