@ahmednawaz/crank 0.1.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/.env.example +53 -0
- package/README.md +266 -0
- package/bin/crank.js +737 -0
- package/bookmarklet/crank-bookmarklet.js +2295 -0
- package/bookmarklet/crank-ui-helpers.js +355 -0
- package/bookmarklet/install-snippet.txt +5 -0
- package/bookmarklet/text-source.js +239 -0
- package/companion/agent-queue.js +485 -0
- package/companion/agent-runner.js +334 -0
- package/companion/bin/crank.js +69 -0
- package/companion/dev-loader.js +39 -0
- package/companion/glean-client.js +991 -0
- package/companion/package.json +18 -0
- package/companion/persist-apply.js +189 -0
- package/companion/selection-store.js +175 -0
- package/companion/server.js +1147 -0
- package/companion/text-dispatch.js +419 -0
- package/companion/vizpatch-bridge.js +49 -0
- package/lib/copy-labels.js +86 -0
- package/lib/detect.js +88 -0
- package/lib/env.js +23 -0
- package/lib/init.js +435 -0
- package/lib/load-team-env.js +43 -0
- package/lib/resolve-project.js +203 -0
- package/lib/team-auth.js +82 -0
- package/lib/urls.js +164 -0
- package/mcp-server/index.js +174 -0
- package/mcp-server/package.json +16 -0
- package/mcp-server/tools.js +480 -0
- package/package.json +57 -0
- package/panel/demo.html +62 -0
- package/skill/SKILL.md +60 -0
- package/skills/README.md +16 -0
- package/skills/copy-guidance.md +25 -0
- package/team.env +4 -0
- package/vite.js +75 -0
package/bin/crank.js
ADDED
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Crank — one command for any repo / IDE.
|
|
6
|
+
*
|
|
7
|
+
* cd your-app
|
|
8
|
+
* npx crank
|
|
9
|
+
*
|
|
10
|
+
* Auto: init → companion → MCP (stdio local / HTTP Replit) → npm run dev when present.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const http = require('http');
|
|
16
|
+
const { spawn } = require('child_process');
|
|
17
|
+
const {
|
|
18
|
+
initProject,
|
|
19
|
+
PACKAGE_ROOT,
|
|
20
|
+
writeReplitGuide
|
|
21
|
+
} = require('../lib/init');
|
|
22
|
+
const { detectEnvironment } = require('../lib/detect');
|
|
23
|
+
const { env } = require('../lib/env');
|
|
24
|
+
const { loadTeamEnvFile } = require('../lib/load-team-env');
|
|
25
|
+
const {
|
|
26
|
+
resolveUrls,
|
|
27
|
+
writeRuntimeManifest,
|
|
28
|
+
applyUrlEnv,
|
|
29
|
+
readRuntimeManifest
|
|
30
|
+
} = require('../lib/urls');
|
|
31
|
+
const { resolveProjectRoot, describeEnvironment } = require('../lib/resolve-project');
|
|
32
|
+
|
|
33
|
+
const LOG_FILE = env('LOG', '/tmp/crank-companion.log');
|
|
34
|
+
const PID_FILE = env('PID_FILE', '/tmp/crank-companion.pid');
|
|
35
|
+
|
|
36
|
+
function printHelp() {
|
|
37
|
+
process.stdout.write(`Usage: crank [command] [project-root]
|
|
38
|
+
|
|
39
|
+
Commands:
|
|
40
|
+
(default) Init + start everything (companion, MCP, dev server when present)
|
|
41
|
+
setup Same as init — Retune-style one-time config (alias for init)
|
|
42
|
+
init Only write IDE / Vite / MCP setup into the project
|
|
43
|
+
mcp Start stdio MCP server (used by npx crank mcp in IDE configs)
|
|
44
|
+
start Start companion (assumes init already done)
|
|
45
|
+
stop Stop daemon companion + HTTP MCP listeners
|
|
46
|
+
doctor Print detected environment + runtime URLs
|
|
47
|
+
status Print whether companion /health responds
|
|
48
|
+
|
|
49
|
+
Flags:
|
|
50
|
+
--daemon Detach durable supervisor (default on local; Replit stays foreground)
|
|
51
|
+
--no-daemon Keep companion in this terminal
|
|
52
|
+
--no-mcp Skip writing MCP config files
|
|
53
|
+
--no-vite Skip Vite config patch
|
|
54
|
+
--no-open Do not open the setup page in a browser
|
|
55
|
+
--no-dev Do not auto-run npm run dev
|
|
56
|
+
--dev Force npm run dev even if not auto-detected
|
|
57
|
+
--no-claude Skip Claude Desktop MCP merge
|
|
58
|
+
--http-mcp Force HTTP MCP (Replit auto-enables this)
|
|
59
|
+
-h, --help Show help
|
|
60
|
+
|
|
61
|
+
Examples:
|
|
62
|
+
cd ~/code/my-app && npx crank setup && npx crank
|
|
63
|
+
npx crank
|
|
64
|
+
npx crank mcp
|
|
65
|
+
npx crank stop
|
|
66
|
+
`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseArgs(argv) {
|
|
70
|
+
const flags = {
|
|
71
|
+
mcp: true,
|
|
72
|
+
vite: true,
|
|
73
|
+
open: true,
|
|
74
|
+
dev: null,
|
|
75
|
+
httpMcp: false,
|
|
76
|
+
claude: true,
|
|
77
|
+
help: false,
|
|
78
|
+
daemon: null,
|
|
79
|
+
explicit: {}
|
|
80
|
+
};
|
|
81
|
+
const positional = [];
|
|
82
|
+
for (const arg of argv) {
|
|
83
|
+
if (arg === '-h' || arg === '--help') {
|
|
84
|
+
flags.help = true;
|
|
85
|
+
} else if (arg === '--no-mcp') {
|
|
86
|
+
flags.mcp = false;
|
|
87
|
+
} else if (arg === '--no-vite') {
|
|
88
|
+
flags.vite = false;
|
|
89
|
+
} else if (arg === '--no-open') {
|
|
90
|
+
flags.open = false;
|
|
91
|
+
} else if (arg === '--dev') {
|
|
92
|
+
flags.dev = true;
|
|
93
|
+
flags.explicit.dev = true;
|
|
94
|
+
} else if (arg === '--no-dev') {
|
|
95
|
+
flags.dev = false;
|
|
96
|
+
flags.explicit.dev = true;
|
|
97
|
+
} else if (arg === '--no-claude') {
|
|
98
|
+
flags.claude = false;
|
|
99
|
+
} else if (arg === '--http-mcp') {
|
|
100
|
+
flags.httpMcp = true;
|
|
101
|
+
flags.explicit.httpMcp = true;
|
|
102
|
+
} else if (arg === '--daemon') {
|
|
103
|
+
flags.daemon = true;
|
|
104
|
+
flags.explicit.daemon = true;
|
|
105
|
+
} else if (arg === '--no-daemon') {
|
|
106
|
+
flags.daemon = false;
|
|
107
|
+
flags.explicit.daemon = true;
|
|
108
|
+
} else if (!arg.startsWith('-')) {
|
|
109
|
+
positional.push(arg);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
let command = 'run';
|
|
113
|
+
let projectRoot = process.cwd();
|
|
114
|
+
const cmds = new Set(['init', 'setup', 'start', 'doctor', 'stop', 'status', 'mcp']);
|
|
115
|
+
if (positional[0] === 'setup') {
|
|
116
|
+
command = 'init';
|
|
117
|
+
} else if (cmds.has(positional[0])) {
|
|
118
|
+
command = positional[0];
|
|
119
|
+
if (positional[1]) {
|
|
120
|
+
projectRoot = path.resolve(positional[1]);
|
|
121
|
+
}
|
|
122
|
+
} else if (positional[0]) {
|
|
123
|
+
projectRoot = path.resolve(positional[0]);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let resolved = null;
|
|
127
|
+
if (command !== 'mcp' && command !== 'stop' && command !== 'status') {
|
|
128
|
+
resolved = resolveProjectRoot(projectRoot);
|
|
129
|
+
projectRoot = resolved.projectRoot;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { command, projectRoot, flags, resolved };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function loadEnvFile(file) {
|
|
136
|
+
if (!fs.existsSync(file)) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
|
|
140
|
+
const trimmed = line.trim();
|
|
141
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const eq = trimmed.indexOf('=');
|
|
145
|
+
if (eq < 1) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const key = trimmed.slice(0, eq).trim();
|
|
149
|
+
let value = trimmed.slice(eq + 1).trim();
|
|
150
|
+
if (
|
|
151
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
152
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
153
|
+
) {
|
|
154
|
+
value = value.slice(1, -1);
|
|
155
|
+
}
|
|
156
|
+
if (process.env[key] == null) {
|
|
157
|
+
process.env[key] = value;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function loadAllEnv(projectRoot) {
|
|
163
|
+
loadTeamEnvFile(PACKAGE_ROOT);
|
|
164
|
+
loadEnvFile(path.join(PACKAGE_ROOT, '.env'));
|
|
165
|
+
loadEnvFile(path.join(projectRoot, '.env'));
|
|
166
|
+
loadEnvFile(path.join(projectRoot, '.env.local'));
|
|
167
|
+
if (!process.env.CRANK_PROJECT_ROOT) {
|
|
168
|
+
process.env.CRANK_PROJECT_ROOT = path.resolve(projectRoot);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function hasDevScript(detectEnv) {
|
|
173
|
+
return Boolean(detectEnv.packageJson?.scripts?.dev);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function applySmartDefaults(flags, detectEnv) {
|
|
177
|
+
if (detectEnv.isReplit) {
|
|
178
|
+
flags.httpMcp = true;
|
|
179
|
+
if (!flags.explicit.daemon) {
|
|
180
|
+
flags.daemon = false;
|
|
181
|
+
}
|
|
182
|
+
} else if (!flags.explicit.daemon) {
|
|
183
|
+
flags.daemon = true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (flags.dev == null) {
|
|
187
|
+
flags.dev = hasDevScript(detectEnv);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (flags.httpMcp || detectEnv.isReplit) {
|
|
191
|
+
process.env.CRANK_MCP_MODE = 'http';
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function companionBaseUrl() {
|
|
196
|
+
const publicUrl = process.env.CRANK_PUBLIC_URL;
|
|
197
|
+
if (publicUrl) {
|
|
198
|
+
return publicUrl.replace(/\/$/, '');
|
|
199
|
+
}
|
|
200
|
+
const urls = resolveUrls({
|
|
201
|
+
isReplit: Boolean(
|
|
202
|
+
process.env.REPL_ID ||
|
|
203
|
+
process.env.REPLIT_DEV_DOMAIN ||
|
|
204
|
+
process.env.REPL_SLUG
|
|
205
|
+
)
|
|
206
|
+
});
|
|
207
|
+
return urls.companionPublic;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function checkHealth(timeoutMs = 1500, baseUrl) {
|
|
211
|
+
const base = baseUrl || companionBaseUrl();
|
|
212
|
+
return new Promise((resolve) => {
|
|
213
|
+
const req = http.get(`${base}/health`, { timeout: timeoutMs }, (res) => {
|
|
214
|
+
let body = '';
|
|
215
|
+
res.on('data', (c) => {
|
|
216
|
+
body += c;
|
|
217
|
+
});
|
|
218
|
+
res.on('end', () => {
|
|
219
|
+
try {
|
|
220
|
+
resolve({ ok: res.statusCode === 200, status: res.statusCode, body: JSON.parse(body) });
|
|
221
|
+
} catch {
|
|
222
|
+
resolve({ ok: res.statusCode === 200, status: res.statusCode, body: null });
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
req.on('error', () => resolve({ ok: false }));
|
|
227
|
+
req.on('timeout', () => {
|
|
228
|
+
req.destroy();
|
|
229
|
+
resolve({ ok: false });
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function openUrl(url) {
|
|
235
|
+
const platform = process.platform;
|
|
236
|
+
let cmd;
|
|
237
|
+
let args;
|
|
238
|
+
if (platform === 'darwin') {
|
|
239
|
+
cmd = 'open';
|
|
240
|
+
args = [url];
|
|
241
|
+
} else if (platform === 'win32') {
|
|
242
|
+
cmd = 'cmd';
|
|
243
|
+
args = ['/c', 'start', '', url];
|
|
244
|
+
} else {
|
|
245
|
+
cmd = 'xdg-open';
|
|
246
|
+
args = [url];
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
|
|
250
|
+
} catch {
|
|
251
|
+
// ignore
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function printBanner(projectRoot, detectEnv, initResult, runtime, modeLabel) {
|
|
256
|
+
const setup = runtime?.companion?.publicUrl || companionBaseUrl();
|
|
257
|
+
|
|
258
|
+
console.log('');
|
|
259
|
+
console.log('┌──────────────────────────────────────────┐');
|
|
260
|
+
console.log('│ Crank — one-command copy tooling │');
|
|
261
|
+
console.log('└──────────────────────────────────────────┘');
|
|
262
|
+
console.log(` Project: ${projectRoot}`);
|
|
263
|
+
if (resolved?.repoRoot && resolved.repoRoot !== projectRoot) {
|
|
264
|
+
console.log(` Repo: ${resolved.repoRoot} (${resolved.reason})`);
|
|
265
|
+
} else if (resolved?.reason) {
|
|
266
|
+
console.log(` Detected: ${resolved.source} — ${resolved.reason}`);
|
|
267
|
+
}
|
|
268
|
+
console.log(` Companion: ${setup}`);
|
|
269
|
+
if (runtime?.mcp?.mode === 'http' && runtime?.mcp?.endpoint) {
|
|
270
|
+
console.log(` MCP (HTTP): ${runtime.mcp.endpoint}`);
|
|
271
|
+
}
|
|
272
|
+
if (process.env.CRANK_TEAM_TOKEN) {
|
|
273
|
+
console.log(' Team lock: ON (CRANK_TEAM_TOKEN set)');
|
|
274
|
+
} else {
|
|
275
|
+
console.log(' Team lock: OFF — set CRANK_TEAM_TOKEN in .env to require a passphrase');
|
|
276
|
+
}
|
|
277
|
+
if (modeLabel) {
|
|
278
|
+
console.log(` Mode: ${modeLabel}`);
|
|
279
|
+
}
|
|
280
|
+
if (initResult?.wrote?.length) {
|
|
281
|
+
console.log(` Config: ${initResult.wrote.slice(0, 5).join(', ')}`);
|
|
282
|
+
}
|
|
283
|
+
console.log('');
|
|
284
|
+
|
|
285
|
+
if (detectEnv.isReplit) {
|
|
286
|
+
console.log(' Replit:');
|
|
287
|
+
console.log(' • Keep this process running (companion + HTTP MCP + dev).');
|
|
288
|
+
console.log(' • Register MCP URL in Agent → Integrations (see .crank/replit.md).');
|
|
289
|
+
console.log(' • Open the webview — select copy → Get options.');
|
|
290
|
+
} else if (detectEnv.hasVite && hasDevScript(detectEnv)) {
|
|
291
|
+
console.log(' Ready:');
|
|
292
|
+
console.log(' • Dev server starting (or already running).');
|
|
293
|
+
console.log(' • Open your app — Crank panel loads automatically.');
|
|
294
|
+
console.log(' • Reload MCP in Cursor / VS Code / Claude after first init.');
|
|
295
|
+
} else {
|
|
296
|
+
console.log(' Ready:');
|
|
297
|
+
console.log(` • Open ${setup} and drag the Crank bookmarklet.`);
|
|
298
|
+
console.log(' • Click it on any page → Select → Get options.');
|
|
299
|
+
console.log(' • Reload MCP in Cursor / VS Code / Claude after first init.');
|
|
300
|
+
}
|
|
301
|
+
console.log('');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function writePid(pid) {
|
|
305
|
+
try {
|
|
306
|
+
fs.writeFileSync(PID_FILE, String(pid) + '\n', 'utf8');
|
|
307
|
+
} catch {
|
|
308
|
+
// ignore
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function readPid() {
|
|
313
|
+
try {
|
|
314
|
+
const raw = fs.readFileSync(PID_FILE, 'utf8').trim();
|
|
315
|
+
const pid = Number(raw);
|
|
316
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
317
|
+
} catch {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function clearPid() {
|
|
323
|
+
try {
|
|
324
|
+
fs.unlinkSync(PID_FILE);
|
|
325
|
+
} catch {
|
|
326
|
+
// ignore
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function isPidAlive(pid) {
|
|
331
|
+
try {
|
|
332
|
+
process.kill(pid, 0);
|
|
333
|
+
return true;
|
|
334
|
+
} catch {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function killPortListeners(port) {
|
|
340
|
+
try {
|
|
341
|
+
const out = require('child_process').execSync(`lsof -tiTCP:${port} -sTCP:LISTEN`, {
|
|
342
|
+
encoding: 'utf8'
|
|
343
|
+
});
|
|
344
|
+
for (const line of out.split(/\s+/).filter(Boolean)) {
|
|
345
|
+
const pid = Number(line);
|
|
346
|
+
if (pid > 0 && pid !== process.pid) {
|
|
347
|
+
try {
|
|
348
|
+
process.kill(pid, 'SIGTERM');
|
|
349
|
+
} catch {
|
|
350
|
+
// ignore
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
} catch {
|
|
355
|
+
// nothing listening
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function startHttpMcp(foreground) {
|
|
360
|
+
process.env.CRANK_MCP_MODE = 'http';
|
|
361
|
+
const mcpPath = path.join(PACKAGE_ROOT, 'mcp-server', 'index.js');
|
|
362
|
+
const stdio = foreground
|
|
363
|
+
? 'inherit'
|
|
364
|
+
: ['ignore', fs.openSync(LOG_FILE, 'a'), fs.openSync(LOG_FILE, 'a')];
|
|
365
|
+
return spawn(process.execPath, [mcpPath], {
|
|
366
|
+
stdio,
|
|
367
|
+
env: process.env,
|
|
368
|
+
cwd: PACKAGE_ROOT
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function startNpmDev(projectRoot) {
|
|
373
|
+
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
374
|
+
return spawn(npm, ['run', 'dev'], {
|
|
375
|
+
stdio: 'inherit',
|
|
376
|
+
cwd: projectRoot,
|
|
377
|
+
env: process.env
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Foreground or in-process supervisor: companion + optional HTTP MCP.
|
|
383
|
+
*/
|
|
384
|
+
function runCompanionSupervisor(projectRoot, { foreground, httpMcp }) {
|
|
385
|
+
process.env.CRANK_PROJECT_ROOT = projectRoot;
|
|
386
|
+
const serverPath = path.join(PACKAGE_ROOT, 'companion', 'server.js');
|
|
387
|
+
let companionChild = null;
|
|
388
|
+
let mcpChild = null;
|
|
389
|
+
let shuttingDown = false;
|
|
390
|
+
let restartDelayMs = 400;
|
|
391
|
+
let spawnGeneration = 0;
|
|
392
|
+
|
|
393
|
+
writePid(process.pid);
|
|
394
|
+
|
|
395
|
+
const log = (msg) => {
|
|
396
|
+
const line = `[Crank supervisor] ${msg}`;
|
|
397
|
+
if (foreground) {
|
|
398
|
+
console.error(line);
|
|
399
|
+
} else {
|
|
400
|
+
try {
|
|
401
|
+
fs.appendFileSync(LOG_FILE, line + '\n');
|
|
402
|
+
} catch {
|
|
403
|
+
// ignore
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
function spawnMcpOnce() {
|
|
409
|
+
if (!httpMcp || shuttingDown) return;
|
|
410
|
+
if (mcpChild) return;
|
|
411
|
+
mcpChild = startHttpMcp(foreground);
|
|
412
|
+
log(`spawned http mcp pid=${mcpChild.pid}`);
|
|
413
|
+
mcpChild.on('exit', (code, signal) => {
|
|
414
|
+
mcpChild = null;
|
|
415
|
+
if (shuttingDown) return;
|
|
416
|
+
log(`http mcp exited code=${code} signal=${signal || ''} — restart in 800ms`);
|
|
417
|
+
setTimeout(spawnMcpOnce, 800);
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function spawnCompanionOnce() {
|
|
422
|
+
if (shuttingDown) return;
|
|
423
|
+
const gen = ++spawnGeneration;
|
|
424
|
+
const stdio = foreground
|
|
425
|
+
? 'inherit'
|
|
426
|
+
: ['ignore', fs.openSync(LOG_FILE, 'a'), fs.openSync(LOG_FILE, 'a')];
|
|
427
|
+
companionChild = spawn(process.execPath, [serverPath], {
|
|
428
|
+
stdio,
|
|
429
|
+
env: process.env,
|
|
430
|
+
cwd: PACKAGE_ROOT
|
|
431
|
+
});
|
|
432
|
+
log(`spawned companion pid=${companionChild.pid} gen=${gen}`);
|
|
433
|
+
|
|
434
|
+
companionChild.on('exit', async (code, signal) => {
|
|
435
|
+
if (gen !== spawnGeneration) return;
|
|
436
|
+
companionChild = null;
|
|
437
|
+
if (shuttingDown) return;
|
|
438
|
+
|
|
439
|
+
if (code === 0 && !signal) {
|
|
440
|
+
const health = await checkHealth(800);
|
|
441
|
+
if (health.ok) {
|
|
442
|
+
log('port already served by healthy companion — supervisor idle');
|
|
443
|
+
const watch = setInterval(async () => {
|
|
444
|
+
if (shuttingDown) {
|
|
445
|
+
clearInterval(watch);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const h = await checkHealth(800);
|
|
449
|
+
if (!h.ok) {
|
|
450
|
+
clearInterval(watch);
|
|
451
|
+
log('health lost — respawning companion');
|
|
452
|
+
restartDelayMs = 400;
|
|
453
|
+
spawnCompanionOnce();
|
|
454
|
+
}
|
|
455
|
+
}, 5000);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
log(
|
|
461
|
+
`companion exited code=${code} signal=${signal || ''} — restart in ${restartDelayMs}ms`
|
|
462
|
+
);
|
|
463
|
+
setTimeout(() => {
|
|
464
|
+
restartDelayMs = Math.min(restartDelayMs * 2, 8000);
|
|
465
|
+
spawnCompanionOnce();
|
|
466
|
+
}, restartDelayMs);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
companionChild.on('spawn', () => {
|
|
470
|
+
restartDelayMs = 400;
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const shutdown = (signal) => {
|
|
475
|
+
shuttingDown = true;
|
|
476
|
+
log(`shutdown ${signal}`);
|
|
477
|
+
for (const child of [companionChild, mcpChild]) {
|
|
478
|
+
if (child) {
|
|
479
|
+
try {
|
|
480
|
+
child.kill(signal);
|
|
481
|
+
} catch {
|
|
482
|
+
// ignore
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
clearPid();
|
|
487
|
+
process.exit(0);
|
|
488
|
+
};
|
|
489
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
490
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
491
|
+
|
|
492
|
+
spawnCompanionOnce();
|
|
493
|
+
spawnMcpOnce();
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function launchDetachedDaemon(projectRoot, argvFlags) {
|
|
497
|
+
const args = [__filename, 'start', '--daemon', '--no-open', projectRoot];
|
|
498
|
+
if (argvFlags.httpMcp) {
|
|
499
|
+
args.push('--http-mcp');
|
|
500
|
+
}
|
|
501
|
+
const outFd = fs.openSync(LOG_FILE, 'a');
|
|
502
|
+
const child = spawn(process.execPath, args, {
|
|
503
|
+
detached: true,
|
|
504
|
+
stdio: ['ignore', outFd, outFd],
|
|
505
|
+
env: {
|
|
506
|
+
...process.env,
|
|
507
|
+
CRANK_SUPERVISOR: '1',
|
|
508
|
+
CRANK_PROJECT_ROOT: projectRoot
|
|
509
|
+
},
|
|
510
|
+
cwd: PACKAGE_ROOT
|
|
511
|
+
});
|
|
512
|
+
writePid(child.pid);
|
|
513
|
+
child.unref();
|
|
514
|
+
console.log(`[Crank] Daemon supervisor started (pid ${child.pid})`);
|
|
515
|
+
console.log(`[Crank] Log: ${LOG_FILE}`);
|
|
516
|
+
console.log(`[Crank] Stop: npx crank stop`);
|
|
517
|
+
if (argvFlags.open) {
|
|
518
|
+
setTimeout(() => openUrl(companionBaseUrl()), 900);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function stopDaemon() {
|
|
523
|
+
const pid = readPid();
|
|
524
|
+
if (pid && isPidAlive(pid)) {
|
|
525
|
+
try {
|
|
526
|
+
try {
|
|
527
|
+
process.kill(-pid, 'SIGTERM');
|
|
528
|
+
} catch {
|
|
529
|
+
process.kill(pid, 'SIGTERM');
|
|
530
|
+
}
|
|
531
|
+
console.log(`[Crank] Sent SIGTERM to supervisor pid ${pid}`);
|
|
532
|
+
} catch (err) {
|
|
533
|
+
console.error(`[Crank] Could not signal pid ${pid}:`, err.message);
|
|
534
|
+
}
|
|
535
|
+
} else if (pid) {
|
|
536
|
+
console.log(`[Crank] Stale PID file (${pid}); clearing`);
|
|
537
|
+
} else {
|
|
538
|
+
console.log('[Crank] No PID file; stopping listeners on companion/MCP ports if any');
|
|
539
|
+
}
|
|
540
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
541
|
+
killPortListeners(process.env.CRANK_PORT || '3344');
|
|
542
|
+
killPortListeners(process.env.CRANK_MCP_HTTP_PORT || '3345');
|
|
543
|
+
clearPid();
|
|
544
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
545
|
+
const health = await checkHealth(800);
|
|
546
|
+
console.log(
|
|
547
|
+
health.ok
|
|
548
|
+
? '[Crank] Warning: /health still responds — another process may own the port'
|
|
549
|
+
: '[Crank] Companion stopped'
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function prepareRuntime(projectRoot, detectEnv) {
|
|
554
|
+
const urls = resolveUrls(detectEnv);
|
|
555
|
+
applyUrlEnv(urls);
|
|
556
|
+
const runtime = writeRuntimeManifest(projectRoot, detectEnv);
|
|
557
|
+
if (detectEnv.isReplit) {
|
|
558
|
+
writeReplitGuide(projectRoot, detectEnv, { wrote: [] }, runtime);
|
|
559
|
+
}
|
|
560
|
+
return runtime;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async function main() {
|
|
564
|
+
const { command, projectRoot, flags: rawFlags, resolved } = parseArgs(process.argv.slice(2));
|
|
565
|
+
const flags = { ...rawFlags };
|
|
566
|
+
if (flags.help) {
|
|
567
|
+
printHelp();
|
|
568
|
+
process.exit(0);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
loadAllEnv(projectRoot);
|
|
572
|
+
process.env.CRANK_PROJECT_ROOT = path.resolve(projectRoot);
|
|
573
|
+
|
|
574
|
+
if (command === 'mcp') {
|
|
575
|
+
process.env.CRANK_MCP_MODE = process.env.CRANK_MCP_MODE || 'stdio';
|
|
576
|
+
const mcpPath = path.join(PACKAGE_ROOT, 'mcp-server', 'index.js');
|
|
577
|
+
const child = spawn(process.execPath, [mcpPath], {
|
|
578
|
+
stdio: 'inherit',
|
|
579
|
+
env: process.env,
|
|
580
|
+
cwd: PACKAGE_ROOT
|
|
581
|
+
});
|
|
582
|
+
child.on('exit', (code) => process.exit(code || 0));
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (command === 'status') {
|
|
587
|
+
const health = await checkHealth(2000);
|
|
588
|
+
const pid = readPid();
|
|
589
|
+
console.log(
|
|
590
|
+
JSON.stringify(
|
|
591
|
+
{
|
|
592
|
+
url: companionBaseUrl(),
|
|
593
|
+
healthy: !!health.ok,
|
|
594
|
+
pidFile: PID_FILE,
|
|
595
|
+
supervisorPid: pid,
|
|
596
|
+
supervisorAlive: pid ? isPidAlive(pid) : false,
|
|
597
|
+
health: health.body || null
|
|
598
|
+
},
|
|
599
|
+
null,
|
|
600
|
+
2
|
|
601
|
+
)
|
|
602
|
+
);
|
|
603
|
+
process.exit(health.ok ? 0 : 1);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (command === 'stop') {
|
|
607
|
+
await stopDaemon();
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (!fs.existsSync(projectRoot) || !fs.statSync(projectRoot).isDirectory()) {
|
|
612
|
+
console.error(`Project root not found: ${projectRoot}`);
|
|
613
|
+
process.exit(1);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const detectEnv = detectEnvironment(projectRoot);
|
|
617
|
+
applySmartDefaults(flags, detectEnv);
|
|
618
|
+
|
|
619
|
+
if (command === 'doctor') {
|
|
620
|
+
const runtime = prepareRuntime(projectRoot, detectEnv);
|
|
621
|
+
console.log(
|
|
622
|
+
JSON.stringify(
|
|
623
|
+
{
|
|
624
|
+
projectRoot,
|
|
625
|
+
resolved,
|
|
626
|
+
packageRoot: PACKAGE_ROOT,
|
|
627
|
+
environment: detectEnv,
|
|
628
|
+
runtime,
|
|
629
|
+
flags,
|
|
630
|
+
commands: {
|
|
631
|
+
setup: 'npx crank setup',
|
|
632
|
+
start: 'npx crank',
|
|
633
|
+
mcp: 'npx crank mcp',
|
|
634
|
+
agentPaste: 'Apply the pending Crank change'
|
|
635
|
+
}
|
|
636
|
+
},
|
|
637
|
+
null,
|
|
638
|
+
2
|
|
639
|
+
)
|
|
640
|
+
);
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
let initResult = null;
|
|
645
|
+
if (command === 'init' || command === 'run') {
|
|
646
|
+
initResult = initProject(projectRoot, {
|
|
647
|
+
mcp: flags.mcp,
|
|
648
|
+
vite: flags.vite,
|
|
649
|
+
claude: flags.claude,
|
|
650
|
+
scripts: true
|
|
651
|
+
});
|
|
652
|
+
if (command === 'init') {
|
|
653
|
+
const runtime = prepareRuntime(projectRoot, detectEnv);
|
|
654
|
+
printBanner(projectRoot, detectEnv, initResult, runtime, 'init only');
|
|
655
|
+
console.log('Init complete. Run `npx crank` to start everything.');
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const runtime = prepareRuntime(projectRoot, detectEnv);
|
|
661
|
+
const httpMcp = flags.httpMcp || detectEnv.isReplit;
|
|
662
|
+
|
|
663
|
+
if (process.env.CRANK_SUPERVISOR === '1' && flags.daemon) {
|
|
664
|
+
process.env.CRANK_PROJECT_ROOT = projectRoot;
|
|
665
|
+
runCompanionSupervisor(projectRoot, { foreground: false, httpMcp });
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (flags.daemon && !flags.dev) {
|
|
670
|
+
const existing = await checkHealth(800);
|
|
671
|
+
if (existing.ok) {
|
|
672
|
+
printBanner(projectRoot, detectEnv, initResult, runtime, 'daemon (already running)');
|
|
673
|
+
console.log('[Crank] Companion already healthy — leaving it running.');
|
|
674
|
+
console.log(`[Crank] ${companionBaseUrl()}/health`);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
printBanner(projectRoot, detectEnv, initResult, runtime, 'daemon');
|
|
678
|
+
launchDetachedDaemon(projectRoot, flags);
|
|
679
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
680
|
+
const health = await checkHealth(2000);
|
|
681
|
+
console.log(health.ok ? '[Crank] Health OK' : `[Crank] Health not ready — check ${LOG_FILE}`);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (flags.daemon && flags.dev) {
|
|
686
|
+
const existing = await checkHealth(800);
|
|
687
|
+
if (!existing.ok) {
|
|
688
|
+
printBanner(projectRoot, detectEnv, initResult, runtime, 'daemon + dev');
|
|
689
|
+
launchDetachedDaemon(projectRoot, { ...flags, httpMcp });
|
|
690
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
691
|
+
} else {
|
|
692
|
+
printBanner(projectRoot, detectEnv, initResult, runtime, 'dev (companion already up)');
|
|
693
|
+
}
|
|
694
|
+
if (flags.open) {
|
|
695
|
+
openUrl(companionBaseUrl());
|
|
696
|
+
}
|
|
697
|
+
const devChild = startNpmDev(projectRoot);
|
|
698
|
+
devChild.on('exit', (code) => process.exit(code || 0));
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
printBanner(
|
|
703
|
+
projectRoot,
|
|
704
|
+
detectEnv,
|
|
705
|
+
initResult,
|
|
706
|
+
runtime,
|
|
707
|
+
detectEnv.isReplit ? 'Replit foreground' : 'foreground'
|
|
708
|
+
);
|
|
709
|
+
|
|
710
|
+
if (flags.open) {
|
|
711
|
+
setTimeout(() => openUrl(runtime.companion.publicUrl), 800);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
let devChild = null;
|
|
715
|
+
if (flags.dev) {
|
|
716
|
+
devChild = startNpmDev(projectRoot);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const shutdownDev = (signal) => {
|
|
720
|
+
if (devChild) {
|
|
721
|
+
try {
|
|
722
|
+
devChild.kill(signal);
|
|
723
|
+
} catch {
|
|
724
|
+
// ignore
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
process.on('SIGINT', () => shutdownDev('SIGINT'));
|
|
729
|
+
process.on('SIGTERM', () => shutdownDev('SIGTERM'));
|
|
730
|
+
|
|
731
|
+
runCompanionSupervisor(projectRoot, { foreground: true, httpMcp });
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
main().catch((err) => {
|
|
735
|
+
console.error(err);
|
|
736
|
+
process.exit(1);
|
|
737
|
+
});
|