@rahul_ur/devlink 1.0.9 → 1.0.11

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.
Files changed (4) hide show
  1. package/README.md +4 -0
  2. package/bin/cli.mjs +453 -49
  3. package/logo.png +0 -0
  4. package/package.json +12 -18
package/README.md CHANGED
@@ -1,3 +1,7 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/rahul_ur/devlink/main/logo.png" alt="devlink logo" width="120" />
3
+ </p>
4
+
1
5
  # devlink
2
6
 
3
7
  Click any element in your browser → jump to its source code → edit it → UI updates live.
package/bin/cli.mjs CHANGED
@@ -2,10 +2,12 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { execSync } from 'node:child_process';
5
+ import { createInterface } from 'node:readline/promises';
5
6
 
6
7
  const projectRoot = process.cwd();
7
8
  const args = process.argv.slice(2);
8
9
  const command = args[0] || 'setup';
10
+ const skipKeys = args.includes('--skip-keys');
9
11
 
10
12
  const log = (msg) => console.log(`\x1b[36m[devlink]\x1b[0m ${msg}`);
11
13
  const ok = (msg) => console.log(`\x1b[32m[devlink] ✓\x1b[0m ${msg}`);
@@ -16,6 +18,173 @@ function exists(p) { return fs.existsSync(p); }
16
18
  function readJson(p) { return JSON.parse(fs.readFileSync(p, 'utf8')); }
17
19
  function writeJson(p, v) { fs.writeFileSync(p, `${JSON.stringify(v, null, 2)}\n`); }
18
20
 
