ccem 2.28.0 → 2.31.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.
Files changed (2) hide show
  1. package/dist/index.js +473 -87
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -711,7 +711,7 @@ async function parseJSONLFileAsync(filePath, prices, signal) {
711
711
  throw new Error("Aborted");
712
712
  }
713
713
  if (lineCount % 1e3 === 0) {
714
- await new Promise((resolve3) => setTimeout(resolve3, 0));
714
+ await new Promise((resolve4) => setTimeout(resolve4, 0));
715
715
  }
716
716
  }
717
717
  if (!line.trim()) continue;
@@ -800,7 +800,7 @@ async function getUsageStats(signal) {
800
800
  fileStats = cachedFile.stats;
801
801
  } else {
802
802
  fileStats = await parseJSONLFileAsync(file, prices, signal);
803
- await new Promise((resolve3) => setTimeout(resolve3, 0));
803
+ await new Promise((resolve4) => setTimeout(resolve4, 0));
804
804
  }
805
805
  return {
806
806
  file,
@@ -1312,7 +1312,7 @@ var renderFooterHints = (hints) => {
1312
1312
  return minimal;
1313
1313
  };
1314
1314
  var selectEnvWithKeys = (registries, current) => {
1315
- return new Promise((resolve3) => {
1315
+ return new Promise((resolve4) => {
1316
1316
  const envNames = Object.keys(registries);
1317
1317
  let selectedIndex = envNames.indexOf(current);
1318
1318
  if (selectedIndex === -1) selectedIndex = 0;
@@ -1374,7 +1374,7 @@ var selectEnvWithKeys = (registries, current) => {
1374
1374
  }
1375
1375
  if (char === "\x1B" && key.length === 1) {
1376
1376
  cleanup();
1377
- resolve3({ action: "cancel" });
1377
+ resolve4({ action: "cancel" });
1378
1378
  return;
1379
1379
  }
1380
1380
  if (char === "\x1B[A" || char === "k") {
@@ -1391,27 +1391,27 @@ var selectEnvWithKeys = (registries, current) => {
1391
1391
  }
1392
1392
  if (char === "\r" || char === "\n") {
1393
1393
  cleanup();
1394
- resolve3({ action: "select", name: envNames[selectedIndex] });
1394
+ resolve4({ action: "select", name: envNames[selectedIndex] });
1395
1395
  return;
1396
1396
  }
1397
1397
  if (char === "e" || char === "E") {
1398
1398
  cleanup();
1399
- resolve3({ action: "edit", name: envNames[selectedIndex] });
1399
+ resolve4({ action: "edit", name: envNames[selectedIndex] });
1400
1400
  return;
1401
1401
  }
1402
1402
  if (char === "r" || char === "R") {
1403
1403
  cleanup();
1404
- resolve3({ action: "rename", name: envNames[selectedIndex] });
1404
+ resolve4({ action: "rename", name: envNames[selectedIndex] });
1405
1405
  return;
1406
1406
  }
1407
1407
  if (char === "c" || char === "C") {
1408
1408
  cleanup();
1409
- resolve3({ action: "copy", name: envNames[selectedIndex] });
1409
+ resolve4({ action: "copy", name: envNames[selectedIndex] });
1410
1410
  return;
1411
1411
  }
1412
1412
  if (char === "d" || char === "D") {
1413
1413
  cleanup();
1414
- resolve3({ action: "delete", name: envNames[selectedIndex] });
1414
+ resolve4({ action: "delete", name: envNames[selectedIndex] });
1415
1415
  return;
1416
1416
  }
1417
1417
  };
@@ -1893,7 +1893,7 @@ async function launchClaude(options) {
1893
1893
  console.log(renderStarting());
1894
1894
  }
1895
1895
  const sessionsDir = ensureSessionsDir();
1896
- return new Promise((resolve3) => {
1896
+ return new Promise((resolve4) => {
1897
1897
  let provenanceTracking = null;
1898
1898
  const child = spawn("claude", args, {
1899
1899
  stdio: "inherit",
@@ -2151,7 +2151,7 @@ var setupEnvSettings = () => {
2151
2151
  }
2152
2152
  };
2153
2153
  var setupMcpTool = () => {
2154
- return new Promise((resolve3) => {
2154
+ return new Promise((resolve4) => {
2155
2155
  console.log(chalk4.cyan(" \u2192 \u6B63\u5728\u6DFB\u52A0 chrome-devtools MCP \u5DE5\u5177..."));
2156
2156
  const child = spawn2("claude", [
2157
2157
  "mcp",
@@ -2176,22 +2176,22 @@ var setupMcpTool = () => {
2176
2176
  child.on("exit", (code) => {
2177
2177
  if (code === 0) {
2178
2178
  console.log(chalk4.green(" \u2713 \u5DF2\u6DFB\u52A0 chrome-devtools MCP \u5DE5\u5177"));
2179
- resolve3(true);
2179
+ resolve4(true);
2180
2180
  } else {
2181
2181
  if (stderr.includes("already exists") || stdout.includes("already exists")) {
2182
2182
  console.log(chalk4.gray(" \u2713 chrome-devtools MCP \u5DE5\u5177\u5DF2\u5B58\u5728"));
2183
- resolve3(true);
2183
+ resolve4(true);
2184
2184
  } else {
2185
2185
  console.error(chalk4.red(` \u2717 \u6DFB\u52A0 MCP \u5DE5\u5177\u5931\u8D25 (code: ${code})`));
2186
2186
  if (stderr) console.error(chalk4.gray(` ${stderr.trim()}`));
2187
- resolve3(false);
2187
+ resolve4(false);
2188
2188
  }
2189
2189
  }
2190
2190
  });
2191
2191
  child.on("error", (err) => {
2192
2192
  console.error(chalk4.red(` \u2717 \u6267\u884C claude \u547D\u4EE4\u5931\u8D25: ${err.message}`));
2193
2193
  console.log(chalk4.yellow(" \u8BF7\u786E\u4FDD\u5DF2\u5B89\u88C5 Claude Code CLI"));
2194
- resolve3(false);
2194
+ resolve4(false);
2195
2195
  });
2196
2196
  });
2197
2197
  };
@@ -2221,7 +2221,7 @@ var runSetupInit = async (options = {}) => {
2221
2221
  };
2222
2222
 
2223
2223
  // src/skills.ts
2224
- import { execSync } from "child_process";
2224
+ import { execFileSync as execFileSync2 } from "child_process";
2225
2225
  import * as fs8 from "fs";
2226
2226
  import * as path6 from "path";
2227
2227
  import chalk5 from "chalk";
@@ -2230,6 +2230,58 @@ var SKILL_GROUPS = {
2230
2230
  featured: { label: "\u7CBE\u9009", icon: "\u2B50" },
2231
2231
  others: { label: "\u5176\u4ED6", icon: "\u{1F4E6}" }
2232
2232
  };
2233
+ var SAFE_GITHUB_PART_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
2234
+ var SAFE_PATH_PART_RE = /^[A-Za-z0-9._-]+$/;
2235
+ var SAFE_GIT_REF_RE = /^[A-Za-z0-9._/-]+$/;
2236
+ var SAFE_PLUGIN_PACKAGE_RE = /^[A-Za-z0-9._@-]+$/;
2237
+ function isSafeGitHubPart(value) {
2238
+ return SAFE_GITHUB_PART_RE.test(value) && value !== "." && value !== "..";
2239
+ }
2240
+ function isSafePathPart(value) {
2241
+ return SAFE_PATH_PART_RE.test(value) && value !== "." && value !== "..";
2242
+ }
2243
+ function isSafeRepoPath(value) {
2244
+ if (!value) return true;
2245
+ if (value.startsWith("/") || value.includes("\\")) return false;
2246
+ const parts = value.split("/");
2247
+ if (parts.some((part) => !part || !isSafePathPart(part))) return false;
2248
+ return true;
2249
+ }
2250
+ function isSafeGitRef(value) {
2251
+ if (!value || !SAFE_GIT_REF_RE.test(value)) return false;
2252
+ if (value.startsWith("/") || value.endsWith("/") || value.includes("//")) return false;
2253
+ if (value.includes("..") || value.includes("@{")) return false;
2254
+ if (value.split("/").some((part) => part === "." || part === ".." || part.endsWith(".lock"))) {
2255
+ return false;
2256
+ }
2257
+ return true;
2258
+ }
2259
+ function isSafeTargetName(value) {
2260
+ return isSafePathPart(value) && !value.startsWith(".");
2261
+ }
2262
+ function isSafeMarketplace(value) {
2263
+ return isSafeRepoPath(value);
2264
+ }
2265
+ function isSafePluginPackage(value) {
2266
+ return SAFE_PLUGIN_PACKAGE_RE.test(value) && !value.includes("..");
2267
+ }
2268
+ function validateGitHubInstallParts(owner, repo, branch, repoPath, targetName) {
2269
+ if (!isSafeGitHubPart(owner)) {
2270
+ throw new Error(`Invalid GitHub owner: ${owner}`);
2271
+ }
2272
+ if (!isSafeGitHubPart(repo)) {
2273
+ throw new Error(`Invalid GitHub repo: ${repo}`);
2274
+ }
2275
+ if (!isSafeGitRef(branch)) {
2276
+ throw new Error(`Invalid Git branch: ${branch}`);
2277
+ }
2278
+ if (!isSafeRepoPath(repoPath)) {
2279
+ throw new Error(`Invalid repository path: ${repoPath}`);
2280
+ }
2281
+ if (!isSafeTargetName(targetName)) {
2282
+ throw new Error(`Invalid skill name: ${targetName}`);
2283
+ }
2284
+ }
2233
2285
  var SKILL_PRESETS = [
2234
2286
  // ===== 官方 (Official) =====
2235
2287
  {
@@ -2375,21 +2427,44 @@ function getGroupOrder() {
2375
2427
  return ["official", "featured", "others"];
2376
2428
  }
2377
2429
  function parseGitHubUrl(url) {
2378
- if (/^[\w-]+\/[\w-]+$/.test(url)) {
2379
- const [owner2, repo2] = url.split("/");
2380
- return { owner: owner2, repo: repo2, branch: "main", path: "" };
2430
+ if (url !== url.trim()) return null;
2431
+ if (/(?:^|\/)\.{1,2}(?:\/|$)/.test(url) || /%2e|%2f/i.test(url)) return null;
2432
+ if (/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(url)) {
2433
+ const [owner, repo] = url.split("/");
2434
+ if (!isSafeGitHubPart(owner) || !isSafeGitHubPart(repo)) return null;
2435
+ return { owner, repo, branch: "main", path: "" };
2436
+ }
2437
+ try {
2438
+ const parsed = new URL(url);
2439
+ if (parsed.protocol !== "https:" || parsed.hostname !== "github.com") {
2440
+ return null;
2441
+ }
2442
+ if (parsed.search || parsed.hash || parsed.username || parsed.password) {
2443
+ return null;
2444
+ }
2445
+ const parts = parsed.pathname.split("/");
2446
+ if (parts.shift() !== "") return null;
2447
+ const [owner, rawRepo, ...rest] = parts;
2448
+ if (!owner || !rawRepo) return null;
2449
+ const repo = rawRepo.replace(/\.git$/, "");
2450
+ let branch = "main";
2451
+ let repoPath = "";
2452
+ if (rest.length > 0) {
2453
+ if (rest[0] !== "tree" || !rest[1]) return null;
2454
+ branch = rest[1];
2455
+ repoPath = rest.slice(2).join("/");
2456
+ }
2457
+ validateGitHubInstallParts(
2458
+ owner,
2459
+ repo,
2460
+ branch,
2461
+ repoPath,
2462
+ repoPath ? path6.basename(repoPath) : repo
2463
+ );
2464
+ return { owner, repo, branch, path: repoPath };
2465
+ } catch {
2466
+ return null;
2381
2467
  }
2382
- const match = url.match(
2383
- /github\.com\/([^/]+)\/([^/]+)(?:\/tree\/([^/]+)(?:\/(.*))?)?/
2384
- );
2385
- if (!match) return null;
2386
- const [, owner, repo, branch = "main", repoPath = ""] = match;
2387
- return {
2388
- owner,
2389
- repo: repo.replace(/\.git$/, ""),
2390
- branch,
2391
- path: repoPath
2392
- };
2393
2468
  }
2394
2469
  function getSkillsDir() {
2395
2470
  return path6.join(process.cwd(), ".claude", "skills");
@@ -2416,23 +2491,29 @@ function cleanupTempDirs(skillsDir) {
2416
2491
  }
2417
2492
  }
2418
2493
  function downloadSkillWithGit(owner, repo, branch, repoPath, targetName) {
2419
- const skillsDir = ensureSkillsDir();
2420
- const targetDir = path6.join(skillsDir, targetName);
2421
- if (fs8.existsSync(targetDir)) {
2422
- console.log(chalk5.yellow(`Skill "${targetName}" already exists. Updating...`));
2423
- fs8.rmSync(targetDir, { recursive: true });
2424
- }
2425
- const repoUrl = `https://github.com/${owner}/${repo}.git`;
2426
- const tempDir = path6.join(skillsDir, `.tmp-${Date.now()}`);
2494
+ let tempDir = null;
2427
2495
  try {
2496
+ validateGitHubInstallParts(owner, repo, branch, repoPath, targetName);
2497
+ const skillsDir = ensureSkillsDir();
2498
+ const targetDir = path6.join(skillsDir, targetName);
2499
+ if (fs8.existsSync(targetDir)) {
2500
+ console.log(chalk5.yellow(`Skill "${targetName}" already exists. Updating...`));
2501
+ fs8.rmSync(targetDir, { recursive: true });
2502
+ }
2503
+ const repoUrl = `https://github.com/${owner}/${repo}.git`;
2504
+ tempDir = path6.join(skillsDir, `.tmp-${Date.now()}`);
2428
2505
  fs8.mkdirSync(tempDir, { recursive: true });
2429
- execSync(`git init`, { cwd: tempDir, stdio: "pipe" });
2430
- execSync(`git remote add origin ${repoUrl}`, { cwd: tempDir, stdio: "pipe" });
2431
- execSync(`git config core.sparseCheckout true`, { cwd: tempDir, stdio: "pipe" });
2506
+ execFileSync2("git", ["init"], { cwd: tempDir, stdio: "pipe" });
2507
+ execFileSync2("git", ["remote", "add", "origin", repoUrl], { cwd: tempDir, stdio: "pipe" });
2508
+ execFileSync2("git", ["config", "core.sparseCheckout", "true"], { cwd: tempDir, stdio: "pipe" });
2432
2509
  const sparseFile = path6.join(tempDir, ".git", "info", "sparse-checkout");
2510
+ fs8.mkdirSync(path6.dirname(sparseFile), { recursive: true });
2433
2511
  fs8.writeFileSync(sparseFile, repoPath ? `${repoPath}/
2434
2512
  ` : "*\n");
2435
- execSync(`git pull --depth=1 origin ${branch}`, { cwd: tempDir, stdio: "pipe" });
2513
+ execFileSync2("git", ["pull", "--depth=1", "origin", branch], {
2514
+ cwd: tempDir,
2515
+ stdio: "pipe"
2516
+ });
2436
2517
  const sourceDir = repoPath ? path6.join(tempDir, repoPath) : tempDir;
2437
2518
  if (!fs8.existsSync(sourceDir)) {
2438
2519
  throw new Error(`Path "${repoPath}" not found in repository`);
@@ -2445,7 +2526,7 @@ function downloadSkillWithGit(owner, repo, branch, repoPath, targetName) {
2445
2526
  console.error(chalk5.red(`Failed to download skill: ${errMsg}`));
2446
2527
  return false;
2447
2528
  } finally {
2448
- if (fs8.existsSync(tempDir)) {
2529
+ if (tempDir && fs8.existsSync(tempDir)) {
2449
2530
  fs8.rmSync(tempDir, { recursive: true });
2450
2531
  }
2451
2532
  }
@@ -2464,6 +2545,13 @@ function copyDir(src, dest) {
2464
2545
  }
2465
2546
  }
2466
2547
  }
2548
+ function isSafeSkillName(name) {
2549
+ return name.trim().length > 0 && name !== "." && name !== ".." && !name.startsWith(".") && !path6.isAbsolute(name) && !name.includes("/") && !name.includes("\\") && !name.includes("\0");
2550
+ }
2551
+ function isPathInside(childPath, parentPath) {
2552
+ const relativePath = path6.relative(parentPath, childPath);
2553
+ return relativePath === "" || relativePath.length > 0 && !relativePath.startsWith("..") && !path6.isAbsolute(relativePath);
2554
+ }
2467
2555
  function addSkillFromGitHub(urlOrPreset) {
2468
2556
  const preset = SKILL_PRESETS.find((p) => p.name === urlOrPreset);
2469
2557
  if (preset) {
@@ -2530,22 +2618,42 @@ function listInstalledSkills() {
2530
2618
  }));
2531
2619
  }
2532
2620
  function removeSkill(name) {
2621
+ if (!isSafeSkillName(name)) {
2622
+ console.error(chalk5.red(`Invalid skill name "${name}"`));
2623
+ return false;
2624
+ }
2533
2625
  const skillsDir = getSkillsDir();
2534
- const targetDir = path6.join(skillsDir, name);
2626
+ const targetDir = path6.resolve(skillsDir, name);
2535
2627
  if (!fs8.existsSync(targetDir)) {
2536
2628
  console.error(chalk5.red(`Skill "${name}" not found`));
2537
2629
  return false;
2538
2630
  }
2631
+ const canonicalSkillsDir = fs8.realpathSync(skillsDir);
2632
+ const canonicalTargetDir = fs8.realpathSync(targetDir);
2633
+ if (!isPathInside(canonicalTargetDir, canonicalSkillsDir)) {
2634
+ console.error(chalk5.red(`Refusing to remove skill outside skills directory: "${name}"`));
2635
+ return false;
2636
+ }
2539
2637
  fs8.rmSync(targetDir, { recursive: true });
2540
2638
  console.log(chalk5.green(`Removed skill "${name}"`));
2541
2639
  return true;
2542
2640
  }
2543
2641
  function installFromPluginMarketplace(marketplace, packageName) {
2544
2642
  try {
2643
+ if (!isSafeMarketplace(marketplace)) {
2644
+ throw new Error(`Invalid marketplace: ${marketplace}`);
2645
+ }
2646
+ if (!isSafePluginPackage(packageName)) {
2647
+ throw new Error(`Invalid package name: ${packageName}`);
2648
+ }
2545
2649
  console.log(chalk5.cyan(`Adding marketplace: ${marketplace}...`));
2546
- execSync(`claude plugin marketplace add ${marketplace}`, { stdio: "inherit" });
2650
+ execFileSync2("claude", ["plugin", "marketplace", "add", marketplace], {
2651
+ stdio: "inherit"
2652
+ });
2547
2653
  console.log(chalk5.cyan(`Installing package: ${packageName}...`));
2548
- execSync(`claude plugin install ${packageName}`, { stdio: "inherit" });
2654
+ execFileSync2("claude", ["plugin", "install", packageName], {
2655
+ stdio: "inherit"
2656
+ });
2549
2657
  console.log(chalk5.green(`Successfully installed ${packageName}`));
2550
2658
  return true;
2551
2659
  } catch (error) {
@@ -2660,7 +2768,7 @@ function SkillSelector({ onSelect, onCancel }) {
2660
2768
 
2661
2769
  // src/components/index.tsx
2662
2770
  async function runSkillSelector() {
2663
- return new Promise((resolve3) => {
2771
+ return new Promise((resolve4) => {
2664
2772
  let resolved = false;
2665
2773
  const { unmount, waitUntilExit } = render(
2666
2774
  /* @__PURE__ */ React2.createElement(
@@ -2671,23 +2779,23 @@ async function runSkillSelector() {
2671
2779
  resolved = true;
2672
2780
  unmount();
2673
2781
  if (result === "custom") {
2674
- resolve3({ type: "custom" });
2782
+ resolve4({ type: "custom" });
2675
2783
  } else {
2676
- resolve3({ type: "skill", skill: result });
2784
+ resolve4({ type: "skill", skill: result });
2677
2785
  }
2678
2786
  },
2679
2787
  onCancel: () => {
2680
2788
  if (resolved) return;
2681
2789
  resolved = true;
2682
2790
  unmount();
2683
- resolve3({ type: "cancelled" });
2791
+ resolve4({ type: "cancelled" });
2684
2792
  }
2685
2793
  }
2686
2794
  )
2687
2795
  );
2688
2796
  waitUntilExit().then(() => {
2689
2797
  if (!resolved) {
2690
- resolve3({ type: "cancelled" });
2798
+ resolve4({ type: "cancelled" });
2691
2799
  }
2692
2800
  });
2693
2801
  });
@@ -2702,16 +2810,70 @@ var config = new Conf({
2702
2810
  cwd: getCcemConfigDir()
2703
2811
  // 使用统一的配置目录
2704
2812
  });
2705
- var decryptWithSecret = (encryptedBase64, secret) => {
2706
- const key = crypto3.scryptSync(secret, "ccem-salt", 32);
2813
+ var REMOTE_CRYPTO_ALGORITHM_V2 = "aes-256-gcm";
2814
+ var REMOTE_CRYPTO_ALGORITHM_V1 = "aes-256-cbc";
2815
+ var isEnvelopeV2 = (value) => {
2816
+ if (typeof value !== "object" || value === null) return false;
2817
+ const v = value;
2818
+ return v.v === 2 && typeof v.nonce === "string" && typeof v.ciphertext === "string" && typeof v.tag === "string";
2819
+ };
2820
+ var deriveRemoteKey = (secret) => crypto3.scryptSync(secret, "ccem-salt", 32);
2821
+ var decryptV2Envelope = (envelope, key) => {
2822
+ const nonce = Buffer.from(envelope.nonce, "base64");
2823
+ const ciphertext = Buffer.from(envelope.ciphertext, "base64");
2824
+ const tag = Buffer.from(envelope.tag, "base64");
2825
+ if (nonce.length === 0 || ciphertext.length === 0 || tag.length === 0) {
2826
+ throw new Error("v2 envelope has empty nonce/ciphertext/tag");
2827
+ }
2828
+ const decipher = crypto3.createDecipheriv(
2829
+ REMOTE_CRYPTO_ALGORITHM_V2,
2830
+ key,
2831
+ nonce
2832
+ );
2833
+ decipher.setAuthTag(tag);
2834
+ let decrypted = decipher.update(ciphertext);
2835
+ decrypted = Buffer.concat([decrypted, decipher.final()]);
2836
+ return decrypted.toString("utf8");
2837
+ };
2838
+ var decryptV1Legacy = (encryptedBase64, key) => {
2707
2839
  const combined = Buffer.from(encryptedBase64, "base64");
2840
+ if (combined.length < 16) {
2841
+ throw new Error("v1 payload too short");
2842
+ }
2708
2843
  const iv = combined.subarray(0, 16);
2709
2844
  const encryptedHex = combined.subarray(16).toString("hex");
2710
- const decipher = crypto3.createDecipheriv("aes-256-cbc", key, iv);
2845
+ const decipher = crypto3.createDecipheriv(REMOTE_CRYPTO_ALGORITHM_V1, key, iv);
2711
2846
  let decrypted = decipher.update(encryptedHex, "hex", "utf8");
2712
2847
  decrypted += decipher.final("utf8");
2713
2848
  return decrypted;
2714
2849
  };
2850
+ var decryptWithSecret = (encryptedBase64, secret) => {
2851
+ const key = deriveRemoteKey(secret);
2852
+ let parsedObj = null;
2853
+ try {
2854
+ const jsonStr = Buffer.from(encryptedBase64, "base64").toString("utf8");
2855
+ parsedObj = JSON.parse(jsonStr);
2856
+ } catch {
2857
+ }
2858
+ let envelopeV2 = null;
2859
+ if (parsedObj !== null) {
2860
+ if (isEnvelopeV2(parsedObj)) {
2861
+ envelopeV2 = parsedObj;
2862
+ } else if (parsedObj && typeof parsedObj === "object" && "v" in parsedObj) {
2863
+ const version = parsedObj.v;
2864
+ if (version === 2) {
2865
+ throw new Error(
2866
+ "Malformed v2 envelope: missing required fields (nonce, ciphertext, tag)"
2867
+ );
2868
+ }
2869
+ throw new Error(`Unsupported remote envelope version: ${version}`);
2870
+ }
2871
+ }
2872
+ if (envelopeV2) {
2873
+ return decryptV2Envelope(envelopeV2, key);
2874
+ }
2875
+ return decryptV1Legacy(encryptedBase64, key);
2876
+ };
2715
2877
  var getUniqueName = (baseName, existingNames) => {
2716
2878
  if (!existingNames.has(baseName)) {
2717
2879
  return baseName;
@@ -3168,8 +3330,132 @@ function formatCronTaskTableRows(tasks) {
3168
3330
  // src/desktopControl.ts
3169
3331
  import fs10 from "fs";
3170
3332
  import path8 from "path";
3333
+ var DEFAULT_REQUEST_TIMEOUT_MS = 5e3;
3334
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
3335
+ var ENDPOINT_UNREACHABLE_CODES = /* @__PURE__ */ new Set([
3336
+ "ECONNREFUSED",
3337
+ "ECONNRESET",
3338
+ "ENOTCONN",
3339
+ "EHOSTUNREACH",
3340
+ "ECONNABORTED"
3341
+ ]);
3342
+ var StaleDesktopControlDescriptorError = class extends Error {
3343
+ name = "StaleDesktopControlDescriptorError";
3344
+ reason;
3345
+ descriptorPath;
3346
+ pid;
3347
+ cleanedUp;
3348
+ cause;
3349
+ constructor(details) {
3350
+ const subject = details.pid !== null ? `process ${details.pid}` : "the publishing process";
3351
+ const symptom = details.reason === "dead-pid" ? `${subject} is no longer running` : details.reason === "endpoint-unreachable" ? "the control endpoint refused the connection" : `the control request timed out after ${details.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS}ms`;
3352
+ const remedy = details.cleanedUp ? "The stale descriptor was removed automatically; start CCEM Desktop and rerun the command." : details.pid !== null ? "Restart CCEM Desktop so it republishes a fresh control endpoint." : "Restart CCEM Desktop to refresh the descriptor, or remove the stale file manually if it is no longer managed.";
3353
+ super(
3354
+ `CCEM Desktop control descriptor at ${details.descriptorPath} is stale: ${symptom}. ${remedy}`
3355
+ );
3356
+ this.reason = details.reason;
3357
+ this.descriptorPath = details.descriptorPath;
3358
+ this.pid = details.pid;
3359
+ this.cleanedUp = details.cleanedUp;
3360
+ if (details.cause !== void 0) {
3361
+ this.cause = details.cause;
3362
+ }
3363
+ }
3364
+ };
3171
3365
  function getDesktopControlDescriptorPath() {
3172
- return process.env.CCEM_CONTROL_FILE?.trim() || path8.join(getCcemConfigDir(), "control.json");
3366
+ return process.env.CCEM_CONTROL_FILE?.trim() || getDefaultControlDescriptorPath();
3367
+ }
3368
+ function getDefaultControlDescriptorPath() {
3369
+ return path8.join(getCcemConfigDir(), "control.json");
3370
+ }
3371
+ function isDefaultDescriptorPath(descriptorPath) {
3372
+ try {
3373
+ return path8.resolve(descriptorPath) === path8.resolve(getDefaultControlDescriptorPath());
3374
+ } catch {
3375
+ return false;
3376
+ }
3377
+ }
3378
+ function safeRemoveStaleDescriptor(descriptorPath) {
3379
+ if (!isDefaultDescriptorPath(descriptorPath)) {
3380
+ return false;
3381
+ }
3382
+ try {
3383
+ fs10.rmSync(descriptorPath);
3384
+ return true;
3385
+ } catch {
3386
+ return false;
3387
+ }
3388
+ }
3389
+ function readErrnoCode(error) {
3390
+ if (!error || typeof error !== "object") return void 0;
3391
+ const candidate = error;
3392
+ return candidate.code ?? candidate.cause?.code;
3393
+ }
3394
+ function readPid(value) {
3395
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
3396
+ }
3397
+ function isLoopbackHost(host) {
3398
+ const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
3399
+ if (LOOPBACK_HOSTS.has(normalized)) {
3400
+ return true;
3401
+ }
3402
+ if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(normalized)) {
3403
+ return true;
3404
+ }
3405
+ if (normalized === "::1" || normalized === "0:0:0:0:0:0:0:1") {
3406
+ return true;
3407
+ }
3408
+ return false;
3409
+ }
3410
+ function extractHost(endpoint) {
3411
+ const withoutScheme = endpoint.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "");
3412
+ const hostPort = withoutScheme.split(/[\/?#]/)[0] || "";
3413
+ if (hostPort.startsWith("[")) {
3414
+ const end = hostPort.indexOf("]");
3415
+ if (end === -1) return null;
3416
+ return hostPort.slice(1, end);
3417
+ }
3418
+ const colonIndex = hostPort.lastIndexOf(":");
3419
+ if (colonIndex === -1) return hostPort;
3420
+ return hostPort.slice(0, colonIndex);
3421
+ }
3422
+ function validateLoopbackEndpoint(endpoint) {
3423
+ const host = extractHost(endpoint);
3424
+ if (!host) {
3425
+ throw new Error(
3426
+ `CCEM Desktop control endpoint '${redactEndpoint(endpoint)}' is missing a host. Refusing to continue.`
3427
+ );
3428
+ }
3429
+ if (!isLoopbackHost(host)) {
3430
+ throw new Error(
3431
+ `CCEM Desktop control endpoint '${redactEndpoint(endpoint)}' is not bound to loopback. Only 127.0.0.1, localhost, or ::1 are allowed.`
3432
+ );
3433
+ }
3434
+ }
3435
+ function isPidAlive(pid) {
3436
+ if (!Number.isFinite(pid) || pid <= 0) return false;
3437
+ try {
3438
+ process.kill(pid, 0);
3439
+ return true;
3440
+ } catch (error) {
3441
+ const code = error.code;
3442
+ if (code === "ESRCH") return false;
3443
+ if (code === "EPERM") return true;
3444
+ return false;
3445
+ }
3446
+ }
3447
+ function redactEndpoint(endpoint) {
3448
+ try {
3449
+ const url = new URL(endpoint);
3450
+ return `${url.protocol}//${url.host}${url.pathname}`;
3451
+ } catch {
3452
+ return "<invalid endpoint>";
3453
+ }
3454
+ }
3455
+ function isDescriptor(value) {
3456
+ return Boolean(
3457
+ value && typeof value === "object" && typeof value.endpoint === "string" && typeof value.token === "string"
3458
+ );
3173
3459
  }
3174
3460
  function resolveDesktopControlDescriptor(descriptorPath = getDesktopControlDescriptorPath()) {
3175
3461
  if (!fs10.existsSync(descriptorPath)) {
@@ -3181,27 +3467,93 @@ function resolveDesktopControlDescriptor(descriptorPath = getDesktopControlDescr
3181
3467
  if (!endpoint || !token) {
3182
3468
  throw new Error(`Invalid CCEM Desktop control descriptor at ${descriptorPath}`);
3183
3469
  }
3184
- return {
3185
- endpoint,
3186
- token,
3187
- pid: typeof parsed.pid === "number" ? parsed.pid : null
3188
- };
3470
+ validateLoopbackEndpoint(endpoint);
3471
+ const pid = readPid(parsed.pid);
3472
+ if (pid !== null && !isPidAlive(pid)) {
3473
+ const cleanedUp = safeRemoveStaleDescriptor(descriptorPath);
3474
+ throw new StaleDesktopControlDescriptorError({
3475
+ reason: "dead-pid",
3476
+ descriptorPath,
3477
+ pid,
3478
+ cleanedUp
3479
+ });
3480
+ }
3481
+ return { endpoint, token, pid };
3189
3482
  }
3190
- async function requestDesktopControl(method, params, descriptor = resolveDesktopControlDescriptor()) {
3483
+ async function requestDesktopControl(method, params, descriptorOrOptions, maybeOptions = {}) {
3484
+ const descriptorPath = getDesktopControlDescriptorPath();
3485
+ const hasInjectedDescriptor = isDescriptor(descriptorOrOptions);
3486
+ const descriptor = hasInjectedDescriptor ? descriptorOrOptions : resolveDesktopControlDescriptor(descriptorPath);
3487
+ const options = hasInjectedDescriptor ? maybeOptions : descriptorOrOptions ?? maybeOptions ?? {};
3488
+ const timeoutMs = options.timeoutMs ?? options.fetchTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
3489
+ const pid = readPid(descriptor.pid);
3490
+ validateLoopbackEndpoint(descriptor.endpoint);
3491
+ if (pid !== null && !isPidAlive(pid)) {
3492
+ const cleanedUp = hasInjectedDescriptor ? false : safeRemoveStaleDescriptor(descriptorPath);
3493
+ throw new StaleDesktopControlDescriptorError({
3494
+ reason: "dead-pid",
3495
+ descriptorPath,
3496
+ pid,
3497
+ cleanedUp
3498
+ });
3499
+ }
3191
3500
  const id = `ccem-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
3192
- const response = await fetch(descriptor.endpoint, {
3193
- method: "POST",
3194
- headers: {
3195
- authorization: `Bearer ${descriptor.token}`,
3196
- "content-type": "application/json"
3197
- },
3198
- body: JSON.stringify({
3199
- jsonrpc: "2.0",
3200
- id,
3201
- method,
3202
- params: params ?? {}
3203
- })
3204
- });
3501
+ const controller = new AbortController();
3502
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
3503
+ const externalSignal = options.signal;
3504
+ const onExternalAbort = () => controller.abort();
3505
+ if (externalSignal) {
3506
+ if (externalSignal.aborted) {
3507
+ clearTimeout(timer);
3508
+ controller.abort();
3509
+ } else {
3510
+ externalSignal.addEventListener("abort", onExternalAbort, { once: true });
3511
+ }
3512
+ }
3513
+ let response;
3514
+ try {
3515
+ response = await fetch(descriptor.endpoint, {
3516
+ method: "POST",
3517
+ headers: {
3518
+ authorization: `Bearer ${descriptor.token}`,
3519
+ "content-type": "application/json"
3520
+ },
3521
+ body: JSON.stringify({
3522
+ jsonrpc: "2.0",
3523
+ id,
3524
+ method,
3525
+ params: params ?? {}
3526
+ }),
3527
+ signal: controller.signal
3528
+ });
3529
+ } catch (error) {
3530
+ if (controller.signal.aborted) {
3531
+ throw new StaleDesktopControlDescriptorError({
3532
+ reason: "request-timeout",
3533
+ descriptorPath,
3534
+ pid,
3535
+ cleanedUp: false,
3536
+ timeoutMs,
3537
+ cause: error
3538
+ });
3539
+ }
3540
+ const code = readErrnoCode(error);
3541
+ if (code && ENDPOINT_UNREACHABLE_CODES.has(code)) {
3542
+ throw new StaleDesktopControlDescriptorError({
3543
+ reason: "endpoint-unreachable",
3544
+ descriptorPath,
3545
+ pid,
3546
+ cleanedUp: false,
3547
+ cause: error
3548
+ });
3549
+ }
3550
+ throw error;
3551
+ } finally {
3552
+ clearTimeout(timer);
3553
+ if (externalSignal) {
3554
+ externalSignal.removeEventListener("abort", onExternalAbort);
3555
+ }
3556
+ }
3205
3557
  if (!response.ok) {
3206
3558
  throw new Error(`CCEM Desktop control request failed: HTTP ${response.status}`);
3207
3559
  }
@@ -3211,6 +3563,26 @@ async function requestDesktopControl(method, params, descriptor = resolveDesktop
3211
3563
  }
3212
3564
  return payload.result;
3213
3565
  }
3566
+ function parseSinceOption(raw) {
3567
+ if (raw === void 0 || raw === null || raw === "") return null;
3568
+ const value = Number(raw);
3569
+ if (!Number.isFinite(value) || value < 0 || !Number.isInteger(value)) {
3570
+ throw new Error(
3571
+ `Invalid --since value '${raw}'. Expected a non-negative integer sequence number (e.g. 0, 42).`
3572
+ );
3573
+ }
3574
+ return value;
3575
+ }
3576
+ function parseLimitOption(raw) {
3577
+ if (raw === void 0 || raw === null || raw === "") return null;
3578
+ const value = Number(raw);
3579
+ if (!Number.isFinite(value) || value <= 0 || !Number.isInteger(value)) {
3580
+ throw new Error(
3581
+ `Invalid --limit value '${raw}'. Expected a positive integer (e.g. 1, 100).`
3582
+ );
3583
+ }
3584
+ return value;
3585
+ }
3214
3586
  function printJson(value) {
3215
3587
  console.log(JSON.stringify(value, null, 2));
3216
3588
  }
@@ -3574,12 +3946,12 @@ var findAttachSession = (id) => {
3574
3946
  };
3575
3947
  var attachTmuxTarget = (target) => {
3576
3948
  const args = process.env.TMUX ? ["switch-client", "-t", target] : ["attach-session", "-t", target];
3577
- return new Promise((resolve3, reject) => {
3949
+ return new Promise((resolve4, reject) => {
3578
3950
  const child = spawn3("tmux", args, { stdio: "inherit" });
3579
3951
  child.on("error", reject);
3580
3952
  child.on("exit", (code) => {
3581
3953
  if (code === 0 || code === null) {
3582
- resolve3();
3954
+ resolve4();
3583
3955
  } else {
3584
3956
  reject(new Error(`tmux exited with code ${code}`));
3585
3957
  }
@@ -4192,10 +4564,12 @@ desktopCmd.command("status <runtimeId>").description("Get one CCEM Desktop works
4192
4564
  });
4193
4565
  desktopCmd.command("events <runtimeId>").description("Read CCEM Desktop workspace session events").option("--since <seq>", "First event sequence after this value").option("--limit <count>", "Maximum events to return").option("--json", "Output JSON").action(async function(runtimeId) {
4194
4566
  const opts = this.opts();
4567
+ const sinceSeq = parseSinceOption(opts.since);
4568
+ const limit = parseLimitOption(opts.limit);
4195
4569
  const result = await requestDesktopControl("ccem.workspace.getEvents", {
4196
4570
  runtimeId,
4197
- sinceSeq: opts.since ? Number(opts.since) : null,
4198
- limit: opts.limit ? Number(opts.limit) : null
4571
+ sinceSeq,
4572
+ limit
4199
4573
  });
4200
4574
  outputDesktopResult(result, opts);
4201
4575
  });
@@ -4296,11 +4670,11 @@ Editing environment '${result.name}'`));
4296
4670
  registries[result.name] = applyPromptAnswers(envToEdit, answers, true);
4297
4671
  setRegistries(registries);
4298
4672
  msg.success(`Environment '${result.name}' updated.`);
4299
- await new Promise((resolve3) => setTimeout(resolve3, 800));
4673
+ await new Promise((resolve4) => setTimeout(resolve4, 800));
4300
4674
  } else if (result.action === "rename") {
4301
4675
  if (result.name === "official") {
4302
4676
  msg.error("Cannot rename default 'official' environment.");
4303
- await new Promise((resolve3) => setTimeout(resolve3, 800));
4677
+ await new Promise((resolve4) => setTimeout(resolve4, 800));
4304
4678
  } else {
4305
4679
  const { newName } = await inquirer.prompt([
4306
4680
  {
@@ -4321,7 +4695,7 @@ Editing environment '${result.name}'`));
4321
4695
  config2.set("current", newName);
4322
4696
  }
4323
4697
  msg.success(`Environment '${result.name}' renamed to '${newName}'.`);
4324
- await new Promise((resolve3) => setTimeout(resolve3, 800));
4698
+ await new Promise((resolve4) => setTimeout(resolve4, 800));
4325
4699
  }
4326
4700
  } else if (result.action === "copy") {
4327
4701
  const { targetName } = await inquirer.prompt([
@@ -4354,11 +4728,11 @@ Editing environment '${result.name}'`));
4354
4728
  setRegistries(registries);
4355
4729
  msg.success(`Environment '${targetName}' updated.`);
4356
4730
  }
4357
- await new Promise((resolve3) => setTimeout(resolve3, 800));
4731
+ await new Promise((resolve4) => setTimeout(resolve4, 800));
4358
4732
  } else if (result.action === "delete") {
4359
4733
  if (result.name === "official") {
4360
4734
  msg.error("Cannot delete default 'official' environment.");
4361
- await new Promise((resolve3) => setTimeout(resolve3, 800));
4735
+ await new Promise((resolve4) => setTimeout(resolve4, 800));
4362
4736
  } else {
4363
4737
  const { confirm } = await inquirer.prompt([
4364
4738
  {
@@ -4378,7 +4752,7 @@ Editing environment '${result.name}'`));
4378
4752
  msg.success(`Environment '${result.name}' deleted.`);
4379
4753
  }
4380
4754
  }
4381
- await new Promise((resolve3) => setTimeout(resolve3, 800));
4755
+ await new Promise((resolve4) => setTimeout(resolve4, 800));
4382
4756
  }
4383
4757
  }
4384
4758
  } else if (action === "perm") {
@@ -4409,15 +4783,27 @@ Editing environment '${result.name}'`));
4409
4783
  if (selectedMode === "clear") {
4410
4784
  config2.set("defaultMode", null);
4411
4785
  msg.success("Default mode cleared");
4412
- await new Promise((resolve3) => setTimeout(resolve3, 800));
4786
+ await new Promise((resolve4) => setTimeout(resolve4, 800));
4413
4787
  } else if (selectedMode !== "back") {
4414
4788
  config2.set("defaultMode", selectedMode);
4415
4789
  msg.success(`Default mode set: ${PERMISSION_PRESETS[selectedMode].name}`);
4416
- await new Promise((resolve3) => setTimeout(resolve3, 800));
4790
+ await new Promise((resolve4) => setTimeout(resolve4, 800));
4417
4791
  }
4418
4792
  } else {
4419
4793
  process.exit(0);
4420
4794
  }
4421
4795
  }
4422
4796
  });
4423
- program.parse(process.argv);
4797
+ function handleCliError(error) {
4798
+ if (error instanceof StaleDesktopControlDescriptorError) {
4799
+ console.error(chalk7.red(error.message));
4800
+ process.exit(1);
4801
+ }
4802
+ if (error instanceof Error) {
4803
+ console.error(chalk7.red(error.message));
4804
+ process.exit(1);
4805
+ }
4806
+ console.error(chalk7.red(String(error)));
4807
+ process.exit(1);
4808
+ }
4809
+ await program.parseAsync(process.argv).catch(handleCliError);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccem",
3
- "version": "2.28.0",
3
+ "version": "2.31.0",
4
4
  "type": "module",
5
5
  "description": "Claude Code Environment Manager",
6
6
  "repository": {