blockend-cli 1.5.0 → 2.0.0

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/dist/index.js CHANGED
@@ -5,9 +5,9 @@ import { defineCommand, runMain } from "citty";
5
5
 
6
6
  // src/commands/add.ts
7
7
  import path, { join, dirname } from "path";
8
- import fs, { readFile } from "fs/promises";
8
+ import fs from "fs/promises";
9
9
  import { exec } from "child_process";
10
- import { intro, outro, select, spinner, confirm, isCancel, log } from "@clack/prompts";
10
+ import { intro, outro, select, multiselect, spinner, confirm, isCancel, log } from "@clack/prompts";
11
11
  import pc from "picocolors";
12
12
  var REPO_OWNER = "codewithnuh";
13
13
  var REPO_NAME = "blockend";
@@ -20,17 +20,85 @@ function blockSupportsEnv(block, envKey) {
20
20
  }
21
21
  return block.adapters?.[envKey] !== void 0 || block.environments?.[envKey] !== void 0;
22
22
  }
23
- var getMissingDependencies = (deps, installed) => [...new Set(deps)].filter((dep) => !(dep in installed));
23
+ function parseDepSpec(dep) {
24
+ const atIndex = dep.lastIndexOf("@");
25
+ if (atIndex > 0) {
26
+ return { name: dep.slice(0, atIndex), versionRange: dep.slice(atIndex + 1) };
27
+ }
28
+ return { name: dep, versionRange: null };
29
+ }
30
+ function semverSatisfies(installed, range) {
31
+ const parse = (v) => {
32
+ const clean = v.replace(/^[~^>=<]+/, "");
33
+ const parts = clean.split(".").map(Number);
34
+ return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
35
+ };
36
+ const [iMajor, iMinor, iPatch] = parse(installed);
37
+ const raw = range.replace(/^[~^>=<]+/, "");
38
+ const [rMajor, rMinor, rPatch] = parse(raw);
39
+ if (range.startsWith("^")) {
40
+ return (iMajor > rMajor || iMajor === rMajor && (iMinor > rMinor || iMinor === rMinor && iPatch >= rPatch)) && iMajor === rMajor;
41
+ }
42
+ if (range.startsWith("~")) {
43
+ return iMajor === rMajor && iMinor === rMinor && iPatch >= rPatch;
44
+ }
45
+ if (range.startsWith(">=")) {
46
+ return iMajor > rMajor || iMajor === rMajor && (iMinor > rMinor || iMinor === rMinor && iPatch >= rPatch);
47
+ }
48
+ return installed === raw;
49
+ }
50
+ function getVersionConflicts(deps, installed) {
51
+ const conflicts = [];
52
+ for (const dep of deps) {
53
+ const { name, versionRange } = parseDepSpec(dep);
54
+ if (versionRange && name in installed) {
55
+ const installedVersion = installed[name];
56
+ if (!semverSatisfies(installedVersion, versionRange)) {
57
+ conflicts.push(`${name}: block requires ${versionRange}, you have ${installedVersion}`);
58
+ }
59
+ }
60
+ }
61
+ return conflicts;
62
+ }
63
+ var getMissingDependencies = (deps, installed, forceLatest = false) => {
64
+ const seen = /* @__PURE__ */ new Set();
65
+ const result = [];
66
+ for (const dep of deps) {
67
+ const { name, versionRange } = parseDepSpec(dep);
68
+ if (seen.has(name)) continue;
69
+ seen.add(name);
70
+ if (!(name in installed)) {
71
+ result.push(dep);
72
+ } else if (versionRange && !semverSatisfies(installed[name], versionRange)) {
73
+ result.push(dep);
74
+ } else if (!versionRange && forceLatest) {
75
+ result.push(`${name}@latest`);
76
+ }
77
+ }
78
+ return result;
79
+ };
24
80
  function resolveAdapterContext(block, envKey) {
25
81
  return block.adapters?.[envKey] ?? block.adapters?.["*"] ?? block.environments?.[envKey] ?? block.environments?.["*"] ?? null;
26
82
  }
27
- function rewriteFileImports(fileContent, writtenFilePath, blocksRoot, aliasBase, strategy) {
83
+ function resolveIndexImport(importPath, sourceFileDir, downloadedFiles) {
84
+ const resolved = path.resolve(sourceFileDir, importPath);
85
+ for (const downloaded of downloadedFiles) {
86
+ const downloadedAbs = path.resolve(sourceFileDir, downloaded);
87
+ const dirOfDownloaded = dirname(downloadedAbs);
88
+ const basenameOfDownloaded = path.basename(downloadedAbs);
89
+ if (dirOfDownloaded === resolved && basenameOfDownloaded.startsWith("index.")) {
90
+ return `${importPath}/index`;
91
+ }
92
+ }
93
+ return null;
94
+ }
95
+ function rewriteFileImports(fileContent, writtenFilePath, blocksRoot, aliasBase, strategy, downloadedFiles = /* @__PURE__ */ new Set()) {
28
96
  const importRegex = /((?:from|import)\s*)(['"`])(\.\.?\/[^'"` \n\r]+)(['"`])/g;
29
97
  const fileDir = dirname(writtenFilePath);
30
- return fileContent.replace(importRegex, (match, keyword, openQuote, relativePath, closeQuote) => {
31
- let targetPath = relativePath;
98
+ return fileContent.replace(importRegex, (match, keyword, openQuote, importPath, closeQuote) => {
99
+ let targetPath = importPath;
32
100
  if (aliasBase && !aliasBase.startsWith(".")) {
33
- const absoluteTarget = path.resolve(fileDir, relativePath);
101
+ const absoluteTarget = path.resolve(fileDir, importPath);
34
102
  const fromBlocksRoot = path.relative(blocksRoot, absoluteTarget).replace(/\\/g, "/");
35
103
  if (!fromBlocksRoot.startsWith("..")) {
36
104
  targetPath = `${aliasBase}/${fromBlocksRoot}`.replace(/\/+/g, "/");
@@ -39,6 +107,12 @@ function rewriteFileImports(fileContent, writtenFilePath, blocksRoot, aliasBase,
39
107
  const hasJsExt = targetPath.endsWith(".js");
40
108
  if (strategy === "rewrite" && !hasJsExt) {
41
109
  targetPath = targetPath.replace(/\.(ts|tsx|mts|cts)$/, "");
110
+ if (downloadedFiles.size > 0) {
111
+ const indexResolved = resolveIndexImport(targetPath, fileDir, downloadedFiles);
112
+ if (indexResolved) {
113
+ targetPath = indexResolved;
114
+ }
115
+ }
42
116
  targetPath = `${targetPath}.js`;
43
117
  } else if (strategy === "remove" && hasJsExt) {
44
118
  targetPath = targetPath.slice(0, -3);
@@ -81,87 +155,17 @@ async function findUp(filename, startDir) {
81
155
  }
82
156
  return null;
83
157
  }
84
- async function addCommand(blockName, options = {}) {
158
+ async function addSingleBlock(targetBlock, blockMap, config, rootDir, physicalBlocksPath, blocksRootAbsolute, aliasBase, envKey, rewriteStrategy, options) {
85
159
  const { yes = false, json = false } = options;
86
- if (!json) {
87
- console.log("");
88
- intro(`${pc.bgCyan(pc.black(" blockend "))} ${pc.dim("add")}`);
89
- }
90
- const cwd = process.cwd();
91
- const configPath = await findUp("blockend.json", cwd);
92
- if (!configPath) {
93
- outputError(json, "blockend.json not found. Run 'npx blockend init' first.");
94
- return;
95
- }
96
- const rootDir = dirname(configPath);
97
- let config;
98
- try {
99
- config = JSON.parse(await fs.readFile(configPath, "utf-8"));
100
- } catch (err) {
101
- outputError(json, "Failed to parse blockend.json.");
102
- if (!json) log.error(pc.dim(String(err)));
103
- return;
104
- }
105
- if (config.language !== "typescript") {
106
- outputError(json, "Blockend currently only supports TypeScript projects.");
107
- return;
108
- }
109
- const physicalBlocksPath = config.paths.blocks;
110
- const blocksRootAbsolute = path.resolve(rootDir, physicalBlocksPath);
111
- const aliasBase = config.aliases.blocks;
112
- const envKey = config.environment;
113
- const rewriteStrategy = config.importRewriteStrategy ?? "remove";
114
160
  const s = spinner();
115
- if (!json) s.start("Fetching registry...");
116
- let registry;
117
- try {
118
- const res = await fetch(MANIFEST_URL);
119
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
120
- registry = await res.json();
121
- if (!json) s.stop("Registry synced.");
122
- } catch (err) {
123
- if (!json) {
124
- s.stop("Failed to fetch registry.");
125
- log.error(pc.dim(String(err)));
126
- } else outputError(json, "Failed to fetch registry from GitHub.");
127
- return;
128
- }
129
- const blockMap = registry.blocks && typeof registry.blocks === "object" && !Array.isArray(registry.blocks) ? registry.blocks : registry;
130
- if (yes && !blockName) {
131
- outputError(json, "Block name required with --yes. Usage: blockend add <block> --yes");
132
- return;
133
- }
134
- let targetBlock = blockName;
135
- if (!targetBlock) {
136
- const available = Object.entries(blockMap).filter(
137
- ([, block]) => block != null && blockSupportsEnv(block, envKey)
138
- );
139
- if (available.length === 0) {
140
- outputError(json, `No blocks available for environment: ${envKey}`);
141
- if (!json) outro(pc.dim("Exiting."));
142
- return;
143
- }
144
- const maxKeyLen = Math.max(...available.map(([k]) => k.length), 0);
145
- const selectPrompt = await select({
146
- message: "Which block would you like to add?",
147
- options: available.map(([key, block], i) => ({
148
- value: key,
149
- label: `${pc.dim(`${i + 1}.`)} ${pc.bold(pc.cyan(key.padEnd(maxKeyLen + 4)))}${pc.dim(
150
- block.description.length > 60 ? `${block.description.slice(0, 60)}...` : block.description
151
- )}`
152
- }))
153
- });
154
- handleCancel(selectPrompt);
155
- targetBlock = selectPrompt;
156
- }
157
161
  const blockMeta = blockMap[targetBlock];
158
162
  if (!blockMeta) {
159
163
  outputError(json, `Block "${targetBlock}" not found in the registry.`);
160
- return;
164
+ return { success: false, filesWritten: [], message: `Block "${targetBlock}" not found` };
161
165
  }
162
166
  if (!blockSupportsEnv(blockMeta, envKey)) {
163
167
  outputError(json, `Block "${targetBlock}" does not support environment: ${envKey}`);
164
- return;
168
+ return { success: false, filesWritten: [], message: `Block "${targetBlock}" incompatible` };
165
169
  }
166
170
  const adapterContext = resolveAdapterContext(blockMeta, envKey);
167
171
  if (!adapterContext) {
@@ -169,12 +173,12 @@ async function addCommand(blockName, options = {}) {
169
173
  json,
170
174
  `Block "${targetBlock}" has no adapter or environment entry for: ${envKey}. This is a registry configuration issue \u2014 please open an issue.`
171
175
  );
172
- return;
176
+ return { success: false, filesWritten: [], message: `No adapter for "${targetBlock}"` };
173
177
  }
174
178
  const variantKeys = Object.keys(adapterContext.variants ?? {});
175
179
  if (variantKeys.length === 0) {
176
180
  outputError(json, `No variants found for "${targetBlock}" / "${envKey}".`);
177
- return;
181
+ return { success: false, filesWritten: [], message: `No variants for "${targetBlock}"` };
178
182
  }
179
183
  let selectedVariant;
180
184
  if (yes) {
@@ -185,7 +189,7 @@ async function addCommand(blockName, options = {}) {
185
189
  selectedVariant = variantKeys[0];
186
190
  } else {
187
191
  const variantPrompt = await select({
188
- message: "Select a storage variant:",
192
+ message: `Select a storage variant for ${targetBlock}:`,
189
193
  options: variantKeys.map((v) => ({ value: v, label: v.toUpperCase() }))
190
194
  });
191
195
  handleCancel(variantPrompt);
@@ -196,7 +200,7 @@ async function addCommand(blockName, options = {}) {
196
200
  const packageJsonPath = await findUp("package.json", rootDir);
197
201
  if (!packageJsonPath) {
198
202
  outputError(json, "Could not locate package.json.");
199
- return;
203
+ return { success: false, filesWritten: [], message: "package.json not found" };
200
204
  }
201
205
  const packageJsonDir = dirname(packageJsonPath);
202
206
  let packageJson;
@@ -205,34 +209,42 @@ async function addCommand(blockName, options = {}) {
205
209
  } catch (err) {
206
210
  outputError(json, "Failed to parse package.json.");
207
211
  if (!json) log.error(pc.dim(String(err)));
208
- return;
212
+ return { success: false, filesWritten: [], message: "Failed to parse package.json" };
209
213
  }
210
214
  const installedDeps = {
211
215
  ...packageJson.dependencies ?? {},
212
216
  ...packageJson.devDependencies ?? {}
213
217
  };
214
- const missingProd = getMissingDependencies(
215
- [
216
- ...blockMeta.dependencies ?? [],
217
- ...adapterContext.dependencies ?? [],
218
- ...variantMeta?.dependencies ?? []
219
- ],
220
- installedDeps
221
- );
222
- const missingDev = getMissingDependencies(
223
- [
224
- ...blockMeta.devDependencies ?? [],
225
- ...adapterContext.devDependencies ?? [],
226
- ...variantMeta?.devDependencies ?? []
227
- ],
228
- installedDeps
229
- );
218
+ const allBlockDeps = [
219
+ ...blockMeta.dependencies ?? [],
220
+ ...adapterContext.dependencies ?? [],
221
+ ...variantMeta?.dependencies ?? []
222
+ ];
223
+ const allBlockDevDeps = [
224
+ ...blockMeta.devDependencies ?? [],
225
+ ...adapterContext.devDependencies ?? [],
226
+ ...variantMeta?.devDependencies ?? []
227
+ ];
228
+ const missingProd = getMissingDependencies(allBlockDeps, installedDeps, true);
229
+ const missingDev = getMissingDependencies(allBlockDevDeps, installedDeps, true);
230
+ const prodConflicts = getVersionConflicts(allBlockDeps, installedDeps);
231
+ const devConflicts = getVersionConflicts(allBlockDevDeps, installedDeps);
232
+ const allConflicts = [...prodConflicts, ...devConflicts];
233
+ if (allConflicts.length > 0 && !json) {
234
+ log.warn(`Version conflicts detected:`);
235
+ for (const conflict of allConflicts) {
236
+ log.warn(` ${pc.yellow("!")} ${conflict}`);
237
+ }
238
+ }
230
239
  if (missingProd.length > 0 || missingDev.length > 0) {
231
240
  if (!json) log.warn(`Missing: ${pc.cyan([...missingProd, ...missingDev].join(", "))}`);
232
- const shouldInstall = yes ? true : await confirm({ message: "Install missing dependencies?", initialValue: true });
241
+ const shouldInstall = yes ? true : await confirm({
242
+ message: `Install missing dependencies for ${targetBlock}?`,
243
+ initialValue: true
244
+ });
233
245
  if (!yes) handleCancel(shouldInstall);
234
246
  if (shouldInstall) {
235
- const pm = await readFile(join(packageJsonDir, "blockend.json"), "utf8").then((data) => JSON.parse(data).packageManager).catch(() => "npm");
247
+ const pm = await fs.readFile(join(packageJsonDir, "blockend.json"), "utf8").then((data) => JSON.parse(data).packageManager).catch(() => "npm");
236
248
  if (!json) s.start(`Installing via ${pm}...`);
237
249
  const tasks = [];
238
250
  if (missingProd.length > 0) {
@@ -268,7 +280,7 @@ async function addCommand(blockName, options = {}) {
268
280
  s.stop("Installation failed.");
269
281
  log.error(pc.dim(String(err)));
270
282
  } else outputError(json, "Dependency installation failed.");
271
- return;
283
+ return { success: false, filesWritten: [], message: "Dependency installation failed" };
272
284
  }
273
285
  }
274
286
  }
@@ -305,43 +317,215 @@ async function addCommand(blockName, options = {}) {
305
317
  });
306
318
  if (!yes) handleCancel(overwrite);
307
319
  if (!overwrite) {
308
- outputResult(json, { success: false, reason: "aborted", message: "Local files preserved." });
309
- return;
320
+ return { success: false, filesWritten: [], message: "Local files preserved." };
310
321
  }
311
322
  }
312
323
  if (!json) s.start(`Downloading ${targetBlock}...`);
313
324
  const filesWritten = [];
325
+ const downloadedTargets = /* @__PURE__ */ new Set();
314
326
  try {
327
+ const fileBatches = [];
315
328
  for (const fm of filesToDownload) {
316
- const res = await fetch(`${MANIFEST_URL}/${fm.source}`);
329
+ const res = await fetch(`${RAW_CDN_BASE}/${fm.source}`);
317
330
  if (!res.ok) throw new Error(`Download failed: ${fm.source} (HTTP ${res.status})`);
318
- let content = await res.text();
331
+ fileBatches.push({ fm, raw: await res.text() });
332
+ downloadedTargets.add(fm.target);
333
+ }
334
+ let contentHash = 0;
335
+ for (const { fm, raw } of fileBatches) {
319
336
  const writtenFilePath = path.join(targetFolder, fm.target);
320
- content = rewriteFileImports(
321
- content,
337
+ const content = rewriteFileImports(
338
+ raw,
322
339
  writtenFilePath,
323
340
  blocksRootAbsolute,
324
341
  aliasBase,
325
- rewriteStrategy
342
+ rewriteStrategy,
343
+ downloadedTargets
326
344
  );
327
345
  await fs.mkdir(dirname(writtenFilePath), { recursive: true });
328
346
  await fs.writeFile(writtenFilePath, content, "utf-8");
329
347
  filesWritten.push(writtenFilePath);
348
+ for (let i = 0; i < content.length; i++) {
349
+ contentHash = (contentHash << 5) - contentHash + content.charCodeAt(i) | 0;
350
+ }
351
+ }
352
+ const blockVersion = blockMeta.version ?? "0.0.0";
353
+ const installedRecord = {
354
+ name: targetBlock,
355
+ version: blockVersion,
356
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
357
+ files: filesWritten.map((f) => path.relative(targetFolder, f).replace(/\\/g, "/")),
358
+ contentHash: contentHash.toString(16)
359
+ };
360
+ try {
361
+ const blockendPath = join(rootDir, "blockend.json");
362
+ const cfgRaw = await fs.readFile(blockendPath, "utf-8");
363
+ const cfgData = JSON.parse(cfgRaw);
364
+ if (!Array.isArray(cfgData.installed)) cfgData.installed = [];
365
+ cfgData.installed = cfgData.installed.filter((b) => b.name !== targetBlock);
366
+ cfgData.installed.push(installedRecord);
367
+ await fs.writeFile(blockendPath, JSON.stringify(cfgData, null, 2), "utf-8");
368
+ } catch {
330
369
  }
331
370
  if (!json) s.stop("Files written.");
332
- outputResult(json, {
371
+ return {
333
372
  success: true,
334
- block: targetBlock,
335
373
  filesWritten,
336
- dependenciesInstalled: [...missingProd, ...missingDev],
337
374
  message: `${targetBlock} added to ${physicalBlocksPath}/${targetBlock}`
338
- });
375
+ };
339
376
  } catch (err) {
340
377
  if (!json) {
341
378
  s.stop("Failed.");
342
379
  log.error(pc.dim(String(err)));
343
380
  } else outputError(json, "Fatal error while writing block files.");
381
+ return { success: false, filesWritten: [], message: `Failed to add ${targetBlock}` };
382
+ }
383
+ }
384
+ async function addCommand(blockName, options = {}) {
385
+ const { yes = false, json = false, multi = false } = options;
386
+ if (!json) {
387
+ console.log("");
388
+ intro(`${pc.bgCyan(pc.black(" blockend "))} ${pc.dim("add")}`);
389
+ }
390
+ const cwd = process.cwd();
391
+ const configPath = await findUp("blockend.json", cwd);
392
+ if (!configPath) {
393
+ outputError(json, "blockend.json not found. Run 'npx blockend init' first.");
394
+ return;
395
+ }
396
+ const rootDir = dirname(configPath);
397
+ let config;
398
+ try {
399
+ config = JSON.parse(await fs.readFile(configPath, "utf-8"));
400
+ } catch (err) {
401
+ outputError(json, "Failed to parse blockend.json.");
402
+ if (!json) log.error(pc.dim(String(err)));
403
+ return;
404
+ }
405
+ if (config.language !== "typescript") {
406
+ outputError(json, "Blockend currently only supports TypeScript projects.");
407
+ return;
408
+ }
409
+ const physicalBlocksPath = config.paths.blocks;
410
+ const blocksRootAbsolute = path.resolve(rootDir, physicalBlocksPath);
411
+ const aliasBase = config.aliases.blocks;
412
+ const envKey = config.environment;
413
+ const rewriteStrategy = config.importRewriteStrategy ?? "remove";
414
+ const s = spinner();
415
+ if (!json) s.start("Fetching registry...");
416
+ let registry;
417
+ try {
418
+ const res = await fetch(MANIFEST_URL);
419
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
420
+ registry = await res.json();
421
+ if (!json) s.stop("Registry synced.");
422
+ } catch (err) {
423
+ if (!json) {
424
+ s.stop("Failed to fetch registry.");
425
+ log.error(pc.dim(String(err)));
426
+ } else outputError(json, "Failed to fetch registry from GitHub.");
427
+ return;
428
+ }
429
+ const blockMap = registry.blocks && typeof registry.blocks === "object" && !Array.isArray(registry.blocks) ? registry.blocks : registry;
430
+ if (yes && !blockName) {
431
+ outputError(json, "Block name required with --yes. Usage: blockend add <block> --yes");
432
+ return;
433
+ }
434
+ if (multi && !blockName) {
435
+ const available = Object.entries(blockMap).filter(
436
+ ([, block]) => block != null && blockSupportsEnv(block, envKey)
437
+ );
438
+ if (available.length === 0) {
439
+ outputError(json, `No blocks available for environment: ${envKey}`);
440
+ if (!json) outro(pc.dim("Exiting."));
441
+ return;
442
+ }
443
+ const maxKeyLen = Math.max(...available.map(([k]) => k.length), 0);
444
+ const multiSelectPrompt = await multiselect({
445
+ message: "Select blocks to add (space to select, enter to confirm):",
446
+ options: available.map(([key, block], i) => ({
447
+ value: key,
448
+ label: `${pc.dim(`${i + 1}.`)} ${pc.bold(pc.cyan(key.padEnd(maxKeyLen + 4)))}${pc.dim(
449
+ block.description.length > 60 ? `${block.description.slice(0, 60)}...` : block.description
450
+ )}`
451
+ }))
452
+ });
453
+ handleCancel(multiSelectPrompt);
454
+ const selectedBlocks = multiSelectPrompt;
455
+ if (selectedBlocks.length === 0) {
456
+ outputError(json, "No blocks selected.");
457
+ return;
458
+ }
459
+ if (!json) log.info(`
460
+ Adding ${selectedBlocks.length} block(s)...
461
+ `);
462
+ const results = [];
463
+ for (const block of selectedBlocks) {
464
+ const result2 = await addSingleBlock(
465
+ block,
466
+ blockMap,
467
+ config,
468
+ rootDir,
469
+ physicalBlocksPath,
470
+ blocksRootAbsolute,
471
+ aliasBase,
472
+ envKey,
473
+ rewriteStrategy,
474
+ options
475
+ );
476
+ if (result2.success) {
477
+ results.push(result2.message);
478
+ }
479
+ }
480
+ if (json) {
481
+ process.stdout.write(JSON.stringify({ success: true, blocks: results }) + "\n");
482
+ } else {
483
+ outro(pc.green(`
484
+ \u2728 Added ${results.length} block(s) successfully!`));
485
+ }
486
+ return;
344
487
  }
488
+ let targetBlock = blockName;
489
+ if (!targetBlock) {
490
+ const available = Object.entries(blockMap).filter(
491
+ ([, block]) => block != null && blockSupportsEnv(block, envKey)
492
+ );
493
+ if (available.length === 0) {
494
+ outputError(json, `No blocks available for environment: ${envKey}`);
495
+ if (!json) outro(pc.dim("Exiting."));
496
+ return;
497
+ }
498
+ const maxKeyLen = Math.max(...available.map(([k]) => k.length), 0);
499
+ const selectPrompt = await select({
500
+ message: "Which block would you like to add?",
501
+ options: available.map(([key, block], i) => ({
502
+ value: key,
503
+ label: `${pc.dim(`${i + 1}.`)} ${pc.bold(pc.cyan(key.padEnd(maxKeyLen + 4)))}${pc.dim(
504
+ block.description.length > 60 ? `${block.description.slice(0, 60)}...` : block.description
505
+ )}`
506
+ }))
507
+ });
508
+ handleCancel(selectPrompt);
509
+ targetBlock = selectPrompt;
510
+ }
511
+ const result = await addSingleBlock(
512
+ targetBlock,
513
+ blockMap,
514
+ config,
515
+ rootDir,
516
+ physicalBlocksPath,
517
+ blocksRootAbsolute,
518
+ aliasBase,
519
+ envKey,
520
+ rewriteStrategy,
521
+ options
522
+ );
523
+ outputResult(json, {
524
+ success: result.success,
525
+ block: targetBlock,
526
+ filesWritten: result.filesWritten,
527
+ message: result.message
528
+ });
345
529
  }
346
530
 
347
531
  // src/commands/init.ts
@@ -355,12 +539,12 @@ import pc3 from "picocolors";
355
539
  import { join as join6 } from "path";
356
540
 
357
541
  // src/detectors/readers/package.ts
358
- import { readFile as readFile2 } from "fs/promises";
542
+ import { readFile } from "fs/promises";
359
543
  import { join as join2 } from "path";
360
544
  async function readPackageJson(cwd) {
361
545
  const pkgPath = join2(cwd, "package.json");
362
546
  try {
363
- const content = await readFile2(pkgPath, "utf-8");
547
+ const content = await readFile(pkgPath, "utf-8");
364
548
  return JSON.parse(content);
365
549
  } catch {
366
550
  throw new Error(`No package.json found at ${pkgPath}. Run blockend from your project root.`);
@@ -368,14 +552,14 @@ async function readPackageJson(cwd) {
368
552
  }
369
553
 
370
554
  // src/detectors/readers/tsconfig.ts
371
- import { readFile as readFile3 } from "fs/promises";
555
+ import { readFile as readFile2 } from "fs/promises";
372
556
  import { existsSync } from "fs";
373
557
  import { join as join3 } from "path";
374
558
  async function readTsConfig(cwd) {
375
559
  const tsconfigPath = join3(cwd, "tsconfig.json");
376
560
  if (!existsSync(tsconfigPath)) return null;
377
561
  try {
378
- const content = await readFile3(tsconfigPath, "utf-8");
562
+ const content = await readFile2(tsconfigPath, "utf-8");
379
563
  const cleanLines = content.split(/\r?\n/).filter((line) => {
380
564
  const trimmed = line.trim();
381
565
  return !trimmed.startsWith("//") && !trimmed.startsWith("/*");
@@ -958,7 +1142,7 @@ async function mcpStartCommand() {
958
1142
  );
959
1143
  server.tool(
960
1144
  "detect_project",
961
- "Analyzes a project context.",
1145
+ "Analyzes a project context: framework, language, package manager, etc.",
962
1146
  {
963
1147
  projectPath: z.string().describe("Absolute path to the project root")
964
1148
  },
@@ -969,25 +1153,97 @@ async function mcpStartCommand() {
969
1153
  return { content: [{ type: "text", text: result.text }], isError: result.isError };
970
1154
  }
971
1155
  );
972
- const transport = new StdioServerTransport();
973
- await server.connect(transport);
974
- }
975
- async function mcpInitCommand(options) {
976
- const cwd = process.cwd();
977
- let chosenClient = options.client;
978
- if (!chosenClient) {
979
- if (options.yes) {
980
- chosenClient = "claude";
981
- } else {
982
- const selection = await select3({
983
- message: "Select target AI client layout profile:",
984
- options: Object.entries(CLIENT_MATRIX).map(([key, value]) => ({
985
- value: key,
986
- label: value.label
987
- }))
1156
+ server.tool(
1157
+ "diff_block",
1158
+ "Preview what files a block would generate without writing to disk. Shows new/modified/unchanged files.",
1159
+ {
1160
+ blockName: z.string().describe("Block name to preview, e.g. rate-limiter, pino-logger"),
1161
+ projectPath: z.string().describe("Absolute path to the project root")
1162
+ },
1163
+ async ({ blockName, projectPath }) => {
1164
+ const result = await safeExec(
1165
+ "npx",
1166
+ ["--no-install", "blockend", "diff", blockName, "--json"],
1167
+ { cwd: projectPath }
1168
+ );
1169
+ return { content: [{ type: "text", text: result.text }], isError: result.isError };
1170
+ }
1171
+ );
1172
+ server.tool(
1173
+ "doctor",
1174
+ "Check project health: validates blockend.json, detects framework, checks for issues.",
1175
+ {
1176
+ projectPath: z.string().describe("Absolute path to the project root")
1177
+ },
1178
+ async ({ projectPath }) => {
1179
+ const result = await safeExec("npx", ["--no-install", "blockend", "doctor", "--json"], {
1180
+ cwd: projectPath
988
1181
  });
989
- if (typeof selection === "symbol") return;
990
- chosenClient = selection;
1182
+ return { content: [{ type: "text", text: result.text }], isError: result.isError };
1183
+ }
1184
+ );
1185
+ server.tool(
1186
+ "update_check",
1187
+ "Check for available updates to installed blocks. Shows version comparison and diffs.",
1188
+ {
1189
+ projectPath: z.string().describe("Absolute path to the project root")
1190
+ },
1191
+ async ({ projectPath }) => {
1192
+ const result = await safeExec("npx", ["--no-install", "blockend", "update", "--json"], {
1193
+ cwd: projectPath
1194
+ });
1195
+ return { content: [{ type: "text", text: result.text }], isError: result.isError };
1196
+ }
1197
+ );
1198
+ server.tool(
1199
+ "update_apply",
1200
+ "Apply updates to all installed blocks. Downloads new versions, rewrites imports, and updates blockend.json.",
1201
+ {
1202
+ projectPath: z.string().describe("Absolute path to the project root")
1203
+ },
1204
+ async ({ projectPath }) => {
1205
+ const result = await safeExec(
1206
+ "npx",
1207
+ ["--no-install", "blockend", "update", "--apply", "--yes", "--json"],
1208
+ { cwd: projectPath }
1209
+ );
1210
+ return { content: [{ type: "text", text: result.text }], isError: result.isError };
1211
+ }
1212
+ );
1213
+ server.tool(
1214
+ "add_blocks",
1215
+ "Install multiple blocks at once via interactive selection.",
1216
+ {
1217
+ projectPath: z.string().describe("Absolute path to the project root")
1218
+ },
1219
+ async ({ projectPath }) => {
1220
+ const result = await safeExec(
1221
+ "npx",
1222
+ ["--no-install", "blockend", "add", "--multi", "--yes", "--json"],
1223
+ { cwd: projectPath }
1224
+ );
1225
+ return { content: [{ type: "text", text: result.text }], isError: result.isError };
1226
+ }
1227
+ );
1228
+ const transport = new StdioServerTransport();
1229
+ await server.connect(transport);
1230
+ }
1231
+ async function mcpInitCommand(options) {
1232
+ const cwd = process.cwd();
1233
+ let chosenClient = options.client;
1234
+ if (!chosenClient) {
1235
+ if (options.yes) {
1236
+ chosenClient = "claude";
1237
+ } else {
1238
+ const selection = await select3({
1239
+ message: "Select target AI client layout profile:",
1240
+ options: Object.entries(CLIENT_MATRIX).map(([key, value]) => ({
1241
+ value: key,
1242
+ label: value.label
1243
+ }))
1244
+ });
1245
+ if (typeof selection === "symbol") return;
1246
+ chosenClient = selection;
991
1247
  }
992
1248
  }
993
1249
  const meta = CLIENT_MATRIX[chosenClient];
@@ -1060,6 +1316,840 @@ async function mcpInitCommand(options) {
1060
1316
  }
1061
1317
  }
1062
1318
 
1319
+ // src/commands/diff.ts
1320
+ import path4, { join as join10, dirname as dirname3 } from "path";
1321
+ import fs5 from "fs/promises";
1322
+ import { intro as intro3, outro as outro6, spinner as spinner5, log as log3 } from "@clack/prompts";
1323
+ import pc7 from "picocolors";
1324
+ var REPO_OWNER3 = "codewithnuh";
1325
+ var REPO_NAME3 = "blockend";
1326
+ var BRANCH3 = "master";
1327
+ var RAW_CDN_BASE2 = `https://raw.githubusercontent.com/${REPO_OWNER3}/${REPO_NAME3}/${BRANCH3}`;
1328
+ var MANIFEST_URL3 = `${RAW_CDN_BASE2}/registry/index.json`;
1329
+ function blockSupportsEnv2(block, envKey) {
1330
+ if (block.frameworks && block.frameworks.length > 0) {
1331
+ return block.frameworks.includes("*") || block.frameworks.includes(envKey);
1332
+ }
1333
+ return block.adapters?.[envKey] !== void 0 || block.environments?.[envKey] !== void 0;
1334
+ }
1335
+ function resolveAdapterContext2(block, envKey) {
1336
+ return block.adapters?.[envKey] ?? block.adapters?.["*"] ?? block.environments?.[envKey] ?? block.environments?.["*"] ?? null;
1337
+ }
1338
+ async function findUp3(filename, startDir) {
1339
+ let dir = startDir;
1340
+ while (true) {
1341
+ const candidate = join10(dir, filename);
1342
+ try {
1343
+ await fs5.access(candidate);
1344
+ return candidate;
1345
+ } catch {
1346
+ const parent = dirname3(dir);
1347
+ if (parent === dir) break;
1348
+ dir = parent;
1349
+ }
1350
+ }
1351
+ return null;
1352
+ }
1353
+ async function fileExists(filePath) {
1354
+ try {
1355
+ await fs5.access(filePath);
1356
+ return true;
1357
+ } catch {
1358
+ return false;
1359
+ }
1360
+ }
1361
+ async function diffCommand(blockName, options = {}) {
1362
+ const { json = false } = options;
1363
+ if (!json) {
1364
+ console.log("");
1365
+ intro3(`${pc7.bgCyan(pc7.black(" blockend "))} ${pc7.dim("diff")}`);
1366
+ }
1367
+ if (!blockName) {
1368
+ return { block: "", files: [], error: "Block name is required. Usage: blockend diff <block>" };
1369
+ }
1370
+ const cwd = process.cwd();
1371
+ const configPath = await findUp3("blockend.json", cwd);
1372
+ if (!configPath) {
1373
+ const error = "blockend.json not found. Run 'npx blockend init' first.";
1374
+ if (json) {
1375
+ process.stdout.write(JSON.stringify({ block: blockName, files: [], error }) + "\n");
1376
+ } else {
1377
+ log3.error(error);
1378
+ }
1379
+ return { block: blockName, files: [], error };
1380
+ }
1381
+ const rootDir = dirname3(configPath);
1382
+ let config;
1383
+ try {
1384
+ config = JSON.parse(await fs5.readFile(configPath, "utf-8"));
1385
+ } catch {
1386
+ const error = "Failed to parse blockend.json.";
1387
+ if (json) {
1388
+ process.stdout.write(JSON.stringify({ block: blockName, files: [], error }) + "\n");
1389
+ } else {
1390
+ log3.error(error);
1391
+ }
1392
+ return { block: blockName, files: [], error };
1393
+ }
1394
+ const physicalBlocksPath = config.paths.blocks;
1395
+ const blocksRootAbsolute = path4.resolve(rootDir, physicalBlocksPath);
1396
+ const envKey = config.environment;
1397
+ const s = spinner5();
1398
+ if (!json) s.start("Fetching registry...");
1399
+ let registry;
1400
+ try {
1401
+ const res = await fetch(MANIFEST_URL3);
1402
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1403
+ registry = await res.json();
1404
+ if (!json) s.stop("Registry synced.");
1405
+ } catch (err) {
1406
+ const error = `Failed to fetch registry: ${String(err)}`;
1407
+ if (!json) {
1408
+ s.stop("Failed.");
1409
+ log3.error(error);
1410
+ }
1411
+ return { block: blockName, files: [], error };
1412
+ }
1413
+ const blockMap = registry.blocks && typeof registry.blocks === "object" && !Array.isArray(registry.blocks) ? registry.blocks : registry;
1414
+ const blockMeta = blockMap[blockName];
1415
+ if (!blockMeta) {
1416
+ const error = `Block "${blockName}" not found in the registry.`;
1417
+ if (json) {
1418
+ process.stdout.write(JSON.stringify({ block: blockName, files: [], error }) + "\n");
1419
+ } else {
1420
+ log3.error(error);
1421
+ outro6(pc7.dim("Block not found."));
1422
+ }
1423
+ return { block: blockName, files: [], error };
1424
+ }
1425
+ if (!blockSupportsEnv2(blockMeta, envKey)) {
1426
+ const error = `Block "${blockName}" does not support environment: ${envKey}`;
1427
+ if (json) {
1428
+ process.stdout.write(JSON.stringify({ block: blockName, files: [], error }) + "\n");
1429
+ } else {
1430
+ log3.error(error);
1431
+ }
1432
+ return { block: blockName, files: [], error };
1433
+ }
1434
+ const adapterContext = resolveAdapterContext2(blockMeta, envKey);
1435
+ if (!adapterContext) {
1436
+ const error = `Block "${blockName}" has no adapter for: ${envKey}`;
1437
+ if (json) {
1438
+ process.stdout.write(JSON.stringify({ block: blockName, files: [], error }) + "\n");
1439
+ } else {
1440
+ log3.error(error);
1441
+ }
1442
+ return { block: blockName, files: [], error };
1443
+ }
1444
+ const variantKeys = Object.keys(adapterContext.variants ?? {});
1445
+ if (variantKeys.length === 0) {
1446
+ const error = `No variants found for "${blockName}" / "${envKey}".`;
1447
+ if (json) {
1448
+ process.stdout.write(JSON.stringify({ block: blockName, files: [], error }) + "\n");
1449
+ } else {
1450
+ log3.error(error);
1451
+ }
1452
+ return { block: blockName, files: [], error };
1453
+ }
1454
+ const selectedVariant = variantKeys[0];
1455
+ const variantMeta = adapterContext.variants[selectedVariant];
1456
+ const targetFolder = path4.resolve(blocksRootAbsolute, blockName);
1457
+ const filesToDownload = [];
1458
+ if (blockMeta.baseFiles?.length) {
1459
+ filesToDownload.push(...blockMeta.baseFiles);
1460
+ }
1461
+ if (adapterContext.core) {
1462
+ filesToDownload.push({
1463
+ source: adapterContext.core,
1464
+ target: path4.basename(adapterContext.core)
1465
+ });
1466
+ }
1467
+ if (variantMeta && Array.isArray(variantMeta.files)) {
1468
+ for (const entry of variantMeta.files) {
1469
+ filesToDownload.push(
1470
+ typeof entry === "string" ? { source: entry, target: path4.basename(entry) } : entry
1471
+ );
1472
+ }
1473
+ }
1474
+ if (!json) s.start(`Comparing ${blockName} files...`);
1475
+ const diffFiles = [];
1476
+ try {
1477
+ for (const fm of filesToDownload) {
1478
+ const res = await fetch(`${RAW_CDN_BASE2}/${fm.source}`);
1479
+ if (!res.ok) throw new Error(`Download failed: ${fm.source} (HTTP ${res.status})`);
1480
+ const newContent = await res.text();
1481
+ const targetPath = path4.join(targetFolder, fm.target);
1482
+ let existingContent = null;
1483
+ let status = "new";
1484
+ if (await fileExists(targetPath)) {
1485
+ try {
1486
+ existingContent = await fs5.readFile(targetPath, "utf-8");
1487
+ status = existingContent === newContent ? "unchanged" : "modified";
1488
+ } catch {
1489
+ }
1490
+ }
1491
+ diffFiles.push({
1492
+ name: fm.target,
1493
+ sourcePath: fm.source,
1494
+ targetPath,
1495
+ existingContent,
1496
+ newContent,
1497
+ status
1498
+ });
1499
+ }
1500
+ if (!json) s.stop("Comparison complete.");
1501
+ if (!json) {
1502
+ console.log(`
1503
+ ${pc7.bold(pc7.cyan(blockName))} \u2014 ${pc7.dim(selectedVariant)} variant
1504
+ `);
1505
+ for (const file of diffFiles) {
1506
+ const icon = file.status === "new" ? pc7.green("+") : file.status === "modified" ? pc7.yellow("~") : pc7.dim("=");
1507
+ const label = file.status === "new" ? pc7.green("new") : file.status === "modified" ? pc7.yellow("modified") : pc7.dim("unchanged");
1508
+ console.log(` ${icon} ${pc7.bold(file.name)} ${pc7.dim(`(${label})`)}`);
1509
+ if (file.status === "modified" && file.existingContent !== null) {
1510
+ const oldLines = file.existingContent.split("\n");
1511
+ const newLines = file.newContent.split("\n");
1512
+ const maxLines = Math.max(oldLines.length, newLines.length);
1513
+ for (let i = 0; i < Math.min(maxLines, 20); i++) {
1514
+ const oldLine = oldLines[i];
1515
+ const newLine = newLines[i];
1516
+ if (oldLine !== newLine) {
1517
+ if (oldLine !== void 0) {
1518
+ console.log(` ${pc7.red(`- ${oldLine}`)}`);
1519
+ }
1520
+ if (newLine !== void 0) {
1521
+ console.log(` ${pc7.green(`+ ${newLine}`)}`);
1522
+ }
1523
+ }
1524
+ }
1525
+ if (maxLines > 20) {
1526
+ console.log(` ${pc7.dim(`... ${maxLines - 20} more lines`)}`);
1527
+ }
1528
+ }
1529
+ }
1530
+ console.log("");
1531
+ const newCount = diffFiles.filter((f) => f.status === "new").length;
1532
+ const modCount = diffFiles.filter((f) => f.status === "modified").length;
1533
+ const unchangedCount = diffFiles.filter((f) => f.status === "unchanged").length;
1534
+ outro6(
1535
+ `${pc7.green(`${newCount} new`)} | ${pc7.yellow(`${modCount} modified`)} | ${pc7.dim(`${unchangedCount} unchanged`)}`
1536
+ );
1537
+ }
1538
+ const result = { block: blockName, files: diffFiles };
1539
+ if (json) {
1540
+ process.stdout.write(JSON.stringify(result) + "\n");
1541
+ }
1542
+ return result;
1543
+ } catch (err) {
1544
+ const error = `Failed during diff: ${String(err)}`;
1545
+ if (!json) {
1546
+ s.stop("Failed.");
1547
+ log3.error(error);
1548
+ }
1549
+ const result = { block: blockName, files: diffFiles, error };
1550
+ if (json) {
1551
+ process.stdout.write(JSON.stringify(result) + "\n");
1552
+ }
1553
+ return result;
1554
+ }
1555
+ }
1556
+
1557
+ // src/commands/doctor.ts
1558
+ import path5, { join as join11, dirname as dirname4 } from "path";
1559
+ import fs6 from "fs/promises";
1560
+ import { existsSync as existsSync5 } from "fs";
1561
+ import { intro as intro4, outro as outro7, spinner as spinner6 } from "@clack/prompts";
1562
+ import pc8 from "picocolors";
1563
+ function outputDoctorResult(json, result) {
1564
+ if (json) {
1565
+ process.stdout.write(
1566
+ JSON.stringify({
1567
+ success: result.errors.length === 0,
1568
+ checks: result.checks,
1569
+ warnings: result.warnings,
1570
+ errors: result.errors
1571
+ }) + "\n"
1572
+ );
1573
+ } else {
1574
+ console.log("");
1575
+ for (const check of result.checks) {
1576
+ const icon = check.status === "ok" ? pc8.green("\u2714") : check.status === "warning" ? pc8.yellow("\u26A0") : pc8.red("\u2716");
1577
+ const msg = check.status === "ok" ? pc8.dim(check.message) : check.status === "warning" ? pc8.yellow(check.message) : pc8.red(check.message);
1578
+ console.log(` ${icon} ${pc8.bold(check.name)}: ${msg}`);
1579
+ }
1580
+ console.log("");
1581
+ if (result.errors.length > 0) {
1582
+ outro7(pc8.red(`Found ${result.errors.length} error(s). Please fix the issues above.`));
1583
+ } else if (result.warnings.length > 0) {
1584
+ outro7(pc8.yellow(`Found ${result.warnings.length} warning(s). Everything looks functional.`));
1585
+ } else {
1586
+ outro7(pc8.green("All checks passed! Your project is healthy."));
1587
+ }
1588
+ }
1589
+ }
1590
+ async function findUp4(filename, startDir) {
1591
+ let dir = startDir;
1592
+ while (true) {
1593
+ const candidate = join11(dir, filename);
1594
+ try {
1595
+ await fs6.access(candidate);
1596
+ return candidate;
1597
+ } catch {
1598
+ const parent = dirname4(dir);
1599
+ if (parent === dir) break;
1600
+ dir = parent;
1601
+ }
1602
+ }
1603
+ return null;
1604
+ }
1605
+ async function doctorCommand(options = {}) {
1606
+ const { json = false } = options;
1607
+ if (!json) {
1608
+ console.log("");
1609
+ intro4(`${pc8.bgCyan(pc8.black(" blockend "))} ${pc8.dim("doctor")}`);
1610
+ }
1611
+ const cwd = process.cwd();
1612
+ const checks = [];
1613
+ const warnings = [];
1614
+ const errors = [];
1615
+ function addCheck(check) {
1616
+ checks.push(check);
1617
+ if (check.status === "warning") warnings.push(check);
1618
+ if (check.status === "error") errors.push(check);
1619
+ }
1620
+ const s = spinner6();
1621
+ if (!json) s.start("Scanning project health...");
1622
+ const packageJsonPath = await findUp4("package.json", cwd);
1623
+ if (packageJsonPath) {
1624
+ addCheck({ name: "package.json", status: "ok", message: "Found" });
1625
+ } else {
1626
+ addCheck({
1627
+ name: "package.json",
1628
+ status: "error",
1629
+ message: "Not found. Run 'npm init' or 'pnpm init' first."
1630
+ });
1631
+ }
1632
+ const configPath = await findUp4("blockend.json", cwd);
1633
+ if (configPath) {
1634
+ addCheck({ name: "blockend.json", status: "ok", message: "Found" });
1635
+ } else {
1636
+ addCheck({
1637
+ name: "blockend.json",
1638
+ status: "error",
1639
+ message: "Not found. Run 'npx blockend init' first."
1640
+ });
1641
+ }
1642
+ const tsConfigPath = await findUp4("tsconfig.json", cwd);
1643
+ if (tsConfigPath) {
1644
+ addCheck({ name: "tsconfig.json", status: "ok", message: "Found" });
1645
+ } else {
1646
+ addCheck({
1647
+ name: "tsconfig.json",
1648
+ status: "warning",
1649
+ message: "Not found. TypeScript projects typically need one."
1650
+ });
1651
+ }
1652
+ const hasPnpmLock = existsSync5(join11(cwd, "pnpm-lock.yaml"));
1653
+ const hasYarnLock = existsSync5(join11(cwd, "yarn.lock"));
1654
+ const hasBunLock = existsSync5(join11(cwd, "bun.lockb"));
1655
+ const hasNpmLock = existsSync5(join11(cwd, "package-lock.json"));
1656
+ if (hasPnpmLock || hasYarnLock || hasBunLock || hasNpmLock) {
1657
+ const pmName = hasPnpmLock ? "pnpm" : hasYarnLock ? "yarn" : hasBunLock ? "bun" : "npm";
1658
+ addCheck({ name: "Lockfile", status: "ok", message: `${pmName} lockfile found` });
1659
+ } else {
1660
+ addCheck({
1661
+ name: "Lockfile",
1662
+ status: "warning",
1663
+ message: "No lockfile detected. Run your package manager to generate one."
1664
+ });
1665
+ }
1666
+ try {
1667
+ const context = await detectProject(cwd);
1668
+ if (context.framework !== "none") {
1669
+ addCheck({
1670
+ name: "Framework",
1671
+ status: "ok",
1672
+ message: `Detected: ${context.framework}`
1673
+ });
1674
+ } else {
1675
+ addCheck({
1676
+ name: "Framework",
1677
+ status: "warning",
1678
+ message: "No framework detected. Blockend works best with Express, Fastify, Hono, or Next.js."
1679
+ });
1680
+ }
1681
+ addCheck({
1682
+ name: "Package manager",
1683
+ status: "ok",
1684
+ message: `Detected: ${context.packageManager}`
1685
+ });
1686
+ if (context.hasRedis) {
1687
+ addCheck({ name: "Redis", status: "ok", message: "Redis client detected" });
1688
+ }
1689
+ if (context.hasPrisma) {
1690
+ addCheck({ name: "Prisma", status: "ok", message: "Prisma ORM detected" });
1691
+ }
1692
+ if (context.hasDrizzle) {
1693
+ addCheck({ name: "Drizzle", status: "ok", message: "Drizzle ORM detected" });
1694
+ }
1695
+ } catch (err) {
1696
+ addCheck({
1697
+ name: "Project detection",
1698
+ status: "error",
1699
+ message: `Failed to scan project: ${String(err)}`
1700
+ });
1701
+ }
1702
+ if (configPath) {
1703
+ try {
1704
+ const raw = await fs6.readFile(configPath, "utf-8");
1705
+ const config = JSON.parse(raw);
1706
+ if (!config.environment) {
1707
+ addCheck({
1708
+ name: "Config: environment",
1709
+ status: "error",
1710
+ message: "Missing 'environment' field in blockend.json"
1711
+ });
1712
+ } else {
1713
+ addCheck({
1714
+ name: "Config: environment",
1715
+ status: "ok",
1716
+ message: `Set to '${config.environment}'`
1717
+ });
1718
+ }
1719
+ if (!config.paths?.blocks) {
1720
+ addCheck({
1721
+ name: "Config: blocks path",
1722
+ status: "error",
1723
+ message: "Missing 'paths.blocks' in blockend.json"
1724
+ });
1725
+ } else {
1726
+ const blocksDir = path5.resolve(dirname4(configPath), config.paths.blocks);
1727
+ if (existsSync5(blocksDir)) {
1728
+ addCheck({
1729
+ name: "Config: blocks directory",
1730
+ status: "ok",
1731
+ message: `Exists: ${config.paths.blocks}`
1732
+ });
1733
+ } else {
1734
+ addCheck({
1735
+ name: "Config: blocks directory",
1736
+ status: "warning",
1737
+ message: `Directory '${config.paths.blocks}' does not exist yet. Blocks will create it on first add.`
1738
+ });
1739
+ }
1740
+ }
1741
+ } catch {
1742
+ addCheck({
1743
+ name: "Config: parse",
1744
+ status: "error",
1745
+ message: "Failed to parse blockend.json \u2014 file may be corrupted."
1746
+ });
1747
+ }
1748
+ }
1749
+ if (!json) s.stop("Scan complete.");
1750
+ const result = { checks, warnings, errors };
1751
+ outputDoctorResult(json, result);
1752
+ if (errors.length > 0 && !json) {
1753
+ process.exit(1);
1754
+ }
1755
+ return result;
1756
+ }
1757
+
1758
+ // src/commands/update.ts
1759
+ import path6, { join as join12, dirname as dirname5 } from "path";
1760
+ import fs7 from "fs/promises";
1761
+ import { intro as intro5, outro as outro8, spinner as spinner7, log as log4, multiselect as multiselect2, confirm as confirm4, isCancel as isCancel3 } from "@clack/prompts";
1762
+ import pc9 from "picocolors";
1763
+ var REPO_OWNER4 = "codewithnuh";
1764
+ var REPO_NAME4 = "blockend";
1765
+ var BRANCH4 = "master";
1766
+ var RAW_CDN_BASE3 = `https://raw.githubusercontent.com/${REPO_OWNER4}/${REPO_NAME4}/${BRANCH4}`;
1767
+ var MANIFEST_URL4 = `${RAW_CDN_BASE3}/registry/index.json`;
1768
+ function blockSupportsEnv3(block, envKey) {
1769
+ if (block.frameworks && block.frameworks.length > 0) {
1770
+ return block.frameworks.includes("*") || block.frameworks.includes(envKey);
1771
+ }
1772
+ return block.adapters?.[envKey] !== void 0 || block.environments?.[envKey] !== void 0;
1773
+ }
1774
+ function resolveAdapterContext3(block, envKey) {
1775
+ return block.adapters?.[envKey] ?? block.adapters?.["*"] ?? block.environments?.[envKey] ?? block.environments?.["*"] ?? null;
1776
+ }
1777
+ async function findUp5(filename, startDir) {
1778
+ let dir = startDir;
1779
+ while (true) {
1780
+ const candidate = join12(dir, filename);
1781
+ try {
1782
+ await fs7.access(candidate);
1783
+ return candidate;
1784
+ } catch {
1785
+ const parent = dirname5(dir);
1786
+ if (parent === dir) break;
1787
+ dir = parent;
1788
+ }
1789
+ }
1790
+ return null;
1791
+ }
1792
+ function computeLineDiff(oldContent, newContent) {
1793
+ const oldLines = oldContent.split("\n");
1794
+ const newLines = newContent.split("\n");
1795
+ const m = oldLines.length;
1796
+ const n = newLines.length;
1797
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
1798
+ for (let i2 = 1; i2 <= m; i2++) {
1799
+ for (let j2 = 1; j2 <= n; j2++) {
1800
+ if (oldLines[i2 - 1] === newLines[j2 - 1]) {
1801
+ dp[i2][j2] = dp[i2 - 1][j2 - 1] + 1;
1802
+ } else {
1803
+ dp[i2][j2] = Math.max(dp[i2 - 1][j2], dp[i2][j2 - 1]);
1804
+ }
1805
+ }
1806
+ }
1807
+ let i = m, j = n;
1808
+ const raw = [];
1809
+ while (i > 0 || j > 0) {
1810
+ if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
1811
+ raw.unshift({ type: "context", oldLine: i, newLine: j, content: oldLines[i - 1] });
1812
+ i--;
1813
+ j--;
1814
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
1815
+ raw.unshift({ type: "added", newLine: j, content: newLines[j - 1] });
1816
+ j--;
1817
+ } else {
1818
+ raw.unshift({ type: "removed", oldLine: i, content: oldLines[i - 1] });
1819
+ i--;
1820
+ }
1821
+ }
1822
+ const result = [];
1823
+ for (let k = 0; k < raw.length; k++) {
1824
+ const r = raw[k];
1825
+ const nearChange = raw.slice(Math.max(0, k - 3), k + 4).some((x) => x.type !== "context");
1826
+ if (r.type !== "context" || nearChange) {
1827
+ result.push({
1828
+ type: r.type,
1829
+ content: r.content,
1830
+ oldLineNo: r.oldLine,
1831
+ newLineNo: r.newLine
1832
+ });
1833
+ }
1834
+ }
1835
+ return result;
1836
+ }
1837
+ function handleCancel2(value) {
1838
+ if (isCancel3(value)) {
1839
+ outro8(pc9.dim("Operation cancelled."));
1840
+ process.exit(0);
1841
+ }
1842
+ }
1843
+ function printDiff(diff2) {
1844
+ for (const file of diff2) {
1845
+ if (file.status === "unchanged") continue;
1846
+ const icon = file.status === "added" ? pc9.green("+") : file.status === "removed" ? pc9.red("-") : pc9.yellow("~");
1847
+ const label = file.status === "added" ? pc9.green("new") : file.status === "removed" ? pc9.red("deleted") : pc9.yellow("modified");
1848
+ console.log(`
1849
+ ${icon} ${pc9.bold(file.name)} ${pc9.dim(`(${label})`)}`);
1850
+ if (file.lines.length === 0) continue;
1851
+ let lastShownLine = -1;
1852
+ for (let idx = 0; idx < file.lines.length; idx++) {
1853
+ const line = file.lines[idx];
1854
+ if (lastShownLine >= 0 && idx - lastShownLine > 1) {
1855
+ console.log(` ${pc9.dim("...")}`);
1856
+ }
1857
+ lastShownLine = idx;
1858
+ const lineNo = line.type === "removed" ? String(line.oldLineNo ?? "").padStart(4) : String(line.newLineNo ?? "").padStart(4);
1859
+ const prefix = line.type === "added" ? "+" : line.type === "removed" ? "-" : " ";
1860
+ const color = line.type === "added" ? pc9.green : line.type === "removed" ? pc9.red : pc9.dim;
1861
+ console.log(` ${pc9.dim(lineNo)} ${color(`${prefix} ${line.content}`)}`);
1862
+ }
1863
+ }
1864
+ }
1865
+ function outputUpdateResult(json, result) {
1866
+ if (json) {
1867
+ process.stdout.write(JSON.stringify(result) + "\n");
1868
+ return;
1869
+ }
1870
+ console.log("");
1871
+ if (result.blocks.length === 0) {
1872
+ console.log(pc9.dim(" No blocks tracked. Run 'npx blockend add <block>' to get started."));
1873
+ console.log("");
1874
+ return;
1875
+ }
1876
+ const upToDate = result.blocks.filter((b) => b.status === "up-to-date");
1877
+ const updates = result.blocks.filter((b) => b.status === "update-available");
1878
+ if (updates.length > 0) {
1879
+ console.log(` ${pc9.bold(pc9.yellow("Updates available:"))}
1880
+ `);
1881
+ for (const block of updates) {
1882
+ console.log(
1883
+ ` ${pc9.yellow("~")} ${pc9.bold(block.name)} ${pc9.dim(`${block.localVersion} \u2192 ${block.remoteVersion}`)}`
1884
+ );
1885
+ }
1886
+ console.log("");
1887
+ }
1888
+ if (upToDate.length > 0) {
1889
+ console.log(` ${pc9.bold(pc9.green("Up to date:"))}`);
1890
+ for (const block of upToDate) {
1891
+ console.log(` ${pc9.green("\u2714")} ${block.name} ${pc9.dim(`v${block.localVersion}`)}`);
1892
+ }
1893
+ console.log("");
1894
+ }
1895
+ if (result.diffs && Object.keys(result.diffs).length > 0) {
1896
+ console.log(` ${pc9.bold(pc9.cyan("Change details:"))}`);
1897
+ for (const [blockName, diffs] of Object.entries(result.diffs)) {
1898
+ console.log(`
1899
+ ${pc9.bold(pc9.cyan(`\u25B8 ${blockName}`))}`);
1900
+ printDiff(diffs);
1901
+ }
1902
+ console.log("");
1903
+ }
1904
+ if (result.applied && result.applied.length > 0) {
1905
+ outro8(pc9.green(`\u2728 Updated ${result.applied.length} block(s) successfully.`));
1906
+ } else if (updates.length > 0) {
1907
+ outro8(`${pc9.yellow(`${updates.length} block(s) have updates.`)} Run with --apply to update.`);
1908
+ } else {
1909
+ outro8(pc9.green("All tracked blocks are up to date."));
1910
+ }
1911
+ }
1912
+ function resolveBlockFiles(blockMeta, envKey) {
1913
+ const adapterContext = resolveAdapterContext3(blockMeta, envKey);
1914
+ if (!adapterContext) return null;
1915
+ const variantKeys = Object.keys(adapterContext.variants ?? {});
1916
+ if (variantKeys.length === 0) return null;
1917
+ const selectedVariant = variantKeys[0];
1918
+ const variantMeta = adapterContext.variants[selectedVariant];
1919
+ const expectedFiles = [];
1920
+ if (blockMeta.baseFiles?.length) expectedFiles.push(...blockMeta.baseFiles);
1921
+ if (adapterContext.core) {
1922
+ expectedFiles.push({ source: adapterContext.core, target: path6.basename(adapterContext.core) });
1923
+ }
1924
+ if (variantMeta && Array.isArray(variantMeta.files)) {
1925
+ for (const entry of variantMeta.files) {
1926
+ expectedFiles.push(
1927
+ typeof entry === "string" ? { source: entry, target: path6.basename(entry) } : entry
1928
+ );
1929
+ }
1930
+ }
1931
+ return { files: expectedFiles, variant: selectedVariant };
1932
+ }
1933
+ async function updateCommand(options = {}) {
1934
+ const { json = false, diff: diff2 = true, apply = false } = options;
1935
+ if (!json) {
1936
+ console.log("");
1937
+ intro5(`${pc9.bgCyan(pc9.black(" blockend "))} ${pc9.dim("update")}`);
1938
+ }
1939
+ const cwd = process.cwd();
1940
+ const configPath = await findUp5("blockend.json", cwd);
1941
+ if (!configPath) {
1942
+ const error = "blockend.json not found. Run 'npx blockend init' first.";
1943
+ if (json) process.stdout.write(JSON.stringify({ blocks: [], error }) + "\n");
1944
+ else log4.error(error);
1945
+ return { blocks: [], error };
1946
+ }
1947
+ const rootDir = dirname5(configPath);
1948
+ let config;
1949
+ try {
1950
+ config = JSON.parse(await fs7.readFile(configPath, "utf-8"));
1951
+ } catch {
1952
+ const error = "Failed to parse blockend.json.";
1953
+ if (json) process.stdout.write(JSON.stringify({ blocks: [], error }) + "\n");
1954
+ else log4.error(error);
1955
+ return { blocks: [], error };
1956
+ }
1957
+ const physicalBlocksPath = config.paths.blocks;
1958
+ const blocksRootAbsolute = path6.resolve(rootDir, physicalBlocksPath);
1959
+ const envKey = config.environment;
1960
+ const aliasBase = config.aliases.blocks;
1961
+ const rewriteStrategy = config.importRewriteStrategy ?? "remove";
1962
+ const installedRecords = Array.isArray(config.installed) ? config.installed : [];
1963
+ const s = spinner7();
1964
+ if (!json) s.start("Fetching registry...");
1965
+ let registry;
1966
+ try {
1967
+ const res = await fetch(MANIFEST_URL4);
1968
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1969
+ registry = await res.json();
1970
+ if (!json) s.stop("Registry synced.");
1971
+ } catch (err) {
1972
+ const error = `Failed to fetch registry: ${String(err)}`;
1973
+ if (!json) {
1974
+ s.stop("Failed.");
1975
+ log4.error(error);
1976
+ }
1977
+ return { blocks: [], error };
1978
+ }
1979
+ const blockMap = registry.blocks && typeof registry.blocks === "object" && !Array.isArray(registry.blocks) ? registry.blocks : registry;
1980
+ if (!json) s.start("Comparing versions...");
1981
+ const blockResults = [];
1982
+ const diffs = {};
1983
+ for (const record of installedRecords) {
1984
+ const blockMeta = blockMap[record.name];
1985
+ const remoteVersion = blockMeta?.version ?? "0.0.0";
1986
+ if (!blockMeta || !blockSupportsEnv3(blockMeta, envKey)) {
1987
+ blockResults.push({
1988
+ name: record.name,
1989
+ localVersion: record.version,
1990
+ remoteVersion: "?",
1991
+ status: "not-tracked"
1992
+ });
1993
+ continue;
1994
+ }
1995
+ const hasUpdate = record.version !== remoteVersion || record.contentHash === "0";
1996
+ blockResults.push({
1997
+ name: record.name,
1998
+ localVersion: record.version,
1999
+ remoteVersion,
2000
+ status: hasUpdate ? "update-available" : "up-to-date"
2001
+ });
2002
+ if (hasUpdate && diff2) {
2003
+ const resolved = resolveBlockFiles(blockMeta, envKey);
2004
+ if (!resolved) continue;
2005
+ const targetFolder = path6.resolve(blocksRootAbsolute, record.name);
2006
+ const fileDiffs = [];
2007
+ for (const fm of resolved.files) {
2008
+ const localPath = join12(targetFolder, fm.target);
2009
+ let localContent = "";
2010
+ let localExists = false;
2011
+ try {
2012
+ localContent = await fs7.readFile(localPath, "utf-8");
2013
+ localExists = true;
2014
+ } catch {
2015
+ localContent = "";
2016
+ }
2017
+ let remoteContent = "";
2018
+ try {
2019
+ const res = await fetch(`${RAW_CDN_BASE3}/${fm.source}`);
2020
+ if (res.ok) remoteContent = await res.text();
2021
+ } catch {
2022
+ remoteContent = "";
2023
+ }
2024
+ if (!localExists) {
2025
+ fileDiffs.push({
2026
+ name: fm.target,
2027
+ file: fm.target,
2028
+ status: "added",
2029
+ lines: remoteContent.split("\n").map((l, i) => ({
2030
+ type: "added",
2031
+ content: l,
2032
+ newLineNo: i + 1
2033
+ }))
2034
+ });
2035
+ } else if (localContent !== remoteContent) {
2036
+ fileDiffs.push({
2037
+ name: fm.target,
2038
+ file: fm.target,
2039
+ status: "modified",
2040
+ lines: computeLineDiff(localContent, remoteContent)
2041
+ });
2042
+ } else {
2043
+ fileDiffs.push({
2044
+ name: fm.target,
2045
+ file: fm.target,
2046
+ status: "unchanged",
2047
+ lines: []
2048
+ });
2049
+ }
2050
+ }
2051
+ diffs[record.name] = fileDiffs;
2052
+ }
2053
+ }
2054
+ if (!json) s.stop("Comparison complete.");
2055
+ const updateableBlocks = blockResults.filter((b) => b.status === "update-available");
2056
+ if (apply && updateableBlocks.length > 0) {
2057
+ let selectedNames;
2058
+ if (json) {
2059
+ selectedNames = updateableBlocks.map((b) => b.name);
2060
+ } else {
2061
+ const multiPrompt = await multiselect2({
2062
+ message: "Select blocks to update (space to select, enter to confirm):",
2063
+ options: updateableBlocks.map((b) => ({
2064
+ value: b.name,
2065
+ label: `${pc9.bold(pc9.cyan(b.name))} ${pc9.dim(`${b.localVersion} \u2192 ${b.remoteVersion}`)}`
2066
+ }))
2067
+ });
2068
+ handleCancel2(multiPrompt);
2069
+ selectedNames = multiPrompt;
2070
+ }
2071
+ if (selectedNames.length === 0) {
2072
+ if (!json) outro8(pc9.dim("No blocks selected."));
2073
+ return { blocks: blockResults, diffs };
2074
+ }
2075
+ if (!json) {
2076
+ const proceed = await confirm4({
2077
+ message: `Update ${selectedNames.length} block(s)? This will overwrite local files.`,
2078
+ initialValue: true
2079
+ });
2080
+ handleCancel2(proceed);
2081
+ if (!proceed) {
2082
+ outro8(pc9.dim("Update cancelled."));
2083
+ return { blocks: blockResults, diffs };
2084
+ }
2085
+ }
2086
+ const applied = [];
2087
+ for (const blockName of selectedNames) {
2088
+ const blockMeta = blockMap[blockName];
2089
+ if (!blockMeta) continue;
2090
+ const resolved = resolveBlockFiles(blockMeta, envKey);
2091
+ if (!resolved) continue;
2092
+ const targetFolder = path6.resolve(blocksRootAbsolute, blockName);
2093
+ const downloadedTargets = /* @__PURE__ */ new Set();
2094
+ try {
2095
+ const fileBatches = [];
2096
+ for (const fm of resolved.files) {
2097
+ const res = await fetch(`${RAW_CDN_BASE3}/${fm.source}`);
2098
+ if (!res.ok) throw new Error(`Download failed: ${fm.source} (HTTP ${res.status})`);
2099
+ fileBatches.push({ fm, raw: await res.text() });
2100
+ downloadedTargets.add(fm.target);
2101
+ }
2102
+ let contentHash = 0;
2103
+ for (const { fm, raw } of fileBatches) {
2104
+ const writtenFilePath = path6.join(targetFolder, fm.target);
2105
+ const content = rewriteFileImports(
2106
+ raw,
2107
+ writtenFilePath,
2108
+ blocksRootAbsolute,
2109
+ aliasBase,
2110
+ rewriteStrategy,
2111
+ downloadedTargets
2112
+ );
2113
+ await fs7.mkdir(dirname5(writtenFilePath), { recursive: true });
2114
+ await fs7.writeFile(writtenFilePath, content, "utf-8");
2115
+ for (let i = 0; i < content.length; i++) {
2116
+ contentHash = (contentHash << 5) - contentHash + content.charCodeAt(i) | 0;
2117
+ }
2118
+ }
2119
+ const blockVersion = blockMeta.version ?? "0.0.0";
2120
+ const installedRecord = {
2121
+ name: blockName,
2122
+ version: blockVersion,
2123
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
2124
+ files: resolved.files.map((f) => f.target),
2125
+ contentHash: contentHash.toString(16)
2126
+ };
2127
+ try {
2128
+ const cfgRaw = await fs7.readFile(configPath, "utf-8");
2129
+ const cfgData = JSON.parse(cfgRaw);
2130
+ if (!Array.isArray(cfgData.installed)) cfgData.installed = [];
2131
+ cfgData.installed = cfgData.installed.filter(
2132
+ (b) => b.name !== blockName
2133
+ );
2134
+ cfgData.installed.push(installedRecord);
2135
+ await fs7.writeFile(configPath, JSON.stringify(cfgData, null, 2), "utf-8");
2136
+ } catch {
2137
+ }
2138
+ applied.push(blockName);
2139
+ if (!json) log4.info(` ${pc9.green("\u2714")} ${blockName} updated to v${blockVersion}`);
2140
+ } catch (err) {
2141
+ if (!json) log4.error(` ${pc9.red("\u2716")} ${blockName}: ${String(err)}`);
2142
+ }
2143
+ }
2144
+ const result2 = { blocks: blockResults, applied, diffs };
2145
+ outputUpdateResult(json, result2);
2146
+ return result2;
2147
+ }
2148
+ const result = { blocks: blockResults, diffs };
2149
+ outputUpdateResult(json, result);
2150
+ return result;
2151
+ }
2152
+
1063
2153
  // src/index.ts
1064
2154
  var mcpInit = defineCommand({
1065
2155
  meta: {
@@ -1165,10 +2255,16 @@ var add = defineCommand({
1165
2255
  type: "boolean",
1166
2256
  default: false,
1167
2257
  description: "Output streaming machine-readable JSON payloads for automation systems"
2258
+ },
2259
+ multi: {
2260
+ type: "boolean",
2261
+ alias: "m",
2262
+ default: false,
2263
+ description: "Select and add multiple blocks at once via interactive multiselect"
1168
2264
  }
1169
2265
  },
1170
2266
  async run({ args }) {
1171
- await addCommand(args.block, { yes: args.yes, json: args.json });
2267
+ await addCommand(args.block, { yes: args.yes, json: args.json, multi: args.multi });
1172
2268
  }
1173
2269
  });
1174
2270
  var detect = defineCommand({
@@ -1187,12 +2283,75 @@ var detect = defineCommand({
1187
2283
  await detectCommand({ json: args.json });
1188
2284
  }
1189
2285
  });
2286
+ var diff = defineCommand({
2287
+ meta: {
2288
+ name: "diff",
2289
+ description: "Preview generated files for a block without writing to disk"
2290
+ },
2291
+ args: {
2292
+ block: {
2293
+ type: "positional",
2294
+ required: true,
2295
+ description: "Block name to preview"
2296
+ },
2297
+ json: {
2298
+ type: "boolean",
2299
+ default: false,
2300
+ description: "Output diff results as JSON"
2301
+ }
2302
+ },
2303
+ async run({ args }) {
2304
+ await diffCommand(args.block, { json: args.json });
2305
+ }
2306
+ });
2307
+ var doctor = defineCommand({
2308
+ meta: {
2309
+ name: "doctor",
2310
+ description: "Detect configuration and project issues affecting Blockend"
2311
+ },
2312
+ args: {
2313
+ json: {
2314
+ type: "boolean",
2315
+ default: false,
2316
+ description: "Output health check results as JSON"
2317
+ }
2318
+ },
2319
+ async run({ args }) {
2320
+ await doctorCommand({ json: args.json });
2321
+ }
2322
+ });
2323
+ var update = defineCommand({
2324
+ meta: {
2325
+ name: "update",
2326
+ description: "Compare installed blocks with newer available versions and show diffs"
2327
+ },
2328
+ args: {
2329
+ json: {
2330
+ type: "boolean",
2331
+ default: false,
2332
+ description: "Output update status as JSON"
2333
+ },
2334
+ diff: {
2335
+ type: "boolean",
2336
+ default: true,
2337
+ description: "Show git-diff style file comparison for blocks with updates"
2338
+ },
2339
+ apply: {
2340
+ type: "boolean",
2341
+ default: false,
2342
+ description: "Select blocks to update (multiselect) and apply updates with import rewriting"
2343
+ }
2344
+ },
2345
+ async run({ args }) {
2346
+ await updateCommand({ json: args.json, diff: args.diff, apply: args.apply });
2347
+ }
2348
+ });
1190
2349
  var main = defineCommand({
1191
2350
  meta: {
1192
2351
  name: "blockend",
1193
2352
  version: "0.1.0",
1194
2353
  description: "Blockend CLI - Core architectural blocks straight into your repository layout primitives"
1195
2354
  },
1196
- subCommands: { init, add, detect, list, mcp }
2355
+ subCommands: { init, add, detect, list, diff, doctor, update, mcp }
1197
2356
  });
1198
2357
  runMain(main);