@ahmednawaz/crank 0.1.9 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +22 -10
- package/README.md +8 -10
- package/bin/crank.js +48 -10
- package/companion/dev-loader.js +2 -2
- package/lib/init.js +104 -83
- package/lib/wire-react.js +297 -0
- package/next.js +26 -36
- package/next.mjs +17 -0
- package/package.json +35 -9
- package/react.d.ts +35 -0
- package/react.js +36 -32
- package/react.mjs +93 -0
- package/skill/SKILL.md +1 -1
package/AGENTS.md
CHANGED
|
@@ -103,20 +103,32 @@ See **[REPLIT.md](./REPLIT.md)** and `.crank/replit.md` (URLs after first run).
|
|
|
103
103
|
|
|
104
104
|
---
|
|
105
105
|
|
|
106
|
-
## Panel injection (
|
|
106
|
+
## Panel injection (Retune pattern)
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
**Preferred** — CLI inject (no manual edits):
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
110
|
+
```bash
|
|
111
|
+
npx @ahmednawaz/crank@latest setup
|
|
112
|
+
npx @ahmednawaz/crank inject
|
|
113
|
+
npx @ahmednawaz/crank start
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`inject` finds `main.tsx` / `createRoot` / `ReactDOM.render`, adds:
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
import { Crank } from '@ahmednawaz/crank/react'
|
|
120
|
+
// ...
|
|
121
|
+
<Crank />
|
|
122
|
+
```
|
|
116
123
|
|
|
117
|
-
|
|
124
|
+
Same idea as Sentry/Clerk setup CLIs. See `.crank/react-mount.md` after setup.
|
|
118
125
|
|
|
119
|
-
|
|
126
|
+
| Stack | Integration |
|
|
127
|
+
|-------|-------------|
|
|
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 |
|
|
120
132
|
|
|
121
133
|
---
|
|
122
134
|
|
package/README.md
CHANGED
|
@@ -102,19 +102,17 @@ 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 (
|
|
105
|
+
### Panel injection (Retune-style)
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
```bash
|
|
108
|
+
npx @ahmednawaz/crank@latest setup
|
|
109
|
+
npx @ahmednawaz/crank inject # patches main.tsx — no manual edits
|
|
110
|
+
npx @ahmednawaz/crank start
|
|
111
|
+
```
|
|
108
112
|
|
|
109
|
-
|
|
110
|
-
|-------|-------------|
|
|
111
|
-
| **Vite** | `setup` patches `vite.config` with Vizpatch-style graceful load via `.crank/vite-plugin.cjs` (resolves from `packageRoot` / npx cache when `pnpm add` fails) |
|
|
112
|
-
| **Next.js** | `import { CrankScript } from '@ahmednawaz/crank/next'` |
|
|
113
|
-
| **React (no Vite)** | `import { CrankLoader } from '@ahmednawaz/crank/react'` — script injector only |
|
|
114
|
-
| **Webpack** | `crankWebpackPlugin()` from `@ahmednawaz/crank/webpack` |
|
|
115
|
-
| **Fallback** | Companion bookmarklet |
|
|
113
|
+
`inject` installs the package and adds `import { Crank }` + `<Crank />` (Sentry/Clerk-style).
|
|
116
114
|
|
|
117
|
-
|
|
115
|
+
Replit public URL: `window.__CRANK_URL__` or `<Crank companionUrl="https://3344-….replit.dev" />` or `VITE_CRANK_URL`.
|
|
118
116
|
|
|
119
117
|
Useful variants:
|
|
120
118
|
|
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,37 @@ 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
|
+
console.warn('[Crank] Try: pnpm add -D @ahmednawaz/crank');
|
|
639
|
+
}
|
|
640
|
+
if (result.reactWire?.wired) {
|
|
641
|
+
console.log(`[Crank] Patched ${result.reactWire.file}`);
|
|
642
|
+
console.log('[Crank] Added: import { Crank } from \'@ahmednawaz/crank/react\' + <Crank />');
|
|
643
|
+
console.log('[Crank] Next: npx @ahmednawaz/crank start');
|
|
644
|
+
process.exit(0);
|
|
645
|
+
}
|
|
646
|
+
if (result.reactWire?.reason === 'already-present') {
|
|
647
|
+
console.log(`[Crank] Already injected in ${result.reactWire.file}`);
|
|
648
|
+
process.exit(0);
|
|
649
|
+
}
|
|
650
|
+
console.error(
|
|
651
|
+
`[Crank] Could not inject (${result.reactWire?.reason || 'unknown'}).` +
|
|
652
|
+
(result.reactWire?.file ? ` Looked at ${result.reactWire.file}.` : '') +
|
|
653
|
+
' Add manually:\n\n import { Crank } from \'@ahmednawaz/crank/react\'\n <Crank />\n'
|
|
654
|
+
);
|
|
655
|
+
process.exit(1);
|
|
656
|
+
}
|
|
657
|
+
|
|
620
658
|
if (!fs.existsSync(projectRoot) || !fs.statSync(projectRoot).isDirectory()) {
|
|
621
659
|
console.error(`Project root not found: ${projectRoot}`);
|
|
622
660
|
process.exit(1);
|
package/companion/dev-loader.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Injected by
|
|
5
|
-
* Loads the Crank panel from the
|
|
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/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');
|
|
@@ -433,7 +434,7 @@ function mergeUserCursorMcp(projectRoot, results) {
|
|
|
433
434
|
}
|
|
434
435
|
|
|
435
436
|
/**
|
|
436
|
-
* Retune-style: add crank to devDependencies
|
|
437
|
+
* Retune-style: add @ahmednawaz/crank to devDependencies (scoped — works with pnpm).
|
|
437
438
|
*/
|
|
438
439
|
function ensureCrankDependency(projectRoot, results) {
|
|
439
440
|
const pkgPath = path.join(projectRoot, 'package.json');
|
|
@@ -448,20 +449,28 @@ function ensureCrankDependency(projectRoot, results) {
|
|
|
448
449
|
const crankPkg = readJson(path.join(PACKAGE_ROOT, 'package.json'), {});
|
|
449
450
|
pkg.devDependencies = pkg.devDependencies || {};
|
|
450
451
|
|
|
451
|
-
|
|
452
|
+
const scoped = '@ahmednawaz/crank';
|
|
453
|
+
let versionSpec;
|
|
452
454
|
if (process.env.CRANK_NPM_SPEC) {
|
|
453
|
-
|
|
455
|
+
versionSpec = process.env.CRANK_NPM_SPEC;
|
|
454
456
|
} else {
|
|
455
|
-
|
|
457
|
+
versionSpec = `^${crankPkg.version || '0.2.0'}`;
|
|
456
458
|
}
|
|
457
459
|
|
|
458
|
-
|
|
460
|
+
const prev = pkg.devDependencies[scoped];
|
|
461
|
+
const hadLegacyAlias = Boolean(pkg.devDependencies.crank);
|
|
462
|
+
pkg.devDependencies[scoped] = versionSpec;
|
|
463
|
+
if (hadLegacyAlias) {
|
|
464
|
+
delete pkg.devDependencies.crank;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (prev === versionSpec && !hadLegacyAlias) {
|
|
459
468
|
return;
|
|
460
469
|
}
|
|
461
|
-
|
|
470
|
+
|
|
462
471
|
writeJson(pkgPath, pkg);
|
|
463
|
-
results.wrote.push(
|
|
464
|
-
results.crankDependencyAdded =
|
|
472
|
+
results.wrote.push(`package.json#devDependencies.${scoped}`);
|
|
473
|
+
results.crankDependencyAdded = `${scoped}@${versionSpec}`;
|
|
465
474
|
}
|
|
466
475
|
|
|
467
476
|
function writeConfig(projectRoot, env) {
|
|
@@ -519,9 +528,7 @@ function initProject(projectRoot, options = {}) {
|
|
|
519
528
|
|
|
520
529
|
// MCP + skill (Retune-style: global IDE config, automatic skill sync)
|
|
521
530
|
if (options.mcp !== false) {
|
|
522
|
-
|
|
523
|
-
ensureCrankDependency(projectRoot, results);
|
|
524
|
-
}
|
|
531
|
+
ensureCrankDependency(projectRoot, results);
|
|
525
532
|
configureMcpRetuneStyle(env, projectRoot, results, options);
|
|
526
533
|
}
|
|
527
534
|
|
|
@@ -529,7 +536,11 @@ function initProject(projectRoot, options = {}) {
|
|
|
529
536
|
installCrankSkill(projectRoot, results);
|
|
530
537
|
}
|
|
531
538
|
|
|
532
|
-
|
|
539
|
+
// React apps: install dep + write out inject (Sentry/Clerk-style). Do not patch source here.
|
|
540
|
+
if (env.hasReact && options.react !== false) {
|
|
541
|
+
writeReactMountHint(projectRoot, env, results);
|
|
542
|
+
results.reactMount = true;
|
|
543
|
+
} else if (env.hasVite && options.vite !== false) {
|
|
533
544
|
const viteResult = patchViteConfig(projectRoot);
|
|
534
545
|
results.vite = viteResult;
|
|
535
546
|
if (viteResult.patched) {
|
|
@@ -539,7 +550,6 @@ function initProject(projectRoot, options = {}) {
|
|
|
539
550
|
results.wrote.push(path.relative(projectRoot, viteResult.plugin));
|
|
540
551
|
}
|
|
541
552
|
} else if (options.vite !== false) {
|
|
542
|
-
// Still write stub so agents can wire the graceful load manually.
|
|
543
553
|
const stub = writeVitePluginStub(projectRoot);
|
|
544
554
|
results.vite = { patched: false, reason: 'no-vite-detected', plugin: stub };
|
|
545
555
|
results.wrote.push(path.relative(projectRoot, stub));
|
|
@@ -553,7 +563,7 @@ function initProject(projectRoot, options = {}) {
|
|
|
553
563
|
const pkg = readJson(pkgPath, {});
|
|
554
564
|
pkg.scripts = pkg.scripts || {};
|
|
555
565
|
if (!pkg.scripts.crank) {
|
|
556
|
-
pkg.scripts.crank = 'crank';
|
|
566
|
+
pkg.scripts.crank = 'npx @ahmednawaz/crank';
|
|
557
567
|
writeJson(pkgPath, pkg);
|
|
558
568
|
results.wrote.push('package.json#scripts.crank');
|
|
559
569
|
}
|
|
@@ -563,14 +573,9 @@ function initProject(projectRoot, options = {}) {
|
|
|
563
573
|
writeReplitPlatformGuide(projectRoot, results);
|
|
564
574
|
}
|
|
565
575
|
|
|
566
|
-
if (
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
execSync('npm install', { cwd: projectRoot, stdio: 'inherit' });
|
|
570
|
-
results.installed = true;
|
|
571
|
-
} catch (err) {
|
|
572
|
-
results.installError = err.message;
|
|
573
|
-
}
|
|
576
|
+
if (options.install !== false && results.crankDependencyAdded) {
|
|
577
|
+
const install = installCrankPackage(projectRoot);
|
|
578
|
+
Object.assign(results, install);
|
|
574
579
|
}
|
|
575
580
|
|
|
576
581
|
return results;
|
|
@@ -699,76 +704,90 @@ function copyAgentsDoc(projectRoot, results) {
|
|
|
699
704
|
}
|
|
700
705
|
}
|
|
701
706
|
|
|
707
|
+
function writeReactMountHint(projectRoot, env, results) {
|
|
708
|
+
const hint = path.join(projectRoot, '.crank', 'react-mount.md');
|
|
709
|
+
fs.mkdirSync(path.dirname(hint), { recursive: true });
|
|
710
|
+
fs.writeFileSync(
|
|
711
|
+
hint,
|
|
712
|
+
[
|
|
713
|
+
'# Mount Crank (Retune-style)',
|
|
714
|
+
'',
|
|
715
|
+
'Run (preferred — no manual edits):',
|
|
716
|
+
'',
|
|
717
|
+
'```bash',
|
|
718
|
+
'npx @ahmednawaz/crank inject',
|
|
719
|
+
'```',
|
|
720
|
+
'',
|
|
721
|
+
'That installs `@ahmednawaz/crank` and patches your entry file with:',
|
|
722
|
+
'',
|
|
723
|
+
'```tsx',
|
|
724
|
+
"import { Crank } from '@ahmednawaz/crank/react'",
|
|
725
|
+
'',
|
|
726
|
+
'<Crank />',
|
|
727
|
+
'```',
|
|
728
|
+
'',
|
|
729
|
+
'Then start the companion:',
|
|
730
|
+
'',
|
|
731
|
+
'```bash',
|
|
732
|
+
'npx @ahmednawaz/crank start',
|
|
733
|
+
'```',
|
|
734
|
+
'',
|
|
735
|
+
'On Replit, set the public companion URL if the preview is not on localhost:',
|
|
736
|
+
'',
|
|
737
|
+
'```ts',
|
|
738
|
+
"window.__CRANK_URL__ = 'https://3344-<your-repl>.replit.dev'",
|
|
739
|
+
'// or: <Crank companionUrl="https://3344-….replit.dev" />',
|
|
740
|
+
'// or: VITE_CRANK_URL=https://3344-….replit.dev',
|
|
741
|
+
'```',
|
|
742
|
+
'',
|
|
743
|
+
'No Vite plugin required.',
|
|
744
|
+
''
|
|
745
|
+
].join('\n')
|
|
746
|
+
);
|
|
747
|
+
results.wrote.push('.crank/react-mount.md');
|
|
748
|
+
results.reactMount = true;
|
|
749
|
+
}
|
|
750
|
+
|
|
702
751
|
function writeInjectionGuide(projectRoot, env, results) {
|
|
703
752
|
const guide = path.join(projectRoot, '.crank', 'injection.md');
|
|
704
753
|
fs.mkdirSync(path.dirname(guide), { recursive: true });
|
|
705
754
|
const lines = [
|
|
706
755
|
'# Crank panel injection',
|
|
707
756
|
'',
|
|
708
|
-
'
|
|
709
|
-
'It is **not** a Retune-style React UI component living in your component tree.',
|
|
710
|
-
'',
|
|
711
|
-
'## Prefer Vite plugin (default)',
|
|
757
|
+
'## Preferred — `npx @ahmednawaz/crank inject`',
|
|
712
758
|
'',
|
|
713
|
-
'
|
|
759
|
+
'Patches your entry file automatically (Sentry/Clerk-style). No manual edits.',
|
|
714
760
|
'',
|
|
715
|
-
'
|
|
716
|
-
'
|
|
717
|
-
'
|
|
718
|
-
'',
|
|
719
|
-
'Graceful load (Vizpatch-style) — works even when `pnpm add @ahmednawaz/crank` fails:',
|
|
720
|
-
'',
|
|
721
|
-
'```ts',
|
|
722
|
-
"import { createRequire } from 'node:module'",
|
|
723
|
-
"import { existsSync } from 'node:fs'",
|
|
724
|
-
"import { fileURLToPath } from 'node:url'",
|
|
725
|
-
'',
|
|
726
|
-
'let crank = null',
|
|
727
|
-
'try {',
|
|
728
|
-
" const pluginPath = fileURLToPath(new URL('./.crank/vite-plugin.cjs', import.meta.url))",
|
|
729
|
-
' if (existsSync(pluginPath)) {',
|
|
730
|
-
' const require = createRequire(import.meta.url)',
|
|
731
|
-
' crank = require(pluginPath).crank',
|
|
732
|
-
' }',
|
|
733
|
-
'} catch {}',
|
|
734
|
-
'',
|
|
735
|
-
'export default defineConfig({',
|
|
736
|
-
' plugins: [',
|
|
737
|
-
' ...(crank ? [crank()] : []),',
|
|
738
|
-
' // ...your plugins',
|
|
739
|
-
' ],',
|
|
740
|
-
'})',
|
|
761
|
+
'```bash',
|
|
762
|
+
'npx @ahmednawaz/crank@latest setup',
|
|
763
|
+
'npx @ahmednawaz/crank inject',
|
|
764
|
+
'npx @ahmednawaz/crank start',
|
|
741
765
|
'```',
|
|
742
766
|
'',
|
|
743
|
-
'
|
|
744
|
-
'then `node_modules`, and noops if missing.',
|
|
745
|
-
'',
|
|
746
|
-
'## React adapter (non-Vite React only)',
|
|
747
|
-
'',
|
|
748
|
-
'Use only when there is **no** Vite config (CRA / custom webpack React):',
|
|
767
|
+
'Adds:',
|
|
749
768
|
'',
|
|
750
769
|
'```tsx',
|
|
751
|
-
"import {
|
|
752
|
-
'
|
|
753
|
-
'<CrankLoader />',
|
|
770
|
+
"import { Crank } from '@ahmednawaz/crank/react'",
|
|
771
|
+
'<Crank />',
|
|
754
772
|
'```',
|
|
755
773
|
'',
|
|
756
|
-
'
|
|
774
|
+
'URL resolution (first match):',
|
|
775
|
+
'1. `window.__CRANK_URL__`',
|
|
776
|
+
'2. `<Crank companionUrl="…" />` / `url` prop',
|
|
777
|
+
'3. `VITE_CRANK_URL` / `VITE_CRANK_COMPANION_URL`',
|
|
778
|
+
'4. `http://localhost:3344`',
|
|
779
|
+
'',
|
|
780
|
+
'See `.crank/react-mount.md` when this project has React.',
|
|
757
781
|
'',
|
|
758
782
|
'## Next.js',
|
|
759
783
|
'',
|
|
760
|
-
'```
|
|
761
|
-
|
|
762
|
-
'// app/layout.tsx or pages/_app',
|
|
763
|
-
'<CrankScript />',
|
|
784
|
+
'```bash',
|
|
785
|
+
'npx @ahmednawaz/crank inject',
|
|
764
786
|
'```',
|
|
765
787
|
'',
|
|
766
|
-
'##
|
|
788
|
+
'## Non-React Vite (Vue / Svelte / vanilla)',
|
|
767
789
|
'',
|
|
768
|
-
'
|
|
769
|
-
"const { crankWebpackPlugin } = require('@ahmednawaz/crank/webpack')",
|
|
770
|
-
'plugins: [new HtmlWebpackPlugin(...), crankWebpackPlugin()]',
|
|
771
|
-
'```',
|
|
790
|
+
'Optional Vite plugin: `import { crank } from "@ahmednawaz/crank/vite"` — only if you are not using React.',
|
|
772
791
|
'',
|
|
773
792
|
'## Bookmarklet fallback',
|
|
774
793
|
'',
|
|
@@ -776,9 +795,9 @@ function writeInjectionGuide(projectRoot, env, results) {
|
|
|
776
795
|
'',
|
|
777
796
|
'## Detected in this project',
|
|
778
797
|
'',
|
|
798
|
+
`- hasReact: ${Boolean(env.hasReact)}`,
|
|
779
799
|
`- hasVite: ${Boolean(env.hasVite)}`,
|
|
780
800
|
`- viteConfig: ${env.viteConfig || '(none)'}`,
|
|
781
|
-
`- hasReact: ${Boolean(env.hasReact)}`,
|
|
782
801
|
`- hasNext: ${Boolean(env.hasNext)}`,
|
|
783
802
|
`- hasWebpack: ${Boolean(env.hasWebpack)}`,
|
|
784
803
|
''
|
|
@@ -796,15 +815,16 @@ function printSetupNextSteps(env, runtime, results) {
|
|
|
796
815
|
if (results?.wrote?.some((w) => String(w).includes('skills'))) {
|
|
797
816
|
console.log(' • Skill crank-apply-changes installed (auto-syncs on start)');
|
|
798
817
|
}
|
|
799
|
-
if (results?.
|
|
800
|
-
console.log(' •
|
|
818
|
+
if (results?.reactMount || env.hasReact) {
|
|
819
|
+
console.log(' • Next: npx @ahmednawaz/crank inject # patches main.tsx with <Crank />');
|
|
820
|
+
} else if (results?.vite?.patched) {
|
|
821
|
+
console.log(' • Vite plugin patched (non-React stack)');
|
|
801
822
|
} else if (env.hasVite) {
|
|
802
|
-
console.log(' • Vite detected — see .crank/injection.md
|
|
823
|
+
console.log(' • Vite detected — see .crank/injection.md');
|
|
803
824
|
} else if (env.hasNext) {
|
|
804
|
-
console.log(' • Next.js —
|
|
805
|
-
} else if (env.hasReact) {
|
|
806
|
-
console.log(' • React without Vite — add <CrankLoader /> from @ahmednawaz/crank/react (see .crank/injection.md)');
|
|
825
|
+
console.log(' • Next.js — run: npx @ahmednawaz/crank inject');
|
|
807
826
|
}
|
|
827
|
+
console.log(' • Then: npx @ahmednawaz/crank start');
|
|
808
828
|
console.log(' • Injection guide: .crank/injection.md');
|
|
809
829
|
if (env.isReplit) {
|
|
810
830
|
console.log(' • Workflow command: npx @ahmednawaz/crank start');
|
|
@@ -858,10 +878,10 @@ function writeReplitGuide(projectRoot, detectEnv, results, runtime) {
|
|
|
858
878
|
'## Panel in preview',
|
|
859
879
|
'',
|
|
860
880
|
`- Companion: ${companionUrl}`,
|
|
861
|
-
'- **
|
|
862
|
-
'-
|
|
863
|
-
'-
|
|
864
|
-
'-
|
|
881
|
+
'- **React (preferred):** `pnpm add -D @ahmednawaz/crank` then `<Crank />` from `@ahmednawaz/crank/react` (Retune pattern).',
|
|
882
|
+
'- Set `window.__CRANK_URL__` or `VITE_CRANK_URL` to the public companion URL on Replit.',
|
|
883
|
+
'- Keep companion Workflow running: `npx @ahmednawaz/crank start`.',
|
|
884
|
+
'- See `.crank/react-mount.md` and `.crank/injection.md`.',
|
|
865
885
|
''
|
|
866
886
|
].join('\n')
|
|
867
887
|
);
|
|
@@ -881,6 +901,7 @@ module.exports = {
|
|
|
881
901
|
writeVitePluginStub,
|
|
882
902
|
patchViteConfig,
|
|
883
903
|
writeInjectionGuide,
|
|
904
|
+
writeReactMountHint,
|
|
884
905
|
patchReplitConfig,
|
|
885
906
|
copyAgentsDoc
|
|
886
907
|
};
|
|
@@ -0,0 +1,297 @@
|
|
|
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 detectInstallCommand(projectRoot) {
|
|
230
|
+
if (
|
|
231
|
+
exists(path.join(projectRoot, 'pnpm-lock.yaml')) ||
|
|
232
|
+
exists(path.join(projectRoot, 'pnpm-workspace.yaml'))
|
|
233
|
+
) {
|
|
234
|
+
return { cmd: 'pnpm', args: ['add', '-D', '@ahmednawaz/crank'] };
|
|
235
|
+
}
|
|
236
|
+
if (exists(path.join(projectRoot, 'yarn.lock'))) {
|
|
237
|
+
return { cmd: 'yarn', args: ['add', '-D', '@ahmednawaz/crank'] };
|
|
238
|
+
}
|
|
239
|
+
return { cmd: 'npm', args: ['install', '-D', '@ahmednawaz/crank'] };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function installCrankPackage(projectRoot) {
|
|
243
|
+
const { execFileSync } = require('child_process');
|
|
244
|
+
const { cmd, args } = detectInstallCommand(projectRoot);
|
|
245
|
+
const result = { installed: false, installCommand: `${cmd} ${args.join(' ')}` };
|
|
246
|
+
try {
|
|
247
|
+
execFileSync(cmd, args, { cwd: projectRoot, stdio: 'inherit', env: process.env });
|
|
248
|
+
result.installed = true;
|
|
249
|
+
return result;
|
|
250
|
+
} catch (err) {
|
|
251
|
+
result.installError = err.message;
|
|
252
|
+
}
|
|
253
|
+
if (cmd !== 'npm') {
|
|
254
|
+
try {
|
|
255
|
+
execFileSync('npm', ['install', '-D', '@ahmednawaz/crank'], {
|
|
256
|
+
cwd: projectRoot,
|
|
257
|
+
stdio: 'inherit',
|
|
258
|
+
env: process.env
|
|
259
|
+
});
|
|
260
|
+
result.installed = true;
|
|
261
|
+
result.installCommand = 'npm install -D @ahmednawaz/crank';
|
|
262
|
+
delete result.installError;
|
|
263
|
+
} catch (err2) {
|
|
264
|
+
result.installError = err2.message;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return result;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Full inject flow: ensure dep → install → patch entry.
|
|
272
|
+
*/
|
|
273
|
+
function injectCrank(projectRoot, options = {}) {
|
|
274
|
+
const results = {
|
|
275
|
+
projectRoot,
|
|
276
|
+
wrote: []
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
if (options.install !== false) {
|
|
280
|
+
Object.assign(results, installCrankPackage(projectRoot));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const wire = wireReactEntry(projectRoot);
|
|
284
|
+
results.reactWire = wire;
|
|
285
|
+
if (wire.wired && wire.file) {
|
|
286
|
+
results.wrote.push(path.relative(projectRoot, wire.file));
|
|
287
|
+
}
|
|
288
|
+
return results;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
module.exports = {
|
|
292
|
+
wireReactEntry,
|
|
293
|
+
installCrankPackage,
|
|
294
|
+
injectCrank,
|
|
295
|
+
findEntry,
|
|
296
|
+
detectInstallCommand
|
|
297
|
+
};
|
package/next.js
CHANGED
|
@@ -1,63 +1,53 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* Prefer Vite plugin for Vite apps. Use this for Next.js when there is no vite.config.
|
|
7
|
-
*
|
|
8
|
-
* // app/layout.tsx (or a client layout section)
|
|
9
|
-
* import { CrankScript } from '@ahmednawaz/crank/next'
|
|
10
|
-
* <CrankScript />
|
|
11
|
-
*
|
|
12
|
-
* peerDependencies: react, next
|
|
4
|
+
* CJS Next adapter — prefer ESM `next.mjs` via package exports.
|
|
13
5
|
*/
|
|
14
6
|
|
|
15
|
-
const {
|
|
16
|
-
|
|
7
|
+
const {
|
|
8
|
+
Crank: CrankEffect,
|
|
9
|
+
resolveCrankLoaderSrc,
|
|
10
|
+
resolveCrankCompanionUrl
|
|
11
|
+
} = require('./react.js');
|
|
17
12
|
|
|
18
13
|
function getReact() {
|
|
19
14
|
try {
|
|
20
15
|
return require('react');
|
|
21
16
|
} catch {
|
|
22
|
-
throw new Error(
|
|
23
|
-
'@ahmednawaz/crank/next requires react. Prefer @ahmednawaz/crank/vite for Vite apps.'
|
|
24
|
-
);
|
|
17
|
+
throw new Error('@ahmednawaz/crank/next requires react');
|
|
25
18
|
}
|
|
26
19
|
}
|
|
27
20
|
|
|
28
|
-
function
|
|
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;
|
|
29
28
|
try {
|
|
30
|
-
|
|
29
|
+
const mod = require('next/script');
|
|
30
|
+
Script = mod.default || mod;
|
|
31
31
|
} catch {
|
|
32
|
-
|
|
32
|
+
Script = null;
|
|
33
33
|
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* @param {{ src?: string, companionUrl?: string, strategy?: string }} props
|
|
38
|
-
*/
|
|
39
|
-
function CrankScript(props) {
|
|
40
|
-
const React = getReact();
|
|
41
|
-
const NextScript = getNextScript();
|
|
42
|
-
const src = resolveCompanionLoaderUrl(props || {});
|
|
43
|
-
const strategy = (props && props.strategy) || 'afterInteractive';
|
|
44
34
|
|
|
45
|
-
if (
|
|
46
|
-
const Script = NextScript.default || NextScript;
|
|
35
|
+
if (Script) {
|
|
47
36
|
return React.createElement(Script, {
|
|
48
|
-
src,
|
|
37
|
+
src: src.indexOf('?') >= 0 ? src : src + '?t=' + Date.now(),
|
|
49
38
|
strategy,
|
|
50
39
|
'data-crank-loader': '1'
|
|
51
40
|
});
|
|
52
41
|
}
|
|
53
42
|
|
|
54
|
-
|
|
55
|
-
return React.createElement(CrankLoader, props || {});
|
|
43
|
+
return React.createElement(CrankEffect, props);
|
|
56
44
|
}
|
|
57
45
|
|
|
58
46
|
module.exports = {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
47
|
+
Crank,
|
|
48
|
+
CrankScript: Crank,
|
|
49
|
+
CrankLoader: Crank,
|
|
50
|
+
resolveCrankLoaderSrc,
|
|
51
|
+
resolveCrankCompanionUrl,
|
|
52
|
+
default: Crank
|
|
63
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.
|
|
3
|
+
"version": "0.2.2",
|
|
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,28 @@
|
|
|
10
10
|
"bin": {
|
|
11
11
|
"crank": "bin/crank.js"
|
|
12
12
|
},
|
|
13
|
-
"main": "
|
|
13
|
+
"main": "./react.js",
|
|
14
|
+
"module": "./react.mjs",
|
|
15
|
+
"types": "./react.d.ts",
|
|
14
16
|
"exports": {
|
|
15
|
-
".":
|
|
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",
|
|
17
|
-
"./react": "./react.js",
|
|
18
|
-
"./next": "./next.js",
|
|
19
35
|
"./webpack": "./webpack.js",
|
|
20
36
|
"./package.json": "./package.json"
|
|
21
37
|
},
|
|
@@ -25,9 +41,15 @@
|
|
|
25
41
|
"react": ">=17"
|
|
26
42
|
},
|
|
27
43
|
"peerDependenciesMeta": {
|
|
28
|
-
"react": {
|
|
29
|
-
|
|
30
|
-
|
|
44
|
+
"react": {
|
|
45
|
+
"optional": true
|
|
46
|
+
},
|
|
47
|
+
"next": {
|
|
48
|
+
"optional": true
|
|
49
|
+
},
|
|
50
|
+
"html-webpack-plugin": {
|
|
51
|
+
"optional": true
|
|
52
|
+
}
|
|
31
53
|
},
|
|
32
54
|
"workspaces": [
|
|
33
55
|
"companion",
|
|
@@ -71,7 +93,10 @@
|
|
|
71
93
|
"panel",
|
|
72
94
|
"vite.js",
|
|
73
95
|
"react.js",
|
|
96
|
+
"react.mjs",
|
|
97
|
+
"react.d.ts",
|
|
74
98
|
"next.js",
|
|
99
|
+
"next.mjs",
|
|
75
100
|
"webpack.js",
|
|
76
101
|
".env.example",
|
|
77
102
|
"AGENTS.md",
|
|
@@ -87,6 +112,7 @@
|
|
|
87
112
|
"glean",
|
|
88
113
|
"mcp",
|
|
89
114
|
"copy",
|
|
90
|
-
"ux-writing"
|
|
115
|
+
"ux-writing",
|
|
116
|
+
"react"
|
|
91
117
|
]
|
|
92
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
CHANGED
|
@@ -1,56 +1,61 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* This is NOT a Retune-style UI component. It only injects the same /dev-loader.js
|
|
7
|
-
* script the Vite plugin uses. Prefer `@ahmednawaz/crank/vite` when the app uses Vite.
|
|
8
|
-
*
|
|
9
|
-
* import { CrankLoader } from '@ahmednawaz/crank/react'
|
|
10
|
-
* // in App / root layout (client-only):
|
|
11
|
-
* <CrankLoader />
|
|
12
|
-
*
|
|
13
|
-
* peerDependency: react >= 17
|
|
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.
|
|
14
6
|
*/
|
|
15
7
|
|
|
16
|
-
const { resolveCompanionLoaderUrl } = require('./lib/loader-url');
|
|
17
|
-
|
|
18
8
|
function getReact() {
|
|
19
9
|
try {
|
|
20
|
-
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
21
10
|
return require('react');
|
|
22
11
|
} catch {
|
|
23
12
|
throw new Error(
|
|
24
|
-
'@ahmednawaz/crank/react requires react as a peer dependency
|
|
13
|
+
'@ahmednawaz/crank/react requires react as a peer dependency (npm i react / pnpm add react).'
|
|
25
14
|
);
|
|
26
15
|
}
|
|
27
16
|
}
|
|
28
17
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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) {
|
|
34
41
|
const React = getReact();
|
|
35
|
-
|
|
42
|
+
props = props || {};
|
|
43
|
+
const src = resolveCrankLoaderSrc(props);
|
|
36
44
|
|
|
37
45
|
React.useEffect(() => {
|
|
38
46
|
if (typeof document === 'undefined') {
|
|
39
47
|
return undefined;
|
|
40
48
|
}
|
|
41
|
-
if (
|
|
49
|
+
if (document.querySelector('script[data-crank-loader="1"]')) {
|
|
42
50
|
return undefined;
|
|
43
51
|
}
|
|
44
|
-
window.__CRANK_LOADER__
|
|
45
|
-
|
|
46
|
-
const existing = document.querySelector('script[data-crank-loader="1"]');
|
|
47
|
-
if (existing) {
|
|
52
|
+
if (window.__CRANK_LOADER__ && document.getElementById('vp-shell')) {
|
|
48
53
|
return undefined;
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
const script = document.createElement('script');
|
|
52
|
-
script.src = src;
|
|
53
|
-
script.
|
|
57
|
+
script.src = src.indexOf('?') >= 0 ? src : src + '?t=' + Date.now();
|
|
58
|
+
script.async = true;
|
|
54
59
|
script.dataset.crankLoader = '1';
|
|
55
60
|
document.documentElement.appendChild(script);
|
|
56
61
|
return undefined;
|
|
@@ -59,11 +64,10 @@ function CrankLoader(props) {
|
|
|
59
64
|
return null;
|
|
60
65
|
}
|
|
61
66
|
|
|
62
|
-
/** Alias — still only a script injector, not an in-tree panel UI. */
|
|
63
|
-
const Crank = CrankLoader;
|
|
64
|
-
|
|
65
67
|
module.exports = {
|
|
66
|
-
CrankLoader,
|
|
67
68
|
Crank,
|
|
68
|
-
|
|
69
|
+
CrankLoader: Crank,
|
|
70
|
+
resolveCrankCompanionUrl,
|
|
71
|
+
resolveCrankLoaderSrc,
|
|
72
|
+
default: Crank
|
|
69
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,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
|
|