21
+ // Package name -> human-readable framework name, used to give an accurate
22
+ // "devlink doesn't support this yet" message instead of the generic
23
+ // "couldn't find @vitejs/plugin-react import" warning, which wrongly
24
+ // implies there's a manual fix available (there isn't — devlink's inspector
25
+ // hooks in via a Babel plugin specifically on @vitejs/plugin-react).
26
+ const NON_REACT_VITE_PLUGINS = [
27
+ ['@vitejs/plugin-vue', 'Vue'],
28
+ ['@vitejs/plugin-vue2', 'Vue 2'],
29
+ ['@sveltejs/vite-plugin-svelte', 'Svelte'],
30
+ ['vite-plugin-solid', 'Solid'],
31
+ ['@preact/preset-vite', 'Preact'],
32
+ ['vite-plugin-vue2', 'Vue 2'],
33
+ ['@builder.io/qwik', 'Qwik'],
34
+ ];
35
+
36
+ // Is TypeScript in play for this project? Used to decide the extension for
37
+ // any file we generate from scratch (vite.config.*, devlink.config.*) so we
38
+ // don't hand a plain-JS project a .ts file (which breaks its build unless a
39
+ // TS toolchain is already configured) or vice versa.
40
+ function projectUsesTypeScript() {
41
+ if (exists(path.join(projectRoot, 'tsconfig.json'))) return true;
42
+ const pkgPath = path.join(projectRoot, 'package.json');
43
+ if (!exists(pkgPath)) return false;
44
+ const pkg = readJson(pkgPath);
45
+ const all = { ...pkg.dependencies, ...pkg.devDependencies };
46
+ return Boolean(all['typescript']);
47
+ }
48
+
49
+ // ─── Bracket matching ───────────────────────────────────────────────────────
50
+ // Small helper for safely editing arbitrary existing config files. Plain
51
+ // string.replace()/regex on a whole file is fragile the moment the thing
52
+ // being matched (e.g. a `react()` call) has its own nested braces — this
53
+ // walks the source char-by-char (skipping over strings/template
54
+ // literals/comments so brackets inside them don't throw off the count) and
55
+ // returns the index of the matching closing bracket for an opening one.
56
+ function findMatchingBracket(src, openIndex, openChar, closeChar) {
57
+ let depth = 0;
58
+ let i = openIndex;
59
+ while (i < src.length) {
60
+ const c = src[i];
61
+ if (c === '"' || c === "'" || c === '`') {
62
+ const quote = c;
63
+ i++;
64
+ while (i < src.length && src[i] !== quote) {
65
+ if (src[i] === '\\') i++;
66
+ i++;
67
+ }
68
+ } else if (c === '/' && src[i + 1] === '/') {
69
+ while (i < src.length && src[i] !== '\n') i++;
70
+ } else if (c === '/' && src[i + 1] === '*') {
71
+ i += 2;
72
+ while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) i++;
73
+ i++;
74
+ } else {
75
+ if (c === openChar) depth++;
76
+ else if (c === closeChar) {
77
+ depth--;
78
+ if (depth === 0) return i;
79
+ }
80
+ }
81
+ i++;
82
+ }
83
+ return -1;
84
+ }
85
+
86
+ // ─── .env merging ───────────────────────────────────────────────────────────
87
+ // Sets one or more keys in .env at the project root without touching any
88
+ // other lines already in there (e.g. keys someone set by hand, or ones
89
+ // added by a previous run of this command). Parses just enough to find and
90
+ // replace an existing KEY=... line, or append a new one.
91
+ function setEnvVars(envPath, vars) {
92
+ let lines = exists(envPath) ? fs.readFileSync(envPath, 'utf8').split('\n') : [];
93
+ // Trim trailing blanks up front — otherwise a file that already ends in
94
+ // a newline leaves an empty '' element that a newly-appended key then
95
+ // lands after, producing a stray blank line in the middle of the file.
96
+ while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
97
+ for (const [key, value] of Object.entries(vars)) {
98
+ if (!value) continue;
99
+ const lineIndex = lines.findIndex(l => l.trim().startsWith(`${key}=`));
100
+ const newLine = `${key}=${value}`;
101
+ if (lineIndex !== -1) lines[lineIndex] = newLine;
102
+ else lines.push(newLine);
103
+ }
104
+ fs.writeFileSync(envPath, lines.join('\n') + '\n');
105
+ }
106
+
107
+ // Makes sure .env (and variants) are excluded from git in *this* project
108
+ // specifically — never assume a .gitignore fix applied somewhere else
109
+ // (e.g. in devlink's own monorepo) carried over to whatever project
110
+ // `devlink setup` is being run in.
111
+ function ensureEnvGitignored() {
112
+ const gitignorePath = path.join(projectRoot, '.gitignore');
113
+ const entry = '.env';
114
+ if (!exists(gitignorePath)) {
115
+ fs.writeFileSync(gitignorePath, `${entry}\n.env.*\n!.env.example\n`);
116
+ ok('created .gitignore with .env excluded');
117
+ return;
118
+ }
119
+ const content = fs.readFileSync(gitignorePath, 'utf8');
120
+ const alreadyCovered = content.split('\n').some(l => {
121
+ const t = l.trim();
122
+ return t === '.env' || t === '.env*' || t === '**/.env' || t === '*.env';
123
+ });
124
+ if (alreadyCovered) {
125
+ ok('.env already excluded in .gitignore');
126
+ return;
127
+ }
128
+ fs.writeFileSync(gitignorePath, content.replace(/\n?$/, '\n') + '\n# API keys — added by devlink setup\n.env\n.env.*\n!.env.example\n');
129
+ ok('added .env to .gitignore');
130
+ }
131
+
132
+ // ─── API key setup ──────────────────────────────────────────────────────────
133
+ async function promptForApiKeys() {
134
+ if (skipKeys) {
135
+ log('skipping API key setup (--skip-keys)');
136
+ return;
137
+ }
138
+ // Non-interactive environments (CI, piped input, etc.) have no TTY to
139
+ // prompt against — skip rather than hang waiting for input that will
140
+ // never come.
141
+ if (!process.stdin.isTTY) {
142
+ log('no interactive terminal detected — skipping API key setup (run with a TTY, or set ANTHROPIC_API_KEY / OPENAI_API_KEY yourself in .env)');
143
+ return;
144
+ }
145
+
146
+ console.log('\n\x1b[36m─────────────────────────────────────────────────\x1b[0m');
147
+ console.log('\x1b[36m Optional: cloud model API keys\x1b[0m');
148
+ console.log('\x1b[36m─────────────────────────────────────────────────\x1b[0m');
149
+ console.log('The AI companion works fully offline with Ollama. Add these only');
150
+ console.log('if you also want them available in the model picker.');
151
+ console.log('Leave any blank and press Enter to skip it.\n');
152
+
153
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
154
+ let anthropicKey, openaiKey, geminiKey, zenmuxKey;
155
+ try {
156
+ anthropicKey = (await rl.question(' Anthropic API key (Claude) — from https://console.anthropic.com/settings/keys: ')).trim();
157
+ openaiKey = (await rl.question(' OpenAI API key (GPT/Codex) — from https://platform.openai.com/api-keys: ')).trim();
158
+ geminiKey = (await rl.question(' Google API key (Gemini) — from https://aistudio.google.com/app/apikey: ')).trim();
159
+ zenmuxKey = (await rl.question(' ZenMux API key (GLM 5.2 + others) — from https://zenmux.ai/settings/keys: ')).trim();
160
+ } finally {
161
+ rl.close();
162
+ }
163
+
164
+ if (!anthropicKey && !openaiKey && !geminiKey && !zenmuxKey) {
165
+ log('no keys entered — skipping. You can rerun `devlink setup` anytime to add them.');
166
+ return;
167
+ }
168
+
169
+ const envPath = path.join(projectRoot, '.env');
170
+ setEnvVars(envPath, {
171
+ ANTHROPIC_API_KEY: anthropicKey,
172
+ OPENAI_API_KEY: openaiKey,
173
+ GEMINI_API_KEY: geminiKey,
174
+ ZENMUX_API_KEY: zenmuxKey,
175
+ });
176
+ ensureEnvGitignored();
177
+
178
+ const examplePath = path.join(projectRoot, '.env.example');
179
+ if (!exists(examplePath)) {
180
+ fs.writeFileSync(examplePath, 'ANTHROPIC_API_KEY=\nOPENAI_API_KEY=\nGEMINI_API_KEY=\nZENMUX_API_KEY=\n');
181
+ ok('created .env.example template');
182
+ }
183
+
184
+ const added = [anthropicKey && 'Claude', openaiKey && 'GPT/Codex', geminiKey && 'Gemini', zenmuxKey && 'ZenMux'].filter(Boolean).join(', ');
185
+ ok(`saved ${added} API key(s) to .env — restart devlink:bridge to pick them up`);
186
+ }
187
+
19
188
  // ─── Detect framework ─────────────────────────────────────────────────────────
20
189
 
21
190
  function detectFramework() {
@@ -29,6 +198,38 @@ function detectFramework() {
29
198
  return 'unknown';
30
199
  }
31
200
 
201
+ // When the current directory isn't a recognizable frontend project (e.g.
202
+ // you're standing at the root of a monorepo whose real Vite/Next/CRA app
203
+ // lives in ./frontend or ./apps/web), blindly writing devlink.config.js and
204
+ // a devlink:bridge script *here* would just be clutter in the wrong place.
205
+ // Check one level of subdirectories for a package.json that looks like a
206
+ // real frontend project, so we can point the person at it instead.
207
+ function findFrontendSubdirectories() {
208
+ const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.turbo', '.devlink']);
209
+ let entries;
210
+ try {
211
+ entries = fs.readdirSync(projectRoot, { withFileTypes: true });
212
+ } catch {
213
+ return [];
214
+ }
215
+ const candidates = [];
216
+ for (const entry of entries) {
217
+ if (!entry.isDirectory() || SKIP.has(entry.name) || entry.name.startsWith('.')) continue;
218
+ const pkgPath = path.join(projectRoot, entry.name, 'package.json');
219
+ if (!exists(pkgPath)) continue;
220
+ try {
221
+ const pkg = readJson(pkgPath);
222
+ const all = { ...pkg.dependencies, ...pkg.devDependencies };
223
+ if (all['vite'] || all['@vitejs/plugin-react'] || all['react-scripts'] || all['next']) {
224
+ candidates.push(entry.name);
225
+ }
226
+ } catch {
227
+ // Unparsable package.json in that subdir — not a candidate, skip it.
228
+ }
229
+ }
230
+ return candidates;
231
+ }
232
+
32
233
  // ─── Add npm scripts ──────────────────────────────────────────────────────────
33
234
 
34
235
  function addScripts() {
@@ -47,11 +248,13 @@ function addScripts() {
47
248
  // ─── Setup Vite ───────────────────────────────────────────────────────────────
48
249
 
49
250
  function setupVite() {
50
- const candidates = ['vite.config.ts', 'vite.config.js', 'vite.config.mts', 'vite.config.mjs'];
251
+ const candidates = ['vite.config.ts', 'vite.config.js', 'vite.config.mts', 'vite.config.mjs', 'vite.config.cts', 'vite.config.cjs'];
51
252
  const configFile = candidates.find(f => exists(path.join(projectRoot, f)));
52
253
 
53
254
  if (!configFile) {
54
- // Create vite.config.ts from scratch
255
+ // Create a config from scratch — match extension to whether the project
256
+ // is actually TypeScript, rather than always assuming .ts.
257
+ const ext = projectUsesTypeScript() ? 'ts' : 'js';
55
258
  const content = `import { defineConfig } from 'vite';
56
259
  import react from '@vitejs/plugin-react';
57
260
  import { devlinkBabelConfig } from '@rahul_ur/devlink-vite-plugin';
@@ -68,38 +271,176 @@ export default defineConfig({
68
271
  ],
69
272
  });
70
273
  `;
71
- fs.writeFileSync(path.join(projectRoot, 'vite.config.ts'), content);
72
- ok('created vite.config.ts with devlink config');
274
+ fs.writeFileSync(path.join(projectRoot, `vite.config.${ext}`), content);
275
+ ok(`created vite.config.${ext} with devlink config`);
73
276
  return;
74
277
  }
75
278
 
76
279
  const configPath = path.join(projectRoot, configFile);
77
- let src = fs.readFileSync(configPath, 'utf8');
280
+ const original = fs.readFileSync(configPath, 'utf8');
78
281
 
79
- if (src.includes('@rahul_ur/devlink-vite-plugin') || src.includes('@rahul_ur/devlink-babel-plugin')) {
282
+ if (original.includes('@rahul_ur/devlink-vite-plugin') || original.includes('@rahul_ur/devlink-babel-plugin')) {
80
283
  ok(`${configFile} already has devlink config`);
81
284
  return;
82
285
  }
83
286
 
84
- // Add import line after last import
85
- const importLine = `import { devlinkBabelConfig } from '@rahul_ur/devlink-vite-plugin';\n`;
86
- const lastImport = src.lastIndexOf('\nimport ');
87
- const insertAt = lastImport === -1 ? 0 : src.indexOf('\n', lastImport) + 1;
88
- src = src.slice(0, insertAt) + importLine + src.slice(insertAt);
287
+ // Bail out cleanly (no partial edits) for the one framework variant this
288
+ // technique can't support: @vitejs/plugin-react-swc compiles with SWC,
289
+ // not Babel, so a Babel plugin passed through `babel: { plugins }` is
290
+ // silently ignored. Rather than write a config that looks patched but
291
+ // does nothing, tell the person up front.
292
+ if (/@vitejs\/plugin-react-swc/.test(original)) {
293
+ warn(`${configFile} uses @vitejs/plugin-react-swc, which compiles with SWC and doesn't run Babel plugins.`);
294
+ warn(`devlink's inspector needs the Babel-based @vitejs/plugin-react instead. Skipping auto-patch — see the README for manual setup options.`);
295
+ return;
296
+ }
297
+
298
+ // Find the local name the React plugin was imported as (usually `react`,
299
+ // but people alias it — `import reactPlugin from '@vitejs/plugin-react'`).
300
+ // Also accept CommonJS-style configs (`const react = require('@vitejs/plugin-react')`),
301
+ // since a plain vite.config.js has no obligation to be ESM.
302
+ const importMatch =
303
+ original.match(/import\s+(\w+)\s+from\s+['"]@vitejs\/plugin-react['"]/) ||
304
+ original.match(/const\s+(\w+)\s*=\s*require\(\s*['"]@vitejs\/plugin-react['"]\s*\)/);
305
+ if (!importMatch) {
306
+ const nonReactPlugin = NON_REACT_VITE_PLUGINS.find(([pkg]) => original.includes(pkg));
307
+ if (nonReactPlugin) {
308
+ const [, framework] = nonReactPlugin;
309
+ warn(`${configFile} uses a ${framework} plugin, not @vitejs/plugin-react.`);
310
+ warn(`devlink's inspector currently only supports React projects (it hooks in via a Babel plugin on @vitejs/plugin-react) — there's no equivalent for ${framework} yet. Skipping auto-patch.`);
311
+ return;
312
+ }
313
+ warn(`Could not find an "import ... from '@vitejs/plugin-react'" (or the CommonJS equivalent) in ${configFile}. Add devlink manually:\n\n import { devlinkBabelConfig } from '@rahul_ur/devlink-vite-plugin';\n // inside react({ babel: { plugins: [devlinkBabelConfig({ root: process.cwd() })] } })\n`);
314
+ return;
315
+ }
316
+ const reactLocalName = importMatch[1];
317
+ const isCjsConfig = importMatch[0].trimStart().startsWith('const');
318
+
319
+ // Locate the actual call, e.g. `react(...)` or `react (...)`, skipping the
320
+ // import line itself.
321
+ const callRegex = new RegExp(`\\b${reactLocalName}\\s*\\(`, 'g');
322
+ callRegex.lastIndex = importMatch.index + importMatch[0].length;
323
+ const callMatch = callRegex.exec(original);
324
+ if (!callMatch) {
325
+ warn(`Could not find a ${reactLocalName}(...) call in ${configFile} to patch. Add devlink manually — see README.`);
326
+ return;
327
+ }
89
328
 
90
- // Patch react() call
91
- if (src.includes('react()')) {
92
- src = src.replace(
93
- 'react()',
94
- `react({\n babel: {\n plugins: [devlinkBabelConfig({ root: process.cwd() })].filter(Boolean),\n },\n })`
95
- );
96
- ok(`patched ${configFile} — react() updated`);
97
- } else if (src.match(/react\s*\(\s*\{/)) {
98
- warn(`Could not auto-patch ${configFile}. Add manually:\n\n react({ babel: { plugins: [devlinkBabelConfig({ root: process.cwd() })] } })\n`);
329
+ const openParenIndex = callMatch.index + callMatch[0].length - 1;
330
+ const closeParenIndex = findMatchingBracket(original, openParenIndex, '(', ')');
331
+ if (closeParenIndex === -1) {
332
+ warn(`Could not parse the ${reactLocalName}(...) call in ${configFile} (unbalanced parens?). Add devlink manually — see README.`);
333
+ return;
99
334
  }
100
335
 
101
- fs.writeFileSync(configPath, src);
102
- ok(`updated ${configFile}`);
336
+ const argsText = original.slice(openParenIndex + 1, closeParenIndex);
337
+ const trimmedArgs = argsText.trim();
338
+ const devlinkPluginCall = `devlinkBabelConfig({ root: process.cwd() })`;
339
+
340
+ // We compute a single (insertAt, textToInsert) point-insertion into
341
+ // `original` — deliberately avoiding building up nested replacement
342
+ // strings with their own relative offsets, since re-deriving absolute
343
+ // positions from those is exactly where off-by-one bugs creep in.
344
+ let insertAt, insertText;
345
+
346
+ if (trimmedArgs === '') {
347
+ // react() — bare call, safe to fill in from scratch.
348
+ insertAt = openParenIndex + 1;
349
+ insertText = `{\n babel: {\n plugins: [${devlinkPluginCall}].filter(Boolean),\n },\n }`;
350
+ // This is a full replacement of the (empty) args, not an insertion —
351
+ // handle it as a range replace below via closeParenIndex.
352
+ const patchedBare = original.slice(0, insertAt) + insertText + original.slice(closeParenIndex);
353
+ finishPatch(patchedBare);
354
+ return;
355
+ }
356
+
357
+ if (!trimmedArgs.startsWith('{')) {
358
+ // react(someVariable) or react(...spread) — a bare identifier/expression
359
+ // we can't safely rewrite without risking breaking the config.
360
+ warn(`${reactLocalName}(...) in ${configFile} is called with something other than a literal object, so it can't be auto-patched safely. Add devlink manually:\n\n babel: { plugins: [${devlinkPluginCall}].filter(Boolean) }\n\ninside whatever object is passed to ${reactLocalName}().`);
361
+ return;
362
+ }
363
+
364
+ // react({ ...existing options... }) — edit the object in place instead of
365
+ // clobbering whatever's already there (fast refresh options, other babel
366
+ // plugins, jsxImportSource, etc). All positions below are absolute
367
+ // offsets into `original`.
368
+ const objStart = openParenIndex + 1 + argsText.indexOf('{');
369
+ const objEnd = findMatchingBracket(original, objStart, '{', '}');
370
+ if (objEnd === -1) {
371
+ warn(`Could not parse the options object passed to ${reactLocalName}(...) in ${configFile}. Add devlink manually — see README.`);
372
+ return;
373
+ }
374
+
375
+ const babelKeyMatch = original.slice(objStart, objEnd).match(/\bbabel\s*:\s*\{/);
376
+ if (!babelKeyMatch) {
377
+ // No babel key at all — insert one right after the object's opening brace.
378
+ insertAt = objStart + 1;
379
+ insertText = `\n babel: {\n plugins: [${devlinkPluginCall}].filter(Boolean),\n },`;
380
+ finishPatch(original.slice(0, insertAt) + insertText + original.slice(insertAt));
381
+ return;
382
+ }
383
+
384
+ const babelObjStart = objStart + babelKeyMatch.index + babelKeyMatch[0].length - 1;
385
+ const babelObjEnd = findMatchingBracket(original, babelObjStart, '{', '}');
386
+ if (babelObjEnd === -1) {
387
+ warn(`Could not parse the existing babel option in ${configFile}. Add devlink manually — see README.`);
388
+ return;
389
+ }
390
+
391
+ const pluginsKeyMatch = original.slice(babelObjStart, babelObjEnd).match(/\bplugins\s*:\s*\[/);
392
+ if (!pluginsKeyMatch) {
393
+ // babel: { ... } exists but has no plugins array yet.
394
+ insertAt = babelObjStart + 1;
395
+ insertText = `\n plugins: [${devlinkPluginCall}].filter(Boolean),`;
396
+ finishPatch(original.slice(0, insertAt) + insertText + original.slice(insertAt));
397
+ return;
398
+ }
399
+
400
+ // babel.plugins already exists (e.g. another tool's babel plugin) —
401
+ // append devlink alongside it rather than overwrite it.
402
+ const arrStart = babelObjStart + pluginsKeyMatch.index + pluginsKeyMatch[0].length - 1;
403
+ const arrEnd = findMatchingBracket(original, arrStart, '[', ']');
404
+ if (arrEnd === -1) {
405
+ warn(`Could not parse the existing babel.plugins array in ${configFile}. Add devlink manually — see README.`);
406
+ return;
407
+ }
408
+ const arrInner = original.slice(arrStart + 1, arrEnd);
409
+ insertAt = arrEnd; // insert just before the closing ']'
410
+ insertText = arrInner.trim() === ''
411
+ ? `${devlinkPluginCall}`
412
+ : `${arrInner.trimEnd().endsWith(',') ? '' : ','}\n ${devlinkPluginCall},`;
413
+ finishPatch(original.slice(0, insertAt) + insertText + original.slice(insertAt));
414
+ return;
415
+
416
+ function finishPatch(patched) {
417
+
418
+ // Only now that the react() call itself is confirmed patchable do we add
419
+ // the import — avoids leaving a dangling unused import if patching fails.
420
+ // Match the file's existing module style: mixing a top-level `import`
421
+ // into an otherwise-CommonJS (`require`/`module.exports`) config forces
422
+ // Node to load the whole file as ESM, at which point `require` is
423
+ // undefined and the config throws at load time — a worse outcome than
424
+ // not patching at all.
425
+ let insertAt = 0;
426
+ let importLine;
427
+ if (isCjsConfig) {
428
+ importLine = `const { devlinkBabelConfig } = require('@rahul_ur/devlink-vite-plugin');\n`;
429
+ const requireLines = [...patched.matchAll(/^.*\brequire\(['"][^'"]+['"]\).*$/gm)];
430
+ if (requireLines.length > 0) {
431
+ const last = requireLines[requireLines.length - 1];
432
+ insertAt = last.index + last[0].length + 1;
433
+ }
434
+ } else {
435
+ importLine = `import { devlinkBabelConfig } from '@rahul_ur/devlink-vite-plugin';\n`;
436
+ const lastImport = patched.lastIndexOf('\nimport ');
437
+ insertAt = lastImport === -1 ? 0 : patched.indexOf('\n', lastImport) + 1;
438
+ }
439
+ patched = patched.slice(0, insertAt) + importLine + patched.slice(insertAt);
440
+
441
+ fs.writeFileSync(configPath, patched);
442
+ ok(`patched ${configFile} — ${reactLocalName}() now runs devlink's babel plugin`);
443
+ }
103
444
  }
104
445
 
105
446
  // ─── Setup Next.js ────────────────────────────────────────────────────────────
@@ -161,21 +502,27 @@ function setupCra() {
161
502
  // ─── Generate devlink.config.ts ───────────────────────────────────────────────
162
503
 
163
504
  function generateConfig() {
164
- const configPath = path.join(projectRoot, 'devlink.config.ts');
165
- if (exists(configPath)) { ok('devlink.config.ts already exists'); return; }
505
+ const candidates = ['devlink.config.ts', 'devlink.config.js', 'devlink.config.mts', 'devlink.config.mjs', 'devlink.config.cts', 'devlink.config.cjs'];
506
+ const existingConfig = candidates.find(f => exists(path.join(projectRoot, f)));
507
+ if (existingConfig) { ok(`${existingConfig} already exists`); return; }
508
+
509
+ const ext = projectUsesTypeScript() ? 'ts' : 'js';
510
+ const configPath = path.join(projectRoot, `devlink.config.${ext}`);
166
511
 
167
- const root = projectRoot.replace(/\\/g, '/');
168
- const srcDir = exists(path.join(projectRoot, 'src')) ? `${root}/src` : root;
512
+ // Use relative paths, not absolute filesystem paths — this file is meant
513
+ // to be committed, and an absolute path baked in here would be wrong the
514
+ // moment anyone else on the team (or CI) checks the project out.
515
+ const rootPath = exists(path.join(projectRoot, 'src')) ? './src' : '.';
169
516
 
170
- fs.writeFileSync(configPath, `// devlink.config.ts — edit projectRoot and rootPath for your project
517
+ fs.writeFileSync(configPath, `// devlink.config.${ext} — edit rootPath if your source lives somewhere other than ./src
171
518
  export const devlinkConfig = {
172
- projectRoot: '${root}',
173
- rootPath: '${srcDir}',
519
+ projectRoot: '.',
520
+ rootPath: '${rootPath}',
174
521
  defaultDocked: true,
175
522
  dockedWidth: 460,
176
523
  };
177
524
  `);
178
- ok('created devlink.config.ts');
525
+ ok(`created devlink.config.${ext}`);
179
526
  }
180
527
 
181
528
  // ─── Print main.tsx instructions ──────────────────────────────────────────────
@@ -186,43 +533,77 @@ function printInstructions(framework) {
186
533
  console.log('\x1b[36m devlink setup complete — final step:\x1b[0m');
187
534
  console.log('\x1b[36m─────────────────────────────────────────────────\x1b[0m\n');
188
535
 
536
+ // A plain top-level `import { DevlinkStudio } from '@rahul_ur/devlink-studio'`
537
+ // bundles it (and its dependencies — Monaco, xterm) into every build,
538
+ // production included. DevlinkStudio's own `enabled` prop stops it from
539
+ // *rendering* outside dev, but that's a runtime check — it does nothing
540
+ // about bundle size or exposing the AI companion/inspector code to real
541
+ // users. A dynamic import gated on the DEV flag drops it during Rollup's
542
+ // production build entirely, as a separate chunk your production users
543
+ // never download.
189
544
  if (isNext) {
190
545
  console.log('Add to pages/_app.tsx:\n');
191
- console.log(` import { DevlinkBridge } from '@rahul_ur/devlink-bridge'
192
- import { DevlinkStudio } from '@rahul_ur/devlink-studio'
546
+ console.log(` import { lazy, Suspense } from 'react'
547
+ import { DevlinkBridge } from '@rahul_ur/devlink-bridge'
193
548
  import { devlinkConfig } from '../devlink.config'
194
549
 
195
- const bridge = new DevlinkBridge()
550
+ // Dynamic + DEV-gated: keeps devlink-studio (and Monaco/xterm) out of
551
+ // the production bundle entirely — not just inactive, actually absent.
552
+ const DevlinkStudio = process.env.NODE_ENV === 'development'
553
+ ? lazy(() => import('@rahul_ur/devlink-studio').then(m => ({ default: m.DevlinkStudio })))
554
+ : null
555
+
556
+ const bridge = process.env.NODE_ENV === 'development' ? new DevlinkBridge() : null
196
557
 
197
558
  export default function App({ Component, pageProps }) {
559
+ if (!DevlinkStudio) return <Component {...pageProps} />
198
560
  return (
199
- <DevlinkStudio bridge={bridge} {...devlinkConfig}>
200
- <Component {...pageProps} />
201
- </DevlinkStudio>
561
+ <Suspense fallback={<Component {...pageProps} />}>
562
+ <DevlinkStudio bridge={bridge} {...devlinkConfig}>
563
+ <Component {...pageProps} />
564
+ </DevlinkStudio>
565
+ </Suspense>
202
566
  )
203
567
  }`);
204
568
  } else {
205
569
  console.log('Add to src/main.tsx:\n');
206
- console.log(` import { DevlinkBridge } from '@rahul_ur/devlink-bridge'
207
- import { DevlinkStudio } from '@rahul_ur/devlink-studio'
570
+ console.log(` import { lazy, Suspense } from 'react'
571
+ import { DevlinkBridge } from '@rahul_ur/devlink-bridge'
208
572
  import { devlinkConfig } from '../devlink.config'
209
573
 
210
- const bridge = new DevlinkBridge()
574
+ // Dynamic + DEV-gated: keeps devlink-studio (and Monaco/xterm) out of
575
+ // the production bundle entirely — not just inactive, actually absent.
576
+ const DevlinkStudio = import.meta.env.DEV
577
+ ? lazy(() => import('@rahul_ur/devlink-studio').then(m => ({ default: m.DevlinkStudio })))
578
+ : null
579
+
580
+ const bridge = import.meta.env.DEV ? new DevlinkBridge() : null
211
581
 
212
582
  function Root() {
583
+ if (!DevlinkStudio) return <App />
213
584
  return (
214
- <DevlinkStudio bridge={bridge} {...devlinkConfig}>
215
- <App />
216
- </DevlinkStudio>
585
+ <Suspense fallback={<App />}>
586
+ <DevlinkStudio bridge={bridge} {...devlinkConfig}>
587
+ <App />
588
+ </DevlinkStudio>
589
+ </Suspense>
217
590
  )
218
591
  }
219
592
 
220
- ReactDOM.createRoot(document.getElementById('root')!).render(
221
- <React.StrictMode><Root /></React.StrictMode>
222
- )`);
593
+ const rootElement = document.getElementById("root");
594
+
595
+ if (rootElement) {
596
+ ReactDOM.createRoot(rootElement).render(
597
+ <React.StrictMode>
598
+ <Root />
599
+ </React.StrictMode>,
600
+ );
601
+ }`);
223
602
  }
224
603
 
225
- console.log('\nThen start the bridge server:\n');
604
+ console.log('\nThis keeps the AI companion, editor, and inspector out of your production');
605
+ console.log('bundle entirely (dynamic import, dropped at build time) — not just hidden.\n');
606
+ console.log('Then start the bridge server:\n');
226
607
  console.log(' npm run devlink:bridge\n');
227
608
  console.log('Press \x1b[36mAlt+D\x1b[0m in the browser to activate inspect mode.\n');
228
609
  console.log('\x1b[36m─────────────────────────────────────────────────\x1b[0m\n');
@@ -230,11 +611,26 @@ function printInstructions(framework) {
230
611
 
231
612
  // ─── Commands ─────────────────────────────────────────────────────────────────
232
613
 
233
- function runSetup() {
614
+ async function runSetup() {
234
615
  log(`setting up devlink in ${projectRoot}`);
235
616
  const framework = detectFramework();
236
617
  log(`detected framework: ${framework}`);
237
618
 
619
+ if (framework === 'unknown') {
620
+ const candidates = findFrontendSubdirectories();
621
+ if (candidates.length > 0) {
622
+ warn(`No frontend framework detected at ${projectRoot} — this looks like it might be a monorepo root.`);
623
+ if (candidates.length === 1) {
624
+ warn(`Found what looks like a frontend project in ./${candidates[0]}. Try running devlink setup from there instead:\n\n cd ${candidates[0]} && npx devlink setup\n`);
625
+ } else {
626
+ warn(`Found possible frontend projects in: ${candidates.map(c => `./${c}`).join(', ')}. Re-run devlink setup from inside the right one.`);
627
+ }
628
+ // Deliberately stop here rather than writing devlink.config.js /
629
+ // package.json scripts at a root that isn't actually the app.
630
+ return;
631
+ }
632
+ }
633
+
238
634
  addScripts();
239
635
 
240
636
  if (framework === 'vite') setupVite();
@@ -246,6 +642,7 @@ function runSetup() {
246
642
  }
247
643
 
248
644
  generateConfig();
645
+ await promptForApiKeys();
249
646
  printInstructions(framework);
250
647
  }
251
648
 
@@ -254,8 +651,9 @@ function runHelp() {
254
651
  \x1b[36mdevlink\x1b[0m — click any element in your browser, jump to source, edit, UI updates live.
255
652
 
256
653
  \x1b[1mCommands:\x1b[0m
257
- devlink setup Auto-configure your project (Vite / Next.js / CRA)
258
- devlink help Show this help message
654
+ devlink setup Auto-configure your project (Vite / Next.js / CRA)
655
+ devlink setup --skip-keys Same, but skip the optional cloud API key prompt
656
+ devlink help Show this help message
259
657
 
260
658
  \x1b[1mQuick start:\x1b[0m
261
659
  npm install @rahul_ur/devlink
@@ -274,6 +672,12 @@ function runHelp() {
274
672
  @rahul_ur/devlink-babel-plugin Stamps data-source at build time
275
673
  @rahul_ur/devlink-vite-plugin Wires plugin into Vite automatically
276
674
 
675
+ \x1b[1mCloud model keys:\x1b[0m
676
+ \`devlink setup\` will offer to save an Anthropic and/or OpenAI API key to
677
+ .env (gitignored automatically) so Claude/GPT show up in the AI companion's
678
+ model picker alongside your local Ollama models. Fully optional — Ollama
679
+ keeps working with no keys set at all.
680
+
277
681
  \x1b[1mDocs:\x1b[0m https://github.com/rahul_ur/devlink
278
682
  `);
279
683
  }
package/logo.png ADDED
Binary file
package/package.json CHANGED
@@ -1,25 +1,19 @@
1
1
  {
2
2
  "name": "@rahul_ur/devlink",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
4
4
  "description": "Click any element in your browser → jump to source code → edit → UI updates live. One command setup for Vite, Next.js and CRA.",
5
- "type": "module",
6
5
  "main": "index.js",
7
- "exports": {
8
- ".": {
9
- "import": "./index.js",
10
- "types": "./index.d.ts"
11
- }
12
- },
6
+ "type": "module",
13
7
  "bin": {
14
8
  "devlink": "./bin/cli.mjs",
15
9
  "devlink-setup": "./bin/cli.mjs"
16
10
  },
17
11
  "files": [
18
12
  "bin",
19
- "templates",
20
13
  "index.js",
21
14
  "index.d.ts",
22
- "README.md"
15
+ "README.md",
16
+ "logo.png"
23
17
  ],
24
18
  "scripts": {
25
19
  "prepublishOnly": "npm pack --dry-run"
@@ -37,21 +31,21 @@
37
31
  "editor",
38
32
  "babel-plugin"
39
33
  ],
40
- "author": "Rahul Urmaliya",
34
+ "author": "Rahul <your@email.com>",
41
35
  "license": "MIT",
42
36
  "repository": {
43
37
  "type": "git",
44
- "url": "git+https://github.com/BiginerCoder/devlink.git"
38
+ "url": "https://github.com/rahul_ur/devlink"
45
39
  },
46
40
  "bugs": {
47
- "url": "https://github.com/BiginerCoder/devlink/issues"
41
+ "url": "https://github.com/rahul_ur/devlink/issues"
48
42
  },
49
- "homepage": "https://github.com/BiginerCoder/devlink#readme",
43
+ "homepage": "https://github.com/rahul_ur/devlink#readme",
50
44
  "dependencies": {
51
- "@rahul_ur/devlink-bridge": "^1.0.1",
52
- "@rahul_ur/devlink-studio": "^1.0.5",
53
- "@rahul_ur/devlink-babel-plugin": "^1.0.4",
54
- "@rahul_ur/devlink-vite-plugin": "^1.0.6"
45
+ "@rahul_ur/devlink-babel-plugin":"1.0.6",
46
+ "@rahul_ur/devlink-bridge": "1.0.4",
47
+ "@rahul_ur/devlink-studio": "1.0.7",
48
+ "@rahul_ur/devlink-vite-plugin": "1.0.7"
55
49
  },
56
50
  "engines": {
57
51
  "node": ">=18.0.0"