@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.
- package/README.md +4 -0
- package/bin/cli.mjs +453 -49
- package/logo.png +0 -0
- package/package.json +12 -18
package/README.md
CHANGED
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
|
|
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,
|
|
72
|
-
ok(
|
|
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
|
-
|
|
280
|
+
const original = fs.readFileSync(configPath, 'utf8');
|
|
78
281
|
|
|
79
|
-
if (
|
|
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
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
|
165
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
|
517
|
+
fs.writeFileSync(configPath, `// devlink.config.${ext} — edit rootPath if your source lives somewhere other than ./src
|
|
171
518
|
export const devlinkConfig = {
|
|
172
|
-
projectRoot: '
|
|
173
|
-
rootPath: '${
|
|
519
|
+
projectRoot: '.',
|
|
520
|
+
rootPath: '${rootPath}',
|
|
174
521
|
defaultDocked: true,
|
|
175
522
|
dockedWidth: 460,
|
|
176
523
|
};
|
|
177
524
|
`);
|
|
178
|
-
ok(
|
|
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 {
|
|
192
|
-
import {
|
|
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
|
-
|
|
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
|
-
<
|
|
200
|
-
<
|
|
201
|
-
|
|
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 {
|
|
207
|
-
import {
|
|
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
|
-
|
|
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
|
-
<
|
|
215
|
-
<
|
|
216
|
-
|
|
585
|
+
<Suspense fallback={<App />}>
|
|
586
|
+
<DevlinkStudio bridge={bridge} {...devlinkConfig}>
|
|
587
|
+
<App />
|
|
588
|
+
</DevlinkStudio>
|
|
589
|
+
</Suspense>
|
|
217
590
|
)
|
|
218
591
|
}
|
|
219
592
|
|
|
220
|
-
|
|
221
|
-
|
|
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('\
|
|
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
|
|
258
|
-
devlink
|
|
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.
|
|
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
|
-
"
|
|
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
|
|
34
|
+
"author": "Rahul <your@email.com>",
|
|
41
35
|
"license": "MIT",
|
|
42
36
|
"repository": {
|
|
43
37
|
"type": "git",
|
|
44
|
-
"url": "
|
|
38
|
+
"url": "https://github.com/rahul_ur/devlink"
|
|
45
39
|
},
|
|
46
40
|
"bugs": {
|
|
47
|
-
"url": "https://github.com/
|
|
41
|
+
"url": "https://github.com/rahul_ur/devlink/issues"
|
|
48
42
|
},
|
|
49
|
-
"homepage": "https://github.com/
|
|
43
|
+
"homepage": "https://github.com/rahul_ur/devlink#readme",
|
|
50
44
|
"dependencies": {
|
|
51
|
-
"@rahul_ur/devlink-
|
|
52
|
-
"@rahul_ur/devlink-
|
|
53
|
-
"@rahul_ur/devlink-
|
|
54
|
-
"@rahul_ur/devlink-vite-plugin": "
|
|
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"
|