episoda 0.2.19 → 0.2.21

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
@@ -295,7 +295,7 @@ var require_git_executor = __commonJS({
295
295
  var git_validator_1 = require_git_validator();
296
296
  var git_parser_1 = require_git_parser();
297
297
  var execAsync = (0, util_1.promisify)(child_process_1.exec);
298
- var GitExecutor = class {
298
+ var GitExecutor6 = class {
299
299
  /**
300
300
  * Execute a git command
301
301
  * @param command - The git command to execute
@@ -381,6 +381,19 @@ var require_git_executor = __commonJS({
381
381
  return await this.executeRebaseContinue(cwd, options);
382
382
  case "rebase_status":
383
383
  return await this.executeRebaseStatus(cwd, options);
384
+ // EP944: Worktree operations
385
+ case "worktree_add":
386
+ return await this.executeWorktreeAdd(command, cwd, options);
387
+ case "worktree_remove":
388
+ return await this.executeWorktreeRemove(command, cwd, options);
389
+ case "worktree_list":
390
+ return await this.executeWorktreeList(cwd, options);
391
+ case "worktree_prune":
392
+ return await this.executeWorktreePrune(cwd, options);
393
+ case "clone_bare":
394
+ return await this.executeCloneBare(command, options);
395
+ case "project_info":
396
+ return await this.executeProjectInfo(cwd, options);
384
397
  default:
385
398
  return {
386
399
  success: false,
@@ -1518,15 +1531,15 @@ var require_git_executor = __commonJS({
1518
1531
  try {
1519
1532
  const { stdout: gitDir } = await execAsync("git rev-parse --git-dir", { cwd, timeout: 5e3 });
1520
1533
  const gitDirPath = gitDir.trim();
1521
- const fs6 = await Promise.resolve().then(() => __importStar(require("fs"))).then((m) => m.promises);
1534
+ const fs12 = await Promise.resolve().then(() => __importStar(require("fs"))).then((m) => m.promises);
1522
1535
  const rebaseMergePath = `${gitDirPath}/rebase-merge`;
1523
1536
  const rebaseApplyPath = `${gitDirPath}/rebase-apply`;
1524
1537
  try {
1525
- await fs6.access(rebaseMergePath);
1538
+ await fs12.access(rebaseMergePath);
1526
1539
  inRebase = true;
1527
1540
  } catch {
1528
1541
  try {
1529
- await fs6.access(rebaseApplyPath);
1542
+ await fs12.access(rebaseApplyPath);
1530
1543
  inRebase = true;
1531
1544
  } catch {
1532
1545
  inRebase = false;
@@ -1564,6 +1577,328 @@ var require_git_executor = __commonJS({
1564
1577
  };
1565
1578
  }
1566
1579
  }
1580
+ // ========================================
1581
+ // EP944: Worktree operations
1582
+ // ========================================
1583
+ /**
1584
+ * EP944: Add a new worktree for a branch
1585
+ * Creates a new working tree at the specified path
1586
+ */
1587
+ async executeWorktreeAdd(command, cwd, options) {
1588
+ try {
1589
+ const validation = (0, git_validator_1.validateBranchName)(command.branch);
1590
+ if (!validation.valid) {
1591
+ return {
1592
+ success: false,
1593
+ error: validation.error || "UNKNOWN_ERROR"
1594
+ };
1595
+ }
1596
+ const fs12 = await Promise.resolve().then(() => __importStar(require("fs"))).then((m) => m.promises);
1597
+ try {
1598
+ await fs12.access(command.path);
1599
+ return {
1600
+ success: false,
1601
+ error: "WORKTREE_EXISTS",
1602
+ output: `Worktree already exists at path: ${command.path}`
1603
+ };
1604
+ } catch {
1605
+ }
1606
+ try {
1607
+ await this.runGitCommand(["fetch", "--all", "--prune"], cwd, options);
1608
+ } catch {
1609
+ }
1610
+ const args = ["worktree", "add"];
1611
+ if (command.create) {
1612
+ args.push("-b", command.branch, command.path);
1613
+ } else {
1614
+ args.push(command.path, command.branch);
1615
+ }
1616
+ const result = await this.runGitCommand(args, cwd, options);
1617
+ if (result.success) {
1618
+ return {
1619
+ success: true,
1620
+ output: `Created worktree at ${command.path} for branch ${command.branch}`,
1621
+ details: {
1622
+ worktreePath: command.path,
1623
+ branchName: command.branch
1624
+ }
1625
+ };
1626
+ }
1627
+ if (result.output?.includes("already checked out")) {
1628
+ return {
1629
+ success: false,
1630
+ error: "BRANCH_IN_USE",
1631
+ output: `Branch '${command.branch}' is already checked out in another worktree`
1632
+ };
1633
+ }
1634
+ return result;
1635
+ } catch (error) {
1636
+ return {
1637
+ success: false,
1638
+ error: "UNKNOWN_ERROR",
1639
+ output: error.message || "Failed to add worktree"
1640
+ };
1641
+ }
1642
+ }
1643
+ /**
1644
+ * EP944: Remove a worktree
1645
+ * Removes the working tree at the specified path
1646
+ */
1647
+ async executeWorktreeRemove(command, cwd, options) {
1648
+ try {
1649
+ const fs12 = await Promise.resolve().then(() => __importStar(require("fs"))).then((m) => m.promises);
1650
+ try {
1651
+ await fs12.access(command.path);
1652
+ } catch {
1653
+ return {
1654
+ success: false,
1655
+ error: "WORKTREE_NOT_FOUND",
1656
+ output: `Worktree not found at path: ${command.path}`
1657
+ };
1658
+ }
1659
+ if (!command.force) {
1660
+ try {
1661
+ const { stdout } = await execAsync("git status --porcelain", {
1662
+ cwd: command.path,
1663
+ timeout: options?.timeout || 1e4
1664
+ });
1665
+ if (stdout.trim()) {
1666
+ return {
1667
+ success: false,
1668
+ error: "UNCOMMITTED_CHANGES",
1669
+ output: "Worktree has uncommitted changes. Use force to remove anyway.",
1670
+ details: {
1671
+ uncommittedFiles: stdout.trim().split("\n").map((line) => line.slice(3))
1672
+ }
1673
+ };
1674
+ }
1675
+ } catch {
1676
+ }
1677
+ }
1678
+ const args = ["worktree", "remove"];
1679
+ if (command.force) {
1680
+ args.push("--force");
1681
+ }
1682
+ args.push(command.path);
1683
+ const result = await this.runGitCommand(args, cwd, options);
1684
+ if (result.success) {
1685
+ return {
1686
+ success: true,
1687
+ output: `Removed worktree at ${command.path}`,
1688
+ details: {
1689
+ worktreePath: command.path
1690
+ }
1691
+ };
1692
+ }
1693
+ if (result.output?.includes("locked")) {
1694
+ return {
1695
+ success: false,
1696
+ error: "WORKTREE_LOCKED",
1697
+ output: `Worktree at ${command.path} is locked`
1698
+ };
1699
+ }
1700
+ return result;
1701
+ } catch (error) {
1702
+ return {
1703
+ success: false,
1704
+ error: "UNKNOWN_ERROR",
1705
+ output: error.message || "Failed to remove worktree"
1706
+ };
1707
+ }
1708
+ }
1709
+ /**
1710
+ * EP944: List all worktrees
1711
+ * Returns information about all worktrees in the repository
1712
+ */
1713
+ async executeWorktreeList(cwd, options) {
1714
+ try {
1715
+ const { stdout } = await execAsync("git worktree list --porcelain", {
1716
+ cwd,
1717
+ timeout: options?.timeout || 1e4
1718
+ });
1719
+ const worktrees = [];
1720
+ const lines = stdout.trim().split("\n");
1721
+ let current = {};
1722
+ for (const line of lines) {
1723
+ if (line.startsWith("worktree ")) {
1724
+ current.path = line.slice(9);
1725
+ } else if (line.startsWith("HEAD ")) {
1726
+ current.commit = line.slice(5);
1727
+ } else if (line.startsWith("branch ")) {
1728
+ const refPath = line.slice(7);
1729
+ current.branch = refPath.replace("refs/heads/", "");
1730
+ } else if (line === "locked") {
1731
+ current.locked = true;
1732
+ } else if (line === "prunable") {
1733
+ current.prunable = true;
1734
+ } else if (line.startsWith("detached")) {
1735
+ current.branch = "HEAD (detached)";
1736
+ } else if (line === "" && current.path) {
1737
+ worktrees.push({
1738
+ path: current.path,
1739
+ branch: current.branch || "unknown",
1740
+ commit: current.commit || "",
1741
+ locked: current.locked,
1742
+ prunable: current.prunable
1743
+ });
1744
+ current = {};
1745
+ }
1746
+ }
1747
+ if (current.path) {
1748
+ worktrees.push({
1749
+ path: current.path,
1750
+ branch: current.branch || "unknown",
1751
+ commit: current.commit || "",
1752
+ locked: current.locked,
1753
+ prunable: current.prunable
1754
+ });
1755
+ }
1756
+ return {
1757
+ success: true,
1758
+ output: `Found ${worktrees.length} worktree(s)`,
1759
+ details: {
1760
+ worktrees
1761
+ }
1762
+ };
1763
+ } catch (error) {
1764
+ return {
1765
+ success: false,
1766
+ error: "UNKNOWN_ERROR",
1767
+ output: error.message || "Failed to list worktrees"
1768
+ };
1769
+ }
1770
+ }
1771
+ /**
1772
+ * EP944: Prune stale worktrees
1773
+ * Removes worktree administrative files for worktrees whose directories are missing
1774
+ */
1775
+ async executeWorktreePrune(cwd, options) {
1776
+ try {
1777
+ const listResult = await this.executeWorktreeList(cwd, options);
1778
+ const prunableCount = listResult.details?.worktrees?.filter((w) => w.prunable).length || 0;
1779
+ const result = await this.runGitCommand(["worktree", "prune"], cwd, options);
1780
+ if (result.success) {
1781
+ return {
1782
+ success: true,
1783
+ output: prunableCount > 0 ? `Pruned ${prunableCount} stale worktree(s)` : "No stale worktrees to prune",
1784
+ details: {
1785
+ prunedCount: prunableCount
1786
+ }
1787
+ };
1788
+ }
1789
+ return result;
1790
+ } catch (error) {
1791
+ return {
1792
+ success: false,
1793
+ error: "UNKNOWN_ERROR",
1794
+ output: error.message || "Failed to prune worktrees"
1795
+ };
1796
+ }
1797
+ }
1798
+ /**
1799
+ * EP944: Clone a repository as a bare repository
1800
+ * Used for worktree-based development setup
1801
+ */
1802
+ async executeCloneBare(command, options) {
1803
+ try {
1804
+ const fs12 = await Promise.resolve().then(() => __importStar(require("fs"))).then((m) => m.promises);
1805
+ const path14 = await Promise.resolve().then(() => __importStar(require("path")));
1806
+ try {
1807
+ await fs12.access(command.path);
1808
+ return {
1809
+ success: false,
1810
+ error: "BRANCH_ALREADY_EXISTS",
1811
+ // Reusing for path exists
1812
+ output: `Directory already exists at path: ${command.path}`
1813
+ };
1814
+ } catch {
1815
+ }
1816
+ const parentDir = path14.dirname(command.path);
1817
+ try {
1818
+ await fs12.mkdir(parentDir, { recursive: true });
1819
+ } catch {
1820
+ }
1821
+ const { stdout, stderr } = await execAsync(
1822
+ `git clone --bare "${command.url}" "${command.path}"`,
1823
+ { timeout: options?.timeout || 12e4 }
1824
+ // 2 minutes for clone
1825
+ );
1826
+ return {
1827
+ success: true,
1828
+ output: `Cloned bare repository to ${command.path}`,
1829
+ details: {
1830
+ worktreePath: command.path
1831
+ }
1832
+ };
1833
+ } catch (error) {
1834
+ if (error.message?.includes("Authentication") || error.message?.includes("Permission denied")) {
1835
+ return {
1836
+ success: false,
1837
+ error: "AUTH_FAILURE",
1838
+ output: "Authentication failed. Please check your credentials."
1839
+ };
1840
+ }
1841
+ if (error.message?.includes("Could not resolve") || error.message?.includes("unable to access")) {
1842
+ return {
1843
+ success: false,
1844
+ error: "NETWORK_ERROR",
1845
+ output: "Network error. Please check your connection."
1846
+ };
1847
+ }
1848
+ return {
1849
+ success: false,
1850
+ error: "UNKNOWN_ERROR",
1851
+ output: error.message || "Failed to clone repository"
1852
+ };
1853
+ }
1854
+ }
1855
+ /**
1856
+ * EP944: Get project info including worktree mode
1857
+ * Returns information about the project configuration
1858
+ */
1859
+ async executeProjectInfo(cwd, options) {
1860
+ try {
1861
+ const fs12 = await Promise.resolve().then(() => __importStar(require("fs"))).then((m) => m.promises);
1862
+ const path14 = await Promise.resolve().then(() => __importStar(require("path")));
1863
+ let currentPath = cwd;
1864
+ let worktreeMode = false;
1865
+ let projectPath = cwd;
1866
+ let bareRepoPath;
1867
+ for (let i = 0; i < 10; i++) {
1868
+ const bareDir = path14.join(currentPath, ".bare");
1869
+ const episodaDir = path14.join(currentPath, ".episoda");
1870
+ try {
1871
+ await fs12.access(bareDir);
1872
+ await fs12.access(episodaDir);
1873
+ worktreeMode = true;
1874
+ projectPath = currentPath;
1875
+ bareRepoPath = bareDir;
1876
+ break;
1877
+ } catch {
1878
+ const parentPath = path14.dirname(currentPath);
1879
+ if (parentPath === currentPath) {
1880
+ break;
1881
+ }
1882
+ currentPath = parentPath;
1883
+ }
1884
+ }
1885
+ return {
1886
+ success: true,
1887
+ output: worktreeMode ? "Worktree mode project" : "Standard git project",
1888
+ details: {
1889
+ worktreeMode,
1890
+ projectPath,
1891
+ bareRepoPath
1892
+ }
1893
+ };
1894
+ } catch (error) {
1895
+ return {
1896
+ success: false,
1897
+ error: "UNKNOWN_ERROR",
1898
+ output: error.message || "Failed to get project info"
1899
+ };
1900
+ }
1901
+ }
1567
1902
  /**
1568
1903
  * Run a git command and return structured result
1569
1904
  */
@@ -1663,7 +1998,7 @@ var require_git_executor = __commonJS({
1663
1998
  }
1664
1999
  }
1665
2000
  };
1666
- exports2.GitExecutor = GitExecutor;
2001
+ exports2.GitExecutor = GitExecutor6;
1667
2002
  }
1668
2003
  });
1669
2004
 
@@ -1754,7 +2089,7 @@ var require_websocket_client = __commonJS({
1754
2089
  clearTimeout(this.reconnectTimeout);
1755
2090
  this.reconnectTimeout = void 0;
1756
2091
  }
1757
- return new Promise((resolve2, reject) => {
2092
+ return new Promise((resolve4, reject) => {
1758
2093
  const connectionTimeout = setTimeout(() => {
1759
2094
  if (this.ws) {
1760
2095
  this.ws.terminate();
@@ -1781,7 +2116,7 @@ var require_websocket_client = __commonJS({
1781
2116
  daemonPid: this.daemonPid
1782
2117
  });
1783
2118
  this.startHeartbeat();
1784
- resolve2();
2119
+ resolve4();
1785
2120
  });
1786
2121
  this.ws.on("pong", () => {
1787
2122
  if (this.heartbeatTimeoutTimer) {
@@ -1897,13 +2232,13 @@ var require_websocket_client = __commonJS({
1897
2232
  if (!this.ws || !this.isConnected) {
1898
2233
  throw new Error("WebSocket not connected");
1899
2234
  }
1900
- return new Promise((resolve2, reject) => {
2235
+ return new Promise((resolve4, reject) => {
1901
2236
  this.ws.send(JSON.stringify(message), (error) => {
1902
2237
  if (error) {
1903
2238
  console.error("[EpisodaClient] Failed to send message:", error);
1904
2239
  reject(error);
1905
2240
  } else {
1906
- resolve2();
2241
+ resolve4();
1907
2242
  }
1908
2243
  });
1909
2244
  });
@@ -2016,12 +2351,13 @@ var require_websocket_client = __commonJS({
2016
2351
  console.log(`[EpisodaClient] Server restarting, reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1}/7)`);
2017
2352
  }
2018
2353
  } else {
2019
- if (this.reconnectAttempts >= 1) {
2020
- console.error('[EpisodaClient] Connection lost. Retry failed. Check server status or restart with "episoda dev".');
2354
+ const MAX_NON_GRACEFUL_RETRIES = 5;
2355
+ if (this.reconnectAttempts >= MAX_NON_GRACEFUL_RETRIES) {
2356
+ console.error(`[EpisodaClient] Connection lost. Reconnection failed after ${MAX_NON_GRACEFUL_RETRIES} attempts. Check server status or restart with "episoda dev".`);
2021
2357
  shouldRetry = false;
2022
2358
  } else {
2023
- delay = 1e3;
2024
- console.log("[EpisodaClient] Connection lost, retrying in 1 second...");
2359
+ delay = Math.min(1e3 * Math.pow(2, this.reconnectAttempts), 16e3);
2360
+ console.log(`[EpisodaClient] Connection lost, retrying in ${delay / 1e3}s... (attempt ${this.reconnectAttempts + 1}/${MAX_NON_GRACEFUL_RETRIES})`);
2025
2361
  }
2026
2362
  }
2027
2363
  if (!shouldRetry) {
@@ -2129,36 +2465,36 @@ var require_auth = __commonJS({
2129
2465
  };
2130
2466
  })();
2131
2467
  Object.defineProperty(exports2, "__esModule", { value: true });
2132
- exports2.getConfigDir = getConfigDir4;
2468
+ exports2.getConfigDir = getConfigDir5;
2133
2469
  exports2.getConfigPath = getConfigPath4;
2134
- exports2.loadConfig = loadConfig3;
2135
- exports2.saveConfig = saveConfig4;
2470
+ exports2.loadConfig = loadConfig7;
2471
+ exports2.saveConfig = saveConfig5;
2136
2472
  exports2.validateToken = validateToken;
2137
- var fs6 = __importStar(require("fs"));
2138
- var path8 = __importStar(require("path"));
2473
+ var fs12 = __importStar(require("fs"));
2474
+ var path14 = __importStar(require("path"));
2139
2475
  var os3 = __importStar(require("os"));
2140
2476
  var child_process_1 = require("child_process");
2141
2477
  var DEFAULT_CONFIG_FILE = "config.json";
2142
- function getConfigDir4() {
2143
- return process.env.EPISODA_CONFIG_DIR || path8.join(os3.homedir(), ".episoda");
2478
+ function getConfigDir5() {
2479
+ return process.env.EPISODA_CONFIG_DIR || path14.join(os3.homedir(), ".episoda");
2144
2480
  }
2145
2481
  function getConfigPath4(configPath) {
2146
2482
  if (configPath) {
2147
2483
  return configPath;
2148
2484
  }
2149
- return path8.join(getConfigDir4(), DEFAULT_CONFIG_FILE);
2485
+ return path14.join(getConfigDir5(), DEFAULT_CONFIG_FILE);
2150
2486
  }
2151
2487
  function ensureConfigDir(configPath) {
2152
- const dir = path8.dirname(configPath);
2153
- const isNew = !fs6.existsSync(dir);
2488
+ const dir = path14.dirname(configPath);
2489
+ const isNew = !fs12.existsSync(dir);
2154
2490
  if (isNew) {
2155
- fs6.mkdirSync(dir, { recursive: true, mode: 448 });
2491
+ fs12.mkdirSync(dir, { recursive: true, mode: 448 });
2156
2492
  }
2157
2493
  if (process.platform === "darwin") {
2158
- const nosyncPath = path8.join(dir, ".nosync");
2159
- if (isNew || !fs6.existsSync(nosyncPath)) {
2494
+ const nosyncPath = path14.join(dir, ".nosync");
2495
+ if (isNew || !fs12.existsSync(nosyncPath)) {
2160
2496
  try {
2161
- fs6.writeFileSync(nosyncPath, "", { mode: 384 });
2497
+ fs12.writeFileSync(nosyncPath, "", { mode: 384 });
2162
2498
  (0, child_process_1.execSync)(`xattr -w com.apple.fileprovider.ignore 1 "${dir}"`, {
2163
2499
  stdio: "ignore",
2164
2500
  timeout: 5e3
@@ -2168,13 +2504,13 @@ var require_auth = __commonJS({
2168
2504
  }
2169
2505
  }
2170
2506
  }
2171
- async function loadConfig3(configPath) {
2507
+ async function loadConfig7(configPath) {
2172
2508
  const fullPath = getConfigPath4(configPath);
2173
- if (!fs6.existsSync(fullPath)) {
2509
+ if (!fs12.existsSync(fullPath)) {
2174
2510
  return null;
2175
2511
  }
2176
2512
  try {
2177
- const content = fs6.readFileSync(fullPath, "utf8");
2513
+ const content = fs12.readFileSync(fullPath, "utf8");
2178
2514
  const config = JSON.parse(content);
2179
2515
  return config;
2180
2516
  } catch (error) {
@@ -2182,12 +2518,12 @@ var require_auth = __commonJS({
2182
2518
  return null;
2183
2519
  }
2184
2520
  }
2185
- async function saveConfig4(config, configPath) {
2521
+ async function saveConfig5(config, configPath) {
2186
2522
  const fullPath = getConfigPath4(configPath);
2187
2523
  ensureConfigDir(fullPath);
2188
2524
  try {
2189
2525
  const content = JSON.stringify(config, null, 2);
2190
- fs6.writeFileSync(fullPath, content, { mode: 384 });
2526
+ fs12.writeFileSync(fullPath, content, { mode: 384 });
2191
2527
  } catch (error) {
2192
2528
  throw new Error(`Failed to save config: ${error instanceof Error ? error.message : String(error)}`);
2193
2529
  }
@@ -2227,6 +2563,11 @@ var require_errors = __commonJS({
2227
2563
  "BRANCH_ALREADY_EXISTS": "Branch already exists",
2228
2564
  "PUSH_REJECTED": "Push rejected by remote",
2229
2565
  "COMMAND_TIMEOUT": "Command timed out",
2566
+ // EP944: Worktree error messages
2567
+ "WORKTREE_EXISTS": "Worktree already exists at this path",
2568
+ "WORKTREE_NOT_FOUND": "Worktree not found at this path",
2569
+ "WORKTREE_LOCKED": "Worktree is locked",
2570
+ "BRANCH_IN_USE": "Branch is already checked out in another worktree",
2230
2571
  "UNKNOWN_ERROR": "Unknown error occurred"
2231
2572
  };
2232
2573
  let message = messages[code] || `Error: ${code}`;
@@ -2293,10 +2634,10 @@ var require_dist = __commonJS({
2293
2634
 
2294
2635
  // src/index.ts
2295
2636
  var import_commander = require("commander");
2296
- var import_core8 = __toESM(require_dist());
2637
+ var import_core15 = __toESM(require_dist());
2297
2638
 
2298
2639
  // src/commands/dev.ts
2299
- var import_core3 = __toESM(require_dist());
2640
+ var import_core6 = __toESM(require_dist());
2300
2641
 
2301
2642
  // src/framework-detector.ts
2302
2643
  var fs = __toESM(require("fs"));
@@ -2630,7 +2971,7 @@ async function startDaemon() {
2630
2971
  const pid = child.pid;
2631
2972
  const pidPath = getPidFilePath();
2632
2973
  fs2.writeFileSync(pidPath, pid.toString(), "utf-8");
2633
- await new Promise((resolve2) => setTimeout(resolve2, 500));
2974
+ await new Promise((resolve4) => setTimeout(resolve4, 500));
2634
2975
  const runningPid = isDaemonRunning();
2635
2976
  if (!runningPid) {
2636
2977
  throw new Error("Daemon failed to start");
@@ -2652,7 +2993,7 @@ async function stopDaemon(timeout = 5e3) {
2652
2993
  while (Date.now() - startTime < timeout) {
2653
2994
  try {
2654
2995
  process.kill(pid, 0);
2655
- await new Promise((resolve2) => setTimeout(resolve2, 100));
2996
+ await new Promise((resolve4) => setTimeout(resolve4, 100));
2656
2997
  } catch (error) {
2657
2998
  const pidPath2 = getPidFilePath();
2658
2999
  if (fs2.existsSync(pidPath2)) {
@@ -2682,7 +3023,7 @@ var import_core2 = __toESM(require_dist());
2682
3023
  var getSocketPath = () => path3.join((0, import_core2.getConfigDir)(), "daemon.sock");
2683
3024
  var DEFAULT_TIMEOUT = 15e3;
2684
3025
  async function sendCommand(command, params, timeout = DEFAULT_TIMEOUT) {
2685
- return new Promise((resolve2, reject) => {
3026
+ return new Promise((resolve4, reject) => {
2686
3027
  const socket = net.createConnection(getSocketPath());
2687
3028
  const requestId = crypto.randomUUID();
2688
3029
  let buffer = "";
@@ -2713,7 +3054,7 @@ async function sendCommand(command, params, timeout = DEFAULT_TIMEOUT) {
2713
3054
  clearTimeout(timeoutHandle);
2714
3055
  socket.end();
2715
3056
  if (response.success) {
2716
- resolve2(response.data);
3057
+ resolve4(response.data);
2717
3058
  } else {
2718
3059
  reject(new Error(response.error || "Command failed"));
2719
3060
  }
@@ -2769,40 +3110,889 @@ async function getDevServerStatus() {
2769
3110
  }
2770
3111
 
2771
3112
  // src/commands/dev.ts
2772
- var import_child_process2 = require("child_process");
2773
- var path4 = __toESM(require("path"));
3113
+ var import_child_process3 = require("child_process");
3114
+ var path7 = __toESM(require("path"));
3115
+ var fs6 = __toESM(require("fs"));
2774
3116
 
2775
3117
  // src/utils/port-check.ts
2776
3118
  var net2 = __toESM(require("net"));
2777
3119
  async function isPortInUse(port) {
2778
- return new Promise((resolve2) => {
3120
+ return new Promise((resolve4) => {
2779
3121
  const server = net2.createServer();
2780
3122
  server.once("error", (err) => {
2781
3123
  if (err.code === "EADDRINUSE") {
2782
- resolve2(true);
3124
+ resolve4(true);
2783
3125
  } else {
2784
- resolve2(false);
3126
+ resolve4(false);
3127
+ }
3128
+ });
3129
+ server.once("listening", () => {
3130
+ server.close();
3131
+ resolve4(false);
3132
+ });
3133
+ server.listen(port);
3134
+ });
3135
+ }
3136
+ function getServerPort() {
3137
+ if (process.env.PORT) {
3138
+ return parseInt(process.env.PORT, 10);
3139
+ }
3140
+ return 3e3;
3141
+ }
3142
+
3143
+ // src/daemon/worktree-manager.ts
3144
+ var fs3 = __toESM(require("fs"));
3145
+ var path4 = __toESM(require("path"));
3146
+ var import_core3 = __toESM(require_dist());
3147
+ function validateModuleUid(moduleUid) {
3148
+ if (!moduleUid || typeof moduleUid !== "string" || !moduleUid.trim()) {
3149
+ return false;
3150
+ }
3151
+ if (moduleUid.includes("/") || moduleUid.includes("\\") || moduleUid.includes("..")) {
3152
+ return false;
3153
+ }
3154
+ if (moduleUid.includes("\0")) {
3155
+ return false;
3156
+ }
3157
+ if (moduleUid.startsWith(".")) {
3158
+ return false;
3159
+ }
3160
+ return true;
3161
+ }
3162
+ var WorktreeManager = class _WorktreeManager {
3163
+ constructor(projectRoot) {
3164
+ // ============================================================
3165
+ // Private methods
3166
+ // ============================================================
3167
+ this.lockPath = "";
3168
+ this.projectRoot = projectRoot;
3169
+ this.bareRepoPath = path4.join(projectRoot, ".bare");
3170
+ this.configPath = path4.join(projectRoot, ".episoda", "config.json");
3171
+ this.gitExecutor = new import_core3.GitExecutor();
3172
+ }
3173
+ /**
3174
+ * Initialize worktree manager from existing project root
3175
+ * @returns true if valid worktree project, false otherwise
3176
+ */
3177
+ async initialize() {
3178
+ if (!fs3.existsSync(this.bareRepoPath)) {
3179
+ return false;
3180
+ }
3181
+ if (!fs3.existsSync(this.configPath)) {
3182
+ return false;
3183
+ }
3184
+ try {
3185
+ const config = this.readConfig();
3186
+ return config?.worktreeMode === true;
3187
+ } catch {
3188
+ return false;
3189
+ }
3190
+ }
3191
+ /**
3192
+ * Create a new worktree project from scratch
3193
+ */
3194
+ static async createProject(projectRoot, repoUrl, projectId, workspaceSlug, projectSlug) {
3195
+ const manager = new _WorktreeManager(projectRoot);
3196
+ const episodaDir = path4.join(projectRoot, ".episoda");
3197
+ fs3.mkdirSync(episodaDir, { recursive: true });
3198
+ const cloneResult = await manager.gitExecutor.execute({
3199
+ action: "clone_bare",
3200
+ url: repoUrl,
3201
+ path: manager.bareRepoPath
3202
+ });
3203
+ if (!cloneResult.success) {
3204
+ throw new Error(`Failed to clone repository: ${cloneResult.output}`);
3205
+ }
3206
+ const config = {
3207
+ projectId,
3208
+ workspaceSlug,
3209
+ projectSlug,
3210
+ bareRepoPath: manager.bareRepoPath,
3211
+ worktreeMode: true,
3212
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3213
+ worktrees: []
3214
+ };
3215
+ manager.writeConfig(config);
3216
+ return manager;
3217
+ }
3218
+ /**
3219
+ * Create a worktree for a module
3220
+ * The entire operation is locked to prevent race conditions
3221
+ */
3222
+ async createWorktree(moduleUid, branchName, createBranch = false) {
3223
+ if (!validateModuleUid(moduleUid)) {
3224
+ return {
3225
+ success: false,
3226
+ error: `Invalid module UID: "${moduleUid}" - contains disallowed characters`
3227
+ };
3228
+ }
3229
+ const worktreePath = path4.join(this.projectRoot, moduleUid);
3230
+ const lockAcquired = await this.acquireLock();
3231
+ if (!lockAcquired) {
3232
+ return {
3233
+ success: false,
3234
+ error: "Could not acquire lock for worktree creation"
3235
+ };
3236
+ }
3237
+ try {
3238
+ const existing = this.getWorktreeByModuleUid(moduleUid);
3239
+ if (existing) {
3240
+ return {
3241
+ success: true,
3242
+ worktreePath: existing.worktreePath,
3243
+ worktreeInfo: existing
3244
+ };
3245
+ }
3246
+ const result = await this.gitExecutor.execute({
3247
+ action: "worktree_add",
3248
+ path: worktreePath,
3249
+ branch: branchName,
3250
+ create: createBranch
3251
+ }, { cwd: this.bareRepoPath });
3252
+ if (!result.success) {
3253
+ return {
3254
+ success: false,
3255
+ error: result.output || "Failed to create worktree"
3256
+ };
3257
+ }
3258
+ const worktreeInfo = {
3259
+ moduleUid,
3260
+ branchName,
3261
+ worktreePath,
3262
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3263
+ lastAccessed: (/* @__PURE__ */ new Date()).toISOString()
3264
+ };
3265
+ const config = this.readConfig();
3266
+ if (config) {
3267
+ config.worktrees.push(worktreeInfo);
3268
+ this.writeConfig(config);
3269
+ }
3270
+ return {
3271
+ success: true,
3272
+ worktreePath,
3273
+ worktreeInfo
3274
+ };
3275
+ } finally {
3276
+ this.releaseLock();
3277
+ }
3278
+ }
3279
+ /**
3280
+ * Remove a worktree for a module
3281
+ * P1-2: Wrapped entire operation in lock to prevent race with createWorktree
3282
+ */
3283
+ async removeWorktree(moduleUid, force = false) {
3284
+ if (!validateModuleUid(moduleUid)) {
3285
+ return {
3286
+ success: false,
3287
+ error: `Invalid module UID: "${moduleUid}" - contains disallowed characters`
3288
+ };
3289
+ }
3290
+ const lockAcquired = await this.acquireLock();
3291
+ if (!lockAcquired) {
3292
+ return {
3293
+ success: false,
3294
+ error: "Could not acquire lock for worktree removal"
3295
+ };
3296
+ }
3297
+ try {
3298
+ const existing = this.getWorktreeByModuleUid(moduleUid);
3299
+ if (!existing) {
3300
+ return {
3301
+ success: false,
3302
+ error: `No worktree found for module ${moduleUid}`
3303
+ };
3304
+ }
3305
+ const result = await this.gitExecutor.execute({
3306
+ action: "worktree_remove",
3307
+ path: existing.worktreePath,
3308
+ force
3309
+ }, { cwd: this.bareRepoPath });
3310
+ if (!result.success) {
3311
+ return {
3312
+ success: false,
3313
+ error: result.output || "Failed to remove worktree"
3314
+ };
3315
+ }
3316
+ const config = this.readConfig();
3317
+ if (config) {
3318
+ config.worktrees = config.worktrees.filter((w) => w.moduleUid !== moduleUid);
3319
+ this.writeConfig(config);
3320
+ }
3321
+ return {
3322
+ success: true,
3323
+ worktreePath: existing.worktreePath
3324
+ };
3325
+ } finally {
3326
+ this.releaseLock();
3327
+ }
3328
+ }
3329
+ /**
3330
+ * Get worktree path for a module
3331
+ */
3332
+ getWorktreePath(moduleUid) {
3333
+ if (!validateModuleUid(moduleUid)) {
3334
+ return null;
3335
+ }
3336
+ const worktree = this.getWorktreeByModuleUid(moduleUid);
3337
+ return worktree?.worktreePath || null;
3338
+ }
3339
+ /**
3340
+ * Get worktree info by module UID
3341
+ */
3342
+ getWorktreeByModuleUid(moduleUid) {
3343
+ const config = this.readConfig();
3344
+ return config?.worktrees.find((w) => w.moduleUid === moduleUid) || null;
3345
+ }
3346
+ /**
3347
+ * Get worktree info by branch name
3348
+ */
3349
+ getWorktreeByBranch(branchName) {
3350
+ const config = this.readConfig();
3351
+ return config?.worktrees.find((w) => w.branchName === branchName) || null;
3352
+ }
3353
+ /**
3354
+ * List all active worktrees
3355
+ */
3356
+ listWorktrees() {
3357
+ const config = this.readConfig();
3358
+ return config?.worktrees || [];
3359
+ }
3360
+ /**
3361
+ * EP957: Audit worktrees against known active module UIDs
3362
+ *
3363
+ * Compares local worktrees against a list of module UIDs that should be active.
3364
+ * Used by daemon startup to detect orphaned worktrees from crashed sessions.
3365
+ *
3366
+ * @param activeModuleUids - UIDs of modules currently in doing/review state
3367
+ * @returns Object with orphaned and valid worktree lists
3368
+ */
3369
+ auditWorktrees(activeModuleUids) {
3370
+ const allWorktrees = this.listWorktrees();
3371
+ const activeSet = new Set(activeModuleUids);
3372
+ const orphaned = allWorktrees.filter((w) => !activeSet.has(w.moduleUid));
3373
+ const valid = allWorktrees.filter((w) => activeSet.has(w.moduleUid));
3374
+ return { orphaned, valid };
3375
+ }
3376
+ /**
3377
+ * Update last accessed timestamp for a worktree
3378
+ */
3379
+ async touchWorktree(moduleUid) {
3380
+ await this.updateConfigSafe((config) => {
3381
+ const worktree = config.worktrees.find((w) => w.moduleUid === moduleUid);
3382
+ if (worktree) {
3383
+ worktree.lastAccessed = (/* @__PURE__ */ new Date()).toISOString();
3384
+ }
3385
+ return config;
3386
+ });
3387
+ }
3388
+ /**
3389
+ * Prune stale worktrees (directories that no longer exist)
3390
+ */
3391
+ async pruneStaleWorktrees() {
3392
+ await this.gitExecutor.execute({
3393
+ action: "worktree_prune"
3394
+ }, { cwd: this.bareRepoPath });
3395
+ let prunedCount = 0;
3396
+ await this.updateConfigSafe((config) => {
3397
+ const initialCount = config.worktrees.length;
3398
+ config.worktrees = config.worktrees.filter((w) => fs3.existsSync(w.worktreePath));
3399
+ prunedCount = initialCount - config.worktrees.length;
3400
+ return config;
3401
+ });
3402
+ return prunedCount;
3403
+ }
3404
+ /**
3405
+ * Validate all worktrees and sync with git
3406
+ */
3407
+ async validateWorktrees() {
3408
+ const config = this.readConfig();
3409
+ const valid = [];
3410
+ const stale = [];
3411
+ const orphaned = [];
3412
+ const listResult = await this.gitExecutor.execute({
3413
+ action: "worktree_list"
3414
+ }, { cwd: this.bareRepoPath });
3415
+ const actualWorktrees = new Set(
3416
+ listResult.details?.worktrees?.map((w) => w.path) || []
3417
+ );
3418
+ for (const worktree of config?.worktrees || []) {
3419
+ if (actualWorktrees.has(worktree.worktreePath)) {
3420
+ valid.push(worktree);
3421
+ actualWorktrees.delete(worktree.worktreePath);
3422
+ } else {
3423
+ stale.push(worktree);
3424
+ }
3425
+ }
3426
+ for (const wpath of actualWorktrees) {
3427
+ if (wpath !== this.bareRepoPath) {
3428
+ orphaned.push(wpath);
3429
+ }
3430
+ }
3431
+ return { valid, stale, orphaned };
3432
+ }
3433
+ /**
3434
+ * Get project configuration
3435
+ */
3436
+ getConfig() {
3437
+ return this.readConfig();
3438
+ }
3439
+ /**
3440
+ * Get the bare repo path
3441
+ */
3442
+ getBareRepoPath() {
3443
+ return this.bareRepoPath;
3444
+ }
3445
+ /**
3446
+ * Get the project root path
3447
+ */
3448
+ getProjectRoot() {
3449
+ return this.projectRoot;
3450
+ }
3451
+ getLockPath() {
3452
+ if (!this.lockPath) {
3453
+ this.lockPath = this.configPath + ".lock";
3454
+ }
3455
+ return this.lockPath;
3456
+ }
3457
+ /**
3458
+ * Check if a process is still running
3459
+ */
3460
+ isProcessRunning(pid) {
3461
+ try {
3462
+ process.kill(pid, 0);
3463
+ return true;
3464
+ } catch {
3465
+ return false;
3466
+ }
3467
+ }
3468
+ /**
3469
+ * Acquire a file lock with timeout
3470
+ * Uses atomic file creation to ensure only one process holds the lock
3471
+ * P1-1: Added PID verification before removing stale locks to prevent race conditions
3472
+ */
3473
+ async acquireLock(timeoutMs = 5e3) {
3474
+ const lockPath = this.getLockPath();
3475
+ const startTime = Date.now();
3476
+ const retryInterval = 50;
3477
+ while (Date.now() - startTime < timeoutMs) {
3478
+ try {
3479
+ fs3.writeFileSync(lockPath, String(process.pid), { flag: "wx" });
3480
+ return true;
3481
+ } catch (err) {
3482
+ if (err.code === "EEXIST") {
3483
+ try {
3484
+ const stats = fs3.statSync(lockPath);
3485
+ const lockAge = Date.now() - stats.mtimeMs;
3486
+ if (lockAge > 3e4) {
3487
+ try {
3488
+ const lockContent = fs3.readFileSync(lockPath, "utf-8").trim();
3489
+ const lockPid = parseInt(lockContent, 10);
3490
+ if (!isNaN(lockPid) && this.isProcessRunning(lockPid)) {
3491
+ await new Promise((resolve4) => setTimeout(resolve4, retryInterval));
3492
+ continue;
3493
+ }
3494
+ } catch {
3495
+ }
3496
+ try {
3497
+ fs3.unlinkSync(lockPath);
3498
+ } catch {
3499
+ }
3500
+ continue;
3501
+ }
3502
+ } catch {
3503
+ continue;
3504
+ }
3505
+ await new Promise((resolve4) => setTimeout(resolve4, retryInterval));
3506
+ continue;
3507
+ }
3508
+ throw err;
3509
+ }
3510
+ }
3511
+ return false;
3512
+ }
3513
+ /**
3514
+ * Release the file lock
3515
+ */
3516
+ releaseLock() {
3517
+ try {
3518
+ fs3.unlinkSync(this.getLockPath());
3519
+ } catch {
3520
+ }
3521
+ }
3522
+ readConfig() {
3523
+ try {
3524
+ if (!fs3.existsSync(this.configPath)) {
3525
+ return null;
3526
+ }
3527
+ const content = fs3.readFileSync(this.configPath, "utf-8");
3528
+ return JSON.parse(content);
3529
+ } catch (error) {
3530
+ console.error("[WorktreeManager] Failed to read config:", error);
3531
+ return null;
3532
+ }
3533
+ }
3534
+ writeConfig(config) {
3535
+ try {
3536
+ const dir = path4.dirname(this.configPath);
3537
+ if (!fs3.existsSync(dir)) {
3538
+ fs3.mkdirSync(dir, { recursive: true });
3539
+ }
3540
+ fs3.writeFileSync(this.configPath, JSON.stringify(config, null, 2), "utf-8");
3541
+ } catch (error) {
3542
+ console.error("[WorktreeManager] Failed to write config:", error);
3543
+ throw error;
3544
+ }
3545
+ }
3546
+ /**
3547
+ * Read-modify-write with file locking for safe concurrent access
3548
+ */
3549
+ async updateConfigSafe(updater) {
3550
+ const lockAcquired = await this.acquireLock();
3551
+ if (!lockAcquired) {
3552
+ console.error("[WorktreeManager] Failed to acquire lock for config update");
3553
+ return false;
3554
+ }
3555
+ try {
3556
+ const config = this.readConfig();
3557
+ if (!config) {
3558
+ return false;
3559
+ }
3560
+ const updated = updater(config);
3561
+ this.writeConfig(updated);
3562
+ return true;
3563
+ } finally {
3564
+ this.releaseLock();
3565
+ }
3566
+ }
3567
+ };
3568
+ function getEpisodaRoot() {
3569
+ return process.env.EPISODA_ROOT || path4.join(require("os").homedir(), "episoda");
3570
+ }
3571
+ function getProjectPath(workspaceSlug, projectSlug) {
3572
+ return path4.join(getEpisodaRoot(), workspaceSlug, projectSlug);
3573
+ }
3574
+ async function isWorktreeProject(projectRoot) {
3575
+ const manager = new WorktreeManager(projectRoot);
3576
+ return manager.initialize();
3577
+ }
3578
+ async function findProjectRoot(startPath) {
3579
+ let current = path4.resolve(startPath);
3580
+ const episodaRoot = getEpisodaRoot();
3581
+ if (!current.startsWith(episodaRoot)) {
3582
+ return null;
3583
+ }
3584
+ for (let i = 0; i < 10; i++) {
3585
+ const bareDir = path4.join(current, ".bare");
3586
+ const episodaDir = path4.join(current, ".episoda");
3587
+ if (fs3.existsSync(bareDir) && fs3.existsSync(episodaDir)) {
3588
+ if (await isWorktreeProject(current)) {
3589
+ return current;
3590
+ }
3591
+ }
3592
+ const parent = path4.dirname(current);
3593
+ if (parent === current) {
3594
+ break;
3595
+ }
3596
+ current = parent;
3597
+ }
3598
+ return null;
3599
+ }
3600
+
3601
+ // src/commands/migrate.ts
3602
+ var fs5 = __toESM(require("fs"));
3603
+ var path6 = __toESM(require("path"));
3604
+ var import_child_process2 = require("child_process");
3605
+ var import_core5 = __toESM(require_dist());
3606
+
3607
+ // src/daemon/project-tracker.ts
3608
+ var fs4 = __toESM(require("fs"));
3609
+ var path5 = __toESM(require("path"));
3610
+ var import_core4 = __toESM(require_dist());
3611
+ function getProjectsFilePath() {
3612
+ return path5.join((0, import_core4.getConfigDir)(), "projects.json");
3613
+ }
3614
+ function readProjects() {
3615
+ const projectsPath = getProjectsFilePath();
3616
+ try {
3617
+ if (!fs4.existsSync(projectsPath)) {
3618
+ return { projects: [] };
3619
+ }
3620
+ const content = fs4.readFileSync(projectsPath, "utf-8");
3621
+ const data = JSON.parse(content);
3622
+ if (!data.projects || !Array.isArray(data.projects)) {
3623
+ console.warn("Invalid projects.json structure, resetting");
3624
+ return { projects: [] };
3625
+ }
3626
+ return data;
3627
+ } catch (error) {
3628
+ console.error("Error reading projects.json:", error);
3629
+ return { projects: [] };
3630
+ }
3631
+ }
3632
+ function writeProjects(data) {
3633
+ const projectsPath = getProjectsFilePath();
3634
+ try {
3635
+ const dir = path5.dirname(projectsPath);
3636
+ if (!fs4.existsSync(dir)) {
3637
+ fs4.mkdirSync(dir, { recursive: true });
3638
+ }
3639
+ fs4.writeFileSync(projectsPath, JSON.stringify(data, null, 2), "utf-8");
3640
+ } catch (error) {
3641
+ throw new Error(`Failed to write projects.json: ${error}`);
3642
+ }
3643
+ }
3644
+ function addProject2(projectId, projectPath, options) {
3645
+ const data = readProjects();
3646
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3647
+ const existingByPath = data.projects.find((p) => p.path === projectPath);
3648
+ if (existingByPath) {
3649
+ existingByPath.id = projectId;
3650
+ existingByPath.last_active = now;
3651
+ if (options?.worktreeMode !== void 0) {
3652
+ existingByPath.worktreeMode = options.worktreeMode;
3653
+ }
3654
+ if (options?.bareRepoPath) {
3655
+ existingByPath.bareRepoPath = options.bareRepoPath;
3656
+ }
3657
+ writeProjects(data);
3658
+ return existingByPath;
3659
+ }
3660
+ const existingByIdIndex = data.projects.findIndex((p) => p.id === projectId);
3661
+ if (existingByIdIndex !== -1) {
3662
+ const existingById = data.projects[existingByIdIndex];
3663
+ console.log(`[ProjectTracker] Replacing project entry: ${existingById.path} -> ${projectPath}`);
3664
+ data.projects.splice(existingByIdIndex, 1);
3665
+ }
3666
+ const projectName = path5.basename(projectPath);
3667
+ const newProject = {
3668
+ id: projectId,
3669
+ path: projectPath,
3670
+ name: projectName,
3671
+ added_at: now,
3672
+ last_active: now,
3673
+ // EP944: Worktree mode fields
3674
+ worktreeMode: options?.worktreeMode,
3675
+ bareRepoPath: options?.bareRepoPath
3676
+ };
3677
+ data.projects.push(newProject);
3678
+ writeProjects(data);
3679
+ return newProject;
3680
+ }
3681
+ function getProject(projectPath) {
3682
+ const data = readProjects();
3683
+ return data.projects.find((p) => p.path === projectPath) || null;
3684
+ }
3685
+
3686
+ // src/commands/migrate.ts
3687
+ async function migrateCommand(options = {}) {
3688
+ const cwd = options.cwd || process.cwd();
3689
+ status.info("Checking current project...");
3690
+ status.info("");
3691
+ if (!fs5.existsSync(path6.join(cwd, ".git"))) {
3692
+ throw new Error(
3693
+ "Not a git repository.\nRun this command from the root of an existing git project."
3694
+ );
3695
+ }
3696
+ const bareDir = path6.join(cwd, ".bare");
3697
+ if (fs5.existsSync(bareDir)) {
3698
+ throw new Error(
3699
+ "This project is already in worktree mode.\nUse `episoda checkout {module}` to create worktrees."
3700
+ );
3701
+ }
3702
+ const config = await (0, import_core5.loadConfig)();
3703
+ if (!config || !config.access_token) {
3704
+ throw new Error(
3705
+ "Not authenticated. Please run `episoda auth` first."
3706
+ );
3707
+ }
3708
+ const episodaConfigPath = path6.join(cwd, ".episoda", "config.json");
3709
+ if (!fs5.existsSync(episodaConfigPath)) {
3710
+ throw new Error(
3711
+ "No .episoda/config.json found.\nThis project is not connected to episoda.dev.\nUse `episoda clone {workspace}/{project}` to clone a fresh copy."
3712
+ );
3713
+ }
3714
+ let existingConfig;
3715
+ try {
3716
+ existingConfig = JSON.parse(fs5.readFileSync(episodaConfigPath, "utf-8"));
3717
+ } catch (error) {
3718
+ throw new Error("Failed to read .episoda/config.json");
3719
+ }
3720
+ const trackedProject = getProject(cwd);
3721
+ const projectId = trackedProject?.id || existingConfig.project_id;
3722
+ if (!projectId) {
3723
+ throw new Error(
3724
+ "Cannot determine project ID.\nUse `episoda clone {workspace}/{project}` for a fresh clone."
3725
+ );
3726
+ }
3727
+ const gitExecutor = new import_core5.GitExecutor();
3728
+ let currentBranch;
3729
+ try {
3730
+ currentBranch = (0, import_child_process2.execSync)("git branch --show-current", {
3731
+ cwd,
3732
+ encoding: "utf-8"
3733
+ }).trim();
3734
+ } catch {
3735
+ throw new Error("Failed to determine current branch");
3736
+ }
3737
+ const statusResult = await gitExecutor.execute({ action: "status" }, { cwd });
3738
+ const hasUncommitted = (statusResult.details?.uncommittedFiles?.length || 0) > 0;
3739
+ if (hasUncommitted && !options.force) {
3740
+ status.warning("You have uncommitted changes:");
3741
+ for (const file of statusResult.details?.uncommittedFiles || []) {
3742
+ status.info(` - ${file}`);
3743
+ }
3744
+ status.info("");
3745
+ throw new Error(
3746
+ "Please commit or stash your changes first.\nOr use --force to stash changes automatically."
3747
+ );
3748
+ }
3749
+ let remoteUrl;
3750
+ try {
3751
+ remoteUrl = (0, import_child_process2.execSync)("git remote get-url origin", {
3752
+ cwd,
3753
+ encoding: "utf-8"
3754
+ }).trim();
3755
+ } catch {
3756
+ throw new Error(
3757
+ 'No git remote "origin" found.\nPlease configure a remote before migrating.'
3758
+ );
3759
+ }
3760
+ let workspaceSlug = existingConfig.workspace_slug || "default";
3761
+ let projectSlug = existingConfig.project_slug || path6.basename(cwd);
3762
+ try {
3763
+ const apiUrl = config.api_url || "https://episoda.dev";
3764
+ const response = await fetch(`${apiUrl}/api/projects/${projectId}`, {
3765
+ headers: {
3766
+ "Authorization": `Bearer ${config.access_token}`,
3767
+ "Content-Type": "application/json"
3768
+ }
3769
+ });
3770
+ if (response.ok) {
3771
+ const responseData = await response.json();
3772
+ const projectData = responseData.data?.project;
3773
+ if (projectData) {
3774
+ workspaceSlug = projectData.workspace_slug || workspaceSlug;
3775
+ projectSlug = projectData.slug || projectSlug;
3776
+ }
3777
+ }
3778
+ } catch {
3779
+ }
3780
+ const targetPath = getProjectPath(workspaceSlug, projectSlug);
3781
+ status.info("Migration Plan:");
3782
+ status.info(` Current path: ${cwd}`);
3783
+ status.info(` Target path: ${targetPath}`);
3784
+ status.info(` Remote URL: ${remoteUrl}`);
3785
+ status.info(` Current branch: ${currentBranch}`);
3786
+ if (hasUncommitted) {
3787
+ status.warning(` Uncommitted changes will be stashed`);
3788
+ }
3789
+ status.info("");
3790
+ if (options.dryRun) {
3791
+ status.info("Dry run complete. No changes made.");
3792
+ status.info("Remove --dry-run to perform the migration.");
3793
+ return;
3794
+ }
3795
+ const uncommittedFiles = statusResult.details?.uncommittedFiles || [];
3796
+ if (hasUncommitted) {
3797
+ status.info(`Found ${uncommittedFiles.length} uncommitted file(s) to preserve...`);
3798
+ }
3799
+ const targetExistedBefore = fs5.existsSync(targetPath);
3800
+ try {
3801
+ status.info("Creating target directory...");
3802
+ const episodaRoot = getEpisodaRoot();
3803
+ fs5.mkdirSync(targetPath, { recursive: true });
3804
+ status.info("Cloning as bare repository...");
3805
+ const bareRepoPath = path6.join(targetPath, ".bare");
3806
+ const cloneResult = await gitExecutor.execute({
3807
+ action: "clone_bare",
3808
+ url: remoteUrl,
3809
+ path: bareRepoPath
3810
+ });
3811
+ if (!cloneResult.success) {
3812
+ throw new Error(`Failed to clone: ${cloneResult.output}`);
3813
+ }
3814
+ status.success("\u2713 Bare repository cloned");
3815
+ status.info("Creating worktree configuration...");
3816
+ const episodaDir = path6.join(targetPath, ".episoda");
3817
+ fs5.mkdirSync(episodaDir, { recursive: true });
3818
+ const worktreeConfig = {
3819
+ projectId,
3820
+ workspaceSlug,
3821
+ projectSlug,
3822
+ bareRepoPath,
3823
+ worktreeMode: true,
3824
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3825
+ worktrees: []
3826
+ };
3827
+ fs5.writeFileSync(
3828
+ path6.join(episodaDir, "config.json"),
3829
+ JSON.stringify(worktreeConfig, null, 2),
3830
+ "utf-8"
3831
+ );
3832
+ status.info(`Creating worktree for branch "${currentBranch}"...`);
3833
+ const moduleUid = extractModuleUid(currentBranch) || "main";
3834
+ const worktreePath = path6.join(targetPath, moduleUid);
3835
+ const worktreeResult = await gitExecutor.execute({
3836
+ action: "worktree_add",
3837
+ path: worktreePath,
3838
+ branch: currentBranch,
3839
+ create: false
3840
+ }, { cwd: bareRepoPath });
3841
+ if (!worktreeResult.success) {
3842
+ throw new Error(`Failed to create worktree: ${worktreeResult.output}`);
3843
+ }
3844
+ status.success(`\u2713 Worktree created at ${worktreePath}`);
3845
+ worktreeConfig.worktrees.push({
3846
+ moduleUid,
3847
+ branchName: currentBranch,
3848
+ worktreePath,
3849
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3850
+ lastAccessed: (/* @__PURE__ */ new Date()).toISOString()
3851
+ });
3852
+ fs5.writeFileSync(
3853
+ path6.join(episodaDir, "config.json"),
3854
+ JSON.stringify(worktreeConfig, null, 2),
3855
+ "utf-8"
3856
+ );
3857
+ if (hasUncommitted && uncommittedFiles.length > 0) {
3858
+ status.info("Copying uncommitted changes to new worktree...");
3859
+ let copiedCount = 0;
3860
+ let failedCount = 0;
3861
+ for (const file of uncommittedFiles) {
3862
+ const sourcePath = path6.join(cwd, file);
3863
+ const destPath = path6.join(worktreePath, file);
3864
+ try {
3865
+ if (fs5.existsSync(sourcePath)) {
3866
+ const destDir = path6.dirname(destPath);
3867
+ if (!fs5.existsSync(destDir)) {
3868
+ fs5.mkdirSync(destDir, { recursive: true });
3869
+ }
3870
+ fs5.copyFileSync(sourcePath, destPath);
3871
+ copiedCount++;
3872
+ }
3873
+ } catch (copyError) {
3874
+ failedCount++;
3875
+ status.warning(`Could not copy: ${file}`);
3876
+ }
3877
+ }
3878
+ if (copiedCount > 0) {
3879
+ status.success(`\u2713 Copied ${copiedCount} uncommitted file(s)`);
3880
+ }
3881
+ if (failedCount > 0) {
3882
+ status.warning(`${failedCount} file(s) could not be copied`);
3883
+ status.info("You may need to copy some files manually from the original directory");
3884
+ }
3885
+ }
3886
+ addProject2(projectId, targetPath, {
3887
+ worktreeMode: true,
3888
+ bareRepoPath
3889
+ });
3890
+ status.success("\u2713 Project registered with daemon");
3891
+ try {
3892
+ const apiUrl = config.api_url || "https://episoda.dev";
3893
+ const settingsResponse = await fetch(`${apiUrl}/api/projects/${projectId}/settings`, {
3894
+ method: "PATCH",
3895
+ headers: {
3896
+ "Authorization": `Bearer ${config.access_token}`,
3897
+ "Content-Type": "application/json"
3898
+ },
3899
+ body: JSON.stringify({
3900
+ local_project_path: worktreePath
3901
+ })
3902
+ });
3903
+ if (settingsResponse.ok) {
3904
+ status.success("\u2713 Project settings updated");
3905
+ } else {
3906
+ status.warning("Could not update project settings in database");
3907
+ }
3908
+ } catch {
3909
+ status.warning("Could not update project settings in database");
3910
+ }
3911
+ try {
3912
+ const updatedConfig = {
3913
+ ...config,
3914
+ project_settings: {
3915
+ ...config.project_settings,
3916
+ local_project_path: worktreePath,
3917
+ cached_at: Date.now()
3918
+ }
3919
+ };
3920
+ await (0, import_core5.saveConfig)(updatedConfig);
3921
+ status.success("\u2713 Local config updated");
3922
+ } catch {
3923
+ status.warning("Could not update local config cache");
3924
+ }
3925
+ status.info("");
3926
+ status.success("Migration complete!");
3927
+ status.info("");
3928
+ status.info("New project structure:");
3929
+ status.info(` ${targetPath}/`);
3930
+ status.info(" \u251C\u2500\u2500 .bare/ # Git repository");
3931
+ status.info(" \u251C\u2500\u2500 .episoda/");
3932
+ status.info(" \u2502 \u2514\u2500\u2500 config.json");
3933
+ status.info(` \u2514\u2500\u2500 ${moduleUid}/ # Your current worktree`);
3934
+ status.info("");
3935
+ status.info("Next steps:");
3936
+ status.info(` 1. cd ${worktreePath}`);
3937
+ status.info(" 2. Continue working on your current branch");
3938
+ status.info(" 3. Use `episoda checkout EP###` for other modules");
3939
+ status.info("");
3940
+ status.info("Your original repository at:");
3941
+ status.info(` ${cwd}`);
3942
+ status.info("can be safely removed after verifying the migration.");
3943
+ status.info("");
3944
+ } catch (error) {
3945
+ status.error("Migration failed, cleaning up...");
3946
+ if (!targetExistedBefore && fs5.existsSync(targetPath)) {
3947
+ try {
3948
+ fs5.rmSync(targetPath, { recursive: true, force: true });
3949
+ status.info(`Removed ${targetPath}`);
3950
+ } catch {
3951
+ status.warning(`Could not remove ${targetPath}`);
3952
+ }
3953
+ } else if (targetExistedBefore) {
3954
+ status.info("Target directory existed before migration, only removing migration artifacts...");
3955
+ const bareRepoPath = path6.join(targetPath, ".bare");
3956
+ const episodaDir = path6.join(targetPath, ".episoda");
3957
+ if (fs5.existsSync(bareRepoPath)) {
3958
+ try {
3959
+ fs5.rmSync(bareRepoPath, { recursive: true, force: true });
3960
+ status.info("Removed .bare directory");
3961
+ } catch {
3962
+ status.warning("Could not remove .bare directory");
3963
+ }
2785
3964
  }
2786
- });
2787
- server.once("listening", () => {
2788
- server.close();
2789
- resolve2(false);
2790
- });
2791
- server.listen(port);
2792
- });
3965
+ if (fs5.existsSync(episodaDir)) {
3966
+ try {
3967
+ const configPath = path6.join(episodaDir, "config.json");
3968
+ if (fs5.existsSync(configPath)) {
3969
+ const config2 = JSON.parse(fs5.readFileSync(configPath, "utf-8"));
3970
+ if (config2.worktreeMode) {
3971
+ fs5.rmSync(episodaDir, { recursive: true, force: true });
3972
+ status.info("Removed .episoda directory");
3973
+ }
3974
+ }
3975
+ } catch {
3976
+ status.warning("Could not remove .episoda directory");
3977
+ }
3978
+ }
3979
+ }
3980
+ throw error;
3981
+ }
2793
3982
  }
2794
- function getServerPort() {
2795
- if (process.env.PORT) {
2796
- return parseInt(process.env.PORT, 10);
3983
+ function extractModuleUid(branchName) {
3984
+ const match = branchName.match(/ep(\d+)/i);
3985
+ if (match) {
3986
+ return `EP${match[1]}`;
2797
3987
  }
2798
- return 3e3;
3988
+ return null;
2799
3989
  }
2800
3990
 
2801
3991
  // src/commands/dev.ts
2802
3992
  var CONNECTION_MAX_RETRIES = 3;
2803
3993
  function findGitRoot(startDir) {
2804
3994
  try {
2805
- const result = (0, import_child_process2.execSync)("git rev-parse --show-toplevel", {
3995
+ const result = (0, import_child_process3.execSync)("git rev-parse --show-toplevel", {
2806
3996
  cwd: startDir,
2807
3997
  encoding: "utf-8",
2808
3998
  stdio: ["pipe", "pipe", "pipe"]
@@ -2814,7 +4004,7 @@ function findGitRoot(startDir) {
2814
4004
  }
2815
4005
  async function devCommand(options = {}) {
2816
4006
  try {
2817
- const config = await (0, import_core3.loadConfig)();
4007
+ const config = await (0, import_core6.loadConfig)();
2818
4008
  if (!config || !config.access_token || !config.project_id) {
2819
4009
  status.error("No authentication found. Please run:");
2820
4010
  status.info("");
@@ -2853,7 +4043,7 @@ async function devCommand(options = {}) {
2853
4043
  const killedCount = killAllEpisodaProcesses();
2854
4044
  if (killedCount > 0) {
2855
4045
  status.info(`Cleaned up ${killedCount} stale process${killedCount > 1 ? "es" : ""}`);
2856
- await new Promise((resolve2) => setTimeout(resolve2, 2e3));
4046
+ await new Promise((resolve4) => setTimeout(resolve4, 2e3));
2857
4047
  }
2858
4048
  }
2859
4049
  const serverUrl = config.api_url || process.env.EPISODA_API_URL || "https://episoda.dev";
@@ -2866,7 +4056,7 @@ async function devCommand(options = {}) {
2866
4056
  status.debug(`Using cached project path: ${projectPath}`);
2867
4057
  } else {
2868
4058
  const detectedRoot = findGitRoot(options.cwd || process.cwd());
2869
- projectPath = detectedRoot || path4.resolve(options.cwd || process.cwd());
4059
+ projectPath = detectedRoot || path7.resolve(options.cwd || process.cwd());
2870
4060
  if (detectedRoot) {
2871
4061
  status.debug(`Detected project root: ${projectPath}`);
2872
4062
  } else {
@@ -2880,7 +4070,7 @@ async function devCommand(options = {}) {
2880
4070
  cached_at: Date.now()
2881
4071
  }
2882
4072
  };
2883
- await (0, import_core3.saveConfig)(updatedConfig);
4073
+ await (0, import_core6.saveConfig)(updatedConfig);
2884
4074
  status.debug("Cached project settings locally");
2885
4075
  const settingsUrl = `${serverUrl}/api/projects/${config.project_id}/settings`;
2886
4076
  fetch(settingsUrl, {
@@ -2893,6 +4083,23 @@ async function devCommand(options = {}) {
2893
4083
  }).catch(() => {
2894
4084
  });
2895
4085
  }
4086
+ const hasGitDir = fs6.existsSync(path7.join(projectPath, ".git"));
4087
+ const isWorktree = await isWorktreeProject(projectPath);
4088
+ if (hasGitDir && !isWorktree) {
4089
+ status.info("");
4090
+ status.info("EP944: Migrating project to worktree mode...");
4091
+ status.info("This is a one-time operation that enables multi-module development.");
4092
+ status.info("");
4093
+ try {
4094
+ await migrateCommand({ cwd: projectPath, silent: false });
4095
+ status.success("Project migrated to worktree mode!");
4096
+ status.info("");
4097
+ } catch (error) {
4098
+ status.warning(`Migration skipped: ${error.message}`);
4099
+ status.info("You can run `episoda migrate` manually later.");
4100
+ status.info("");
4101
+ }
4102
+ }
2896
4103
  let daemonPid = isDaemonRunning();
2897
4104
  if (!daemonPid) {
2898
4105
  status.info("Starting Episoda daemon...");
@@ -2917,7 +4124,7 @@ async function devCommand(options = {}) {
2917
4124
  for (let retry = 0; retry < CONNECTION_MAX_RETRIES && !connected; retry++) {
2918
4125
  if (retry > 0) {
2919
4126
  status.info(`Retrying connection (attempt ${retry + 1}/${CONNECTION_MAX_RETRIES})...`);
2920
- await new Promise((resolve2) => setTimeout(resolve2, 1e3));
4127
+ await new Promise((resolve4) => setTimeout(resolve4, 1e3));
2921
4128
  }
2922
4129
  try {
2923
4130
  const result = await addProject(config.project_id, projectPath);
@@ -2985,7 +4192,7 @@ async function runDevServer(command, cwd, autoRestart) {
2985
4192
  let shuttingDown = false;
2986
4193
  const startServer = () => {
2987
4194
  status.info(`Starting dev server: ${command.join(" ")}`);
2988
- devProcess = (0, import_child_process2.spawn)(command[0], command.slice(1), {
4195
+ devProcess = (0, import_child_process3.spawn)(command[0], command.slice(1), {
2989
4196
  cwd,
2990
4197
  stdio: ["inherit", "inherit", "inherit"],
2991
4198
  shell: true
@@ -3038,33 +4245,33 @@ Received ${signal}, shutting down...`);
3038
4245
 
3039
4246
  // src/commands/auth.ts
3040
4247
  var os = __toESM(require("os"));
3041
- var fs4 = __toESM(require("fs"));
3042
- var path6 = __toESM(require("path"));
3043
- var import_child_process4 = require("child_process");
3044
- var import_core5 = __toESM(require_dist());
4248
+ var fs8 = __toESM(require("fs"));
4249
+ var path9 = __toESM(require("path"));
4250
+ var import_child_process5 = require("child_process");
4251
+ var import_core8 = __toESM(require_dist());
3045
4252
 
3046
4253
  // src/daemon/machine-id.ts
3047
- var fs3 = __toESM(require("fs"));
3048
- var path5 = __toESM(require("path"));
4254
+ var fs7 = __toESM(require("fs"));
4255
+ var path8 = __toESM(require("path"));
3049
4256
  var crypto2 = __toESM(require("crypto"));
3050
- var import_child_process3 = require("child_process");
3051
- var import_core4 = __toESM(require_dist());
4257
+ var import_child_process4 = require("child_process");
4258
+ var import_core7 = __toESM(require_dist());
3052
4259
  function isValidUUID(str) {
3053
4260
  const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3054
4261
  return uuidRegex.test(str);
3055
4262
  }
3056
4263
  async function getMachineId() {
3057
- const machineIdPath = path5.join((0, import_core4.getConfigDir)(), "machine-id");
4264
+ const machineIdPath = path8.join((0, import_core7.getConfigDir)(), "machine-id");
3058
4265
  try {
3059
- if (fs3.existsSync(machineIdPath)) {
3060
- const existingId = fs3.readFileSync(machineIdPath, "utf-8").trim();
4266
+ if (fs7.existsSync(machineIdPath)) {
4267
+ const existingId = fs7.readFileSync(machineIdPath, "utf-8").trim();
3061
4268
  if (existingId) {
3062
4269
  if (isValidUUID(existingId)) {
3063
4270
  return existingId;
3064
4271
  }
3065
4272
  console.log("[MachineId] Migrating legacy machine ID to UUID format...");
3066
4273
  const newUUID = generateMachineId();
3067
- fs3.writeFileSync(machineIdPath, newUUID, "utf-8");
4274
+ fs7.writeFileSync(machineIdPath, newUUID, "utf-8");
3068
4275
  console.log(`[MachineId] Migrated: ${existingId} \u2192 ${newUUID}`);
3069
4276
  return newUUID;
3070
4277
  }
@@ -3073,11 +4280,11 @@ async function getMachineId() {
3073
4280
  }
3074
4281
  const machineId = generateMachineId();
3075
4282
  try {
3076
- const dir = path5.dirname(machineIdPath);
3077
- if (!fs3.existsSync(dir)) {
3078
- fs3.mkdirSync(dir, { recursive: true });
4283
+ const dir = path8.dirname(machineIdPath);
4284
+ if (!fs7.existsSync(dir)) {
4285
+ fs7.mkdirSync(dir, { recursive: true });
3079
4286
  }
3080
- fs3.writeFileSync(machineIdPath, machineId, "utf-8");
4287
+ fs7.writeFileSync(machineIdPath, machineId, "utf-8");
3081
4288
  } catch (error) {
3082
4289
  console.error("Warning: Could not save machine ID to disk:", error);
3083
4290
  }
@@ -3086,7 +4293,7 @@ async function getMachineId() {
3086
4293
  function getHardwareUUID() {
3087
4294
  try {
3088
4295
  if (process.platform === "darwin") {
3089
- const output = (0, import_child_process3.execSync)(
4296
+ const output = (0, import_child_process4.execSync)(
3090
4297
  `ioreg -d2 -c IOPlatformExpertDevice | awk -F\\" '/IOPlatformUUID/{print $(NF-1)}'`,
3091
4298
  { encoding: "utf-8", timeout: 5e3 }
3092
4299
  ).trim();
@@ -3094,20 +4301,20 @@ function getHardwareUUID() {
3094
4301
  return output;
3095
4302
  }
3096
4303
  } else if (process.platform === "linux") {
3097
- if (fs3.existsSync("/etc/machine-id")) {
3098
- const machineId = fs3.readFileSync("/etc/machine-id", "utf-8").trim();
4304
+ if (fs7.existsSync("/etc/machine-id")) {
4305
+ const machineId = fs7.readFileSync("/etc/machine-id", "utf-8").trim();
3099
4306
  if (machineId && machineId.length > 0) {
3100
4307
  return machineId;
3101
4308
  }
3102
4309
  }
3103
- if (fs3.existsSync("/var/lib/dbus/machine-id")) {
3104
- const dbusId = fs3.readFileSync("/var/lib/dbus/machine-id", "utf-8").trim();
4310
+ if (fs7.existsSync("/var/lib/dbus/machine-id")) {
4311
+ const dbusId = fs7.readFileSync("/var/lib/dbus/machine-id", "utf-8").trim();
3105
4312
  if (dbusId && dbusId.length > 0) {
3106
4313
  return dbusId;
3107
4314
  }
3108
4315
  }
3109
4316
  } else if (process.platform === "win32") {
3110
- const output = (0, import_child_process3.execSync)("wmic csproduct get uuid", {
4317
+ const output = (0, import_child_process4.execSync)("wmic csproduct get uuid", {
3111
4318
  encoding: "utf-8",
3112
4319
  timeout: 5e3
3113
4320
  });
@@ -3488,18 +4695,21 @@ async function authCommand(options = {}) {
3488
4695
  status.info(` Project: ${tokenResponse.project_uid}`);
3489
4696
  status.info(` Workspace: ${tokenResponse.workspace_uid}`);
3490
4697
  status.info("");
3491
- await (0, import_core5.saveConfig)({
4698
+ await (0, import_core8.saveConfig)({
3492
4699
  project_id: tokenResponse.project_id,
3493
4700
  user_id: tokenResponse.user_id,
3494
4701
  workspace_id: tokenResponse.workspace_id,
4702
+ workspace_slug: tokenResponse.workspace_uid,
4703
+ // EP956: For worktree paths
4704
+ project_slug: tokenResponse.project_uid,
4705
+ // EP956: For worktree paths
3495
4706
  access_token: tokenResponse.access_token,
3496
4707
  refresh_token: tokenResponse.refresh_token,
3497
4708
  expires_at: Date.now() + tokenResponse.expires_in * 1e3,
3498
4709
  // Convert to Unix timestamp
3499
- api_url: apiUrl,
3500
- cli_version: import_core5.VERSION
4710
+ api_url: apiUrl
3501
4711
  });
3502
- status.success(`\u2713 Configuration saved to ${(0, import_core5.getConfigPath)()}`);
4712
+ status.success(`\u2713 Configuration saved to ${(0, import_core8.getConfigPath)()}`);
3503
4713
  status.info("");
3504
4714
  status.info("Installing git credential helper...");
3505
4715
  const credentialHelperInstalled = await installGitCredentialHelper(apiUrl);
@@ -3537,13 +4747,13 @@ async function initiateDeviceFlow(apiUrl, machineId) {
3537
4747
  return await response.json();
3538
4748
  }
3539
4749
  async function monitorAuthorization(apiUrl, deviceCode, expiresIn) {
3540
- return new Promise((resolve2) => {
4750
+ return new Promise((resolve4) => {
3541
4751
  const timeout = setTimeout(() => {
3542
4752
  status.error("Authorization timed out");
3543
- resolve2(false);
4753
+ resolve4(false);
3544
4754
  }, expiresIn * 1e3);
3545
4755
  const url = `${apiUrl}/api/oauth/authorize-stream?device_code=${deviceCode}`;
3546
- const curlProcess = (0, import_child_process4.spawn)("curl", ["-N", url]);
4756
+ const curlProcess = (0, import_child_process5.spawn)("curl", ["-N", url]);
3547
4757
  let buffer = "";
3548
4758
  curlProcess.stdout.on("data", (chunk) => {
3549
4759
  buffer += chunk.toString();
@@ -3566,26 +4776,26 @@ async function monitorAuthorization(apiUrl, deviceCode, expiresIn) {
3566
4776
  if (eventType === "authorized") {
3567
4777
  clearTimeout(timeout);
3568
4778
  curlProcess.kill();
3569
- resolve2(true);
4779
+ resolve4(true);
3570
4780
  return;
3571
4781
  } else if (eventType === "denied") {
3572
4782
  clearTimeout(timeout);
3573
4783
  curlProcess.kill();
3574
4784
  status.error("Authorization denied by user");
3575
- resolve2(false);
4785
+ resolve4(false);
3576
4786
  return;
3577
4787
  } else if (eventType === "expired") {
3578
4788
  clearTimeout(timeout);
3579
4789
  curlProcess.kill();
3580
4790
  status.error("Authorization code expired");
3581
- resolve2(false);
4791
+ resolve4(false);
3582
4792
  return;
3583
4793
  } else if (eventType === "error") {
3584
4794
  const errorData = JSON.parse(data);
3585
4795
  clearTimeout(timeout);
3586
4796
  curlProcess.kill();
3587
4797
  status.error(`Authorization error: ${errorData.error_description || errorData.error || "Unknown error"}`);
3588
- resolve2(false);
4798
+ resolve4(false);
3589
4799
  return;
3590
4800
  }
3591
4801
  } catch (error) {
@@ -3595,13 +4805,13 @@ async function monitorAuthorization(apiUrl, deviceCode, expiresIn) {
3595
4805
  curlProcess.on("error", (error) => {
3596
4806
  clearTimeout(timeout);
3597
4807
  status.error(`Failed to monitor authorization: ${error.message}`);
3598
- resolve2(false);
4808
+ resolve4(false);
3599
4809
  });
3600
4810
  curlProcess.on("close", (code) => {
3601
4811
  clearTimeout(timeout);
3602
4812
  if (code !== 0 && code !== null) {
3603
4813
  status.error(`Authorization monitoring failed with code ${code}`);
3604
- resolve2(false);
4814
+ resolve4(false);
3605
4815
  }
3606
4816
  });
3607
4817
  });
@@ -3647,7 +4857,7 @@ function openBrowser(url) {
3647
4857
  break;
3648
4858
  }
3649
4859
  try {
3650
- (0, import_child_process4.spawn)(command, args, {
4860
+ (0, import_child_process5.spawn)(command, args, {
3651
4861
  detached: true,
3652
4862
  stdio: "ignore"
3653
4863
  }).unref();
@@ -3658,17 +4868,17 @@ function openBrowser(url) {
3658
4868
  async function installGitCredentialHelper(apiUrl) {
3659
4869
  try {
3660
4870
  const homeDir = os.homedir();
3661
- const episodaBinDir = path6.join(homeDir, ".episoda", "bin");
3662
- const helperPath = path6.join(episodaBinDir, "git-credential-episoda");
3663
- fs4.mkdirSync(episodaBinDir, { recursive: true });
4871
+ const episodaBinDir = path9.join(homeDir, ".episoda", "bin");
4872
+ const helperPath = path9.join(episodaBinDir, "git-credential-episoda");
4873
+ fs8.mkdirSync(episodaBinDir, { recursive: true });
3664
4874
  const scriptContent = generateCredentialHelperScript(apiUrl);
3665
- fs4.writeFileSync(helperPath, scriptContent, { mode: 493 });
4875
+ fs8.writeFileSync(helperPath, scriptContent, { mode: 493 });
3666
4876
  try {
3667
- fs4.accessSync(helperPath, fs4.constants.X_OK);
4877
+ fs8.accessSync(helperPath, fs8.constants.X_OK);
3668
4878
  } catch {
3669
4879
  }
3670
4880
  try {
3671
- const allHelpers = (0, import_child_process4.execSync)("git config --global --get-all credential.helper", {
4881
+ const allHelpers = (0, import_child_process5.execSync)("git config --global --get-all credential.helper", {
3672
4882
  encoding: "utf8",
3673
4883
  stdio: ["pipe", "pipe", "pipe"]
3674
4884
  }).trim().split("\n");
@@ -3677,7 +4887,7 @@ async function installGitCredentialHelper(apiUrl) {
3677
4887
  }
3678
4888
  } catch {
3679
4889
  }
3680
- (0, import_child_process4.execSync)(`git config --global --add credential.helper "${helperPath}"`, {
4890
+ (0, import_child_process5.execSync)(`git config --global --add credential.helper "${helperPath}"`, {
3681
4891
  encoding: "utf8",
3682
4892
  stdio: ["pipe", "pipe", "pipe"]
3683
4893
  });
@@ -3695,19 +4905,19 @@ function updateShellProfile(binDir) {
3695
4905
  }
3696
4906
  const homeDir = os.homedir();
3697
4907
  const profiles = [
3698
- path6.join(homeDir, ".bashrc"),
3699
- path6.join(homeDir, ".zshrc"),
3700
- path6.join(homeDir, ".profile")
4908
+ path9.join(homeDir, ".bashrc"),
4909
+ path9.join(homeDir, ".zshrc"),
4910
+ path9.join(homeDir, ".profile")
3701
4911
  ];
3702
4912
  const exportLine = `export PATH="${binDir}:$PATH" # Added by episoda auth`;
3703
4913
  for (const profile of profiles) {
3704
4914
  try {
3705
- if (fs4.existsSync(profile)) {
3706
- const content = fs4.readFileSync(profile, "utf8");
4915
+ if (fs8.existsSync(profile)) {
4916
+ const content = fs8.readFileSync(profile, "utf8");
3707
4917
  if (content.includes(".episoda/bin")) {
3708
4918
  continue;
3709
4919
  }
3710
- fs4.appendFileSync(profile, `
4920
+ fs8.appendFileSync(profile, `
3711
4921
  # Episoda CLI
3712
4922
  ${exportLine}
3713
4923
  `);
@@ -3719,10 +4929,10 @@ ${exportLine}
3719
4929
 
3720
4930
  // src/commands/connect.ts
3721
4931
  var os2 = __toESM(require("os"));
3722
- var fs5 = __toESM(require("fs"));
3723
- var path7 = __toESM(require("path"));
3724
- var import_child_process5 = require("child_process");
3725
- var import_core6 = __toESM(require_dist());
4932
+ var fs9 = __toESM(require("fs"));
4933
+ var path10 = __toESM(require("path"));
4934
+ var import_child_process6 = require("child_process");
4935
+ var import_core9 = __toESM(require_dist());
3726
4936
  async function connectCommand(options) {
3727
4937
  const { code } = options;
3728
4938
  const apiUrl = options.apiUrl || process.env.EPISODA_API_URL || "https://episoda.dev";
@@ -3738,15 +4948,14 @@ async function connectCommand(options) {
3738
4948
  status.info(` Project: ${tokenResponse.project_uid}`);
3739
4949
  status.info(` Workspace: ${tokenResponse.workspace_uid}`);
3740
4950
  status.info("");
3741
- await (0, import_core6.saveConfig)({
4951
+ await (0, import_core9.saveConfig)({
3742
4952
  project_id: tokenResponse.project_id,
3743
4953
  user_id: tokenResponse.user_id,
3744
4954
  workspace_id: tokenResponse.workspace_id,
3745
4955
  access_token: tokenResponse.access_token,
3746
- api_url: apiUrl,
3747
- cli_version: import_core6.VERSION
4956
+ api_url: apiUrl
3748
4957
  });
3749
- status.success(`\u2713 Configuration saved to ${(0, import_core6.getConfigPath)()}`);
4958
+ status.success(`\u2713 Configuration saved to ${(0, import_core9.getConfigPath)()}`);
3750
4959
  status.info("");
3751
4960
  status.info("Installing git credential helper...");
3752
4961
  const credentialHelperInstalled = await installGitCredentialHelper2(apiUrl);
@@ -3796,13 +5005,13 @@ async function exchangeUserCode(apiUrl, userCode, machineId) {
3796
5005
  async function installGitCredentialHelper2(apiUrl) {
3797
5006
  try {
3798
5007
  const homeDir = os2.homedir();
3799
- const episodaBinDir = path7.join(homeDir, ".episoda", "bin");
3800
- const helperPath = path7.join(episodaBinDir, "git-credential-episoda");
3801
- fs5.mkdirSync(episodaBinDir, { recursive: true });
5008
+ const episodaBinDir = path10.join(homeDir, ".episoda", "bin");
5009
+ const helperPath = path10.join(episodaBinDir, "git-credential-episoda");
5010
+ fs9.mkdirSync(episodaBinDir, { recursive: true });
3802
5011
  const scriptContent = generateCredentialHelperScript(apiUrl);
3803
- fs5.writeFileSync(helperPath, scriptContent, { mode: 493 });
5012
+ fs9.writeFileSync(helperPath, scriptContent, { mode: 493 });
3804
5013
  try {
3805
- const allHelpers = (0, import_child_process5.execSync)("git config --global --get-all credential.helper", {
5014
+ const allHelpers = (0, import_child_process6.execSync)("git config --global --get-all credential.helper", {
3806
5015
  encoding: "utf8",
3807
5016
  stdio: ["pipe", "pipe", "pipe"]
3808
5017
  }).trim().split("\n");
@@ -3811,7 +5020,7 @@ async function installGitCredentialHelper2(apiUrl) {
3811
5020
  }
3812
5021
  } catch {
3813
5022
  }
3814
- (0, import_child_process5.execSync)(`git config --global --add credential.helper "${helperPath}"`, {
5023
+ (0, import_child_process6.execSync)(`git config --global --add credential.helper "${helperPath}"`, {
3815
5024
  encoding: "utf8",
3816
5025
  stdio: ["pipe", "pipe", "pipe"]
3817
5026
  });
@@ -3829,19 +5038,19 @@ function updateShellProfile2(binDir) {
3829
5038
  }
3830
5039
  const homeDir = os2.homedir();
3831
5040
  const profiles = [
3832
- path7.join(homeDir, ".bashrc"),
3833
- path7.join(homeDir, ".zshrc"),
3834
- path7.join(homeDir, ".profile")
5041
+ path10.join(homeDir, ".bashrc"),
5042
+ path10.join(homeDir, ".zshrc"),
5043
+ path10.join(homeDir, ".profile")
3835
5044
  ];
3836
5045
  const exportLine = `export PATH="${binDir}:$PATH" # Added by episoda`;
3837
5046
  for (const profile of profiles) {
3838
5047
  try {
3839
- if (fs5.existsSync(profile)) {
3840
- const content = fs5.readFileSync(profile, "utf8");
5048
+ if (fs9.existsSync(profile)) {
5049
+ const content = fs9.readFileSync(profile, "utf8");
3841
5050
  if (content.includes(".episoda/bin")) {
3842
5051
  continue;
3843
5052
  }
3844
- fs5.appendFileSync(profile, `
5053
+ fs9.appendFileSync(profile, `
3845
5054
  # Episoda CLI
3846
5055
  ${exportLine}
3847
5056
  `);
@@ -3852,11 +5061,11 @@ ${exportLine}
3852
5061
  }
3853
5062
 
3854
5063
  // src/commands/status.ts
3855
- var import_core7 = __toESM(require_dist());
5064
+ var import_core10 = __toESM(require_dist());
3856
5065
  async function statusCommand(options = {}) {
3857
5066
  status.info("Checking CLI status...");
3858
5067
  status.info("");
3859
- const config = await (0, import_core7.loadConfig)();
5068
+ const config = await (0, import_core10.loadConfig)();
3860
5069
  if (!config) {
3861
5070
  status.error("\u2717 CLI not initialized");
3862
5071
  status.info("");
@@ -3871,8 +5080,8 @@ async function statusCommand(options = {}) {
3871
5080
  status.info("Configuration:");
3872
5081
  status.info(` Project ID: ${config.project_id}`);
3873
5082
  status.info(` API URL: ${config.api_url}`);
3874
- status.info(` CLI Version: ${config.cli_version}`);
3875
- status.info(` Config file: ${(0, import_core7.getConfigPath)()}`);
5083
+ status.info(` CLI Version: ${import_core10.VERSION}`);
5084
+ status.info(` Config file: ${(0, import_core10.getConfigPath)()}`);
3876
5085
  status.info("");
3877
5086
  if (!config.access_token || config.access_token === "") {
3878
5087
  status.warning("\u26A0 Not authenticated");
@@ -4031,8 +5240,588 @@ async function stopCommand(options = {}) {
4031
5240
  }
4032
5241
  }
4033
5242
 
5243
+ // src/commands/clone.ts
5244
+ var fs10 = __toESM(require("fs"));
5245
+ var path11 = __toESM(require("path"));
5246
+ var import_core11 = __toESM(require_dist());
5247
+ async function cloneCommand(slugArg, options = {}) {
5248
+ const slugParts = slugArg.split("/");
5249
+ if (slugParts.length !== 2 || !slugParts[0] || !slugParts[1]) {
5250
+ throw new Error(
5251
+ "Invalid format. Usage: episoda clone {workspace}/{project}\nExample: episoda clone my-team/my-project"
5252
+ );
5253
+ }
5254
+ const [workspaceSlug, projectSlug] = slugParts;
5255
+ status.info(`Cloning ${workspaceSlug}/${projectSlug}...`);
5256
+ status.info("");
5257
+ const config = await (0, import_core11.loadConfig)();
5258
+ if (!config || !config.access_token) {
5259
+ throw new Error(
5260
+ "Not authenticated. Please run `episoda auth` first."
5261
+ );
5262
+ }
5263
+ const apiUrl = options.apiUrl || config.api_url || "https://episoda.dev";
5264
+ const projectPath = getProjectPath(workspaceSlug, projectSlug);
5265
+ if (fs10.existsSync(projectPath)) {
5266
+ const bareRepoPath = path11.join(projectPath, ".bare");
5267
+ if (fs10.existsSync(bareRepoPath)) {
5268
+ status.warning(`Project already cloned at ${projectPath}`);
5269
+ status.info("");
5270
+ status.info("Next steps:");
5271
+ status.info(` \u2022 cd ${projectPath}`);
5272
+ status.info(" \u2022 episoda checkout {module}");
5273
+ return;
5274
+ }
5275
+ throw new Error(
5276
+ `Directory exists but is not a worktree project: ${projectPath}
5277
+ Please remove it manually or use a different location.`
5278
+ );
5279
+ }
5280
+ status.info("Fetching project details...");
5281
+ const projectDetails = await fetchProjectDetails(
5282
+ apiUrl,
5283
+ workspaceSlug,
5284
+ projectSlug,
5285
+ config.access_token
5286
+ );
5287
+ if (!projectDetails.repo_url) {
5288
+ throw new Error(
5289
+ `Project "${projectSlug}" has no repository configured.
5290
+ Please configure a repository in the project settings on episoda.dev.`
5291
+ );
5292
+ }
5293
+ validateRepoUrl(projectDetails.repo_url);
5294
+ status.success(`\u2713 Found project: ${projectDetails.name}`);
5295
+ status.info(` Repository: ${projectDetails.repo_url}`);
5296
+ status.info("");
5297
+ status.info("Creating project directory...");
5298
+ const episodaRoot = getEpisodaRoot();
5299
+ fs10.mkdirSync(projectPath, { recursive: true });
5300
+ status.success(`\u2713 Created ${projectPath}`);
5301
+ status.info("Cloning repository (bare)...");
5302
+ try {
5303
+ const worktreeManager = await WorktreeManager.createProject(
5304
+ projectPath,
5305
+ projectDetails.repo_url,
5306
+ projectDetails.id,
5307
+ workspaceSlug,
5308
+ projectSlug
5309
+ );
5310
+ status.success("\u2713 Repository cloned");
5311
+ status.info("");
5312
+ const bareRepoPath = worktreeManager.getBareRepoPath();
5313
+ addProject2(projectDetails.id, projectPath, {
5314
+ worktreeMode: true,
5315
+ bareRepoPath
5316
+ });
5317
+ status.success("\u2713 Project registered with daemon");
5318
+ status.info("");
5319
+ status.success("Project cloned successfully!");
5320
+ status.info("");
5321
+ status.info("Project structure:");
5322
+ status.info(` ${projectPath}/`);
5323
+ status.info(" \u251C\u2500\u2500 .bare/ # Git repository");
5324
+ status.info(" \u2514\u2500\u2500 .episoda/");
5325
+ status.info(" \u2514\u2500\u2500 config.json # Project config");
5326
+ status.info("");
5327
+ status.info("Next steps:");
5328
+ status.info(` 1. cd ${projectPath}`);
5329
+ status.info(" 2. episoda checkout {moduleUid} # e.g., episoda checkout EP100");
5330
+ status.info("");
5331
+ } catch (error) {
5332
+ if (fs10.existsSync(projectPath)) {
5333
+ try {
5334
+ fs10.rmSync(projectPath, { recursive: true, force: true });
5335
+ } catch {
5336
+ }
5337
+ }
5338
+ throw error;
5339
+ }
5340
+ }
5341
+ function validateRepoUrl(url) {
5342
+ const dangerousChars = /[;|&$`\\<>(){}[\]!#*?~'"]/;
5343
+ if (dangerousChars.test(url)) {
5344
+ throw new Error(
5345
+ "Repository URL contains invalid characters.\nPlease check the repository configuration on episoda.dev."
5346
+ );
5347
+ }
5348
+ let parsed;
5349
+ try {
5350
+ parsed = new URL(url);
5351
+ } catch {
5352
+ if (/^[\w.-]+@[\w.-]+:[\w./-]+$/.test(url)) {
5353
+ return;
5354
+ }
5355
+ throw new Error(
5356
+ `Repository URL is not a valid URL format.
5357
+ Received: ${url}`
5358
+ );
5359
+ }
5360
+ const allowedProtocols = ["https:", "http:", "git:", "ssh:"];
5361
+ if (!allowedProtocols.includes(parsed.protocol)) {
5362
+ throw new Error(
5363
+ `Repository URL uses unsupported protocol: ${parsed.protocol}
5364
+ Allowed protocols: https, http, git, ssh`
5365
+ );
5366
+ }
5367
+ if (!parsed.hostname || parsed.hostname.length < 3) {
5368
+ throw new Error(
5369
+ `Repository URL has invalid hostname.
5370
+ Received: ${url}`
5371
+ );
5372
+ }
5373
+ }
5374
+ async function fetchProjectDetails(apiUrl, workspaceSlug, projectSlug, accessToken) {
5375
+ const url = `${apiUrl}/api/projects/by-slug/${workspaceSlug}/${projectSlug}`;
5376
+ const response = await fetch(url, {
5377
+ method: "GET",
5378
+ headers: {
5379
+ "Authorization": `Bearer ${accessToken}`,
5380
+ "Content-Type": "application/json"
5381
+ }
5382
+ });
5383
+ if (response.status === 404) {
5384
+ throw new Error(
5385
+ `Project "${workspaceSlug}/${projectSlug}" not found.
5386
+ Check the workspace and project slugs are correct.`
5387
+ );
5388
+ }
5389
+ if (response.status === 401) {
5390
+ throw new Error(
5391
+ "Authentication expired. Please run `episoda auth` again."
5392
+ );
5393
+ }
5394
+ if (!response.ok) {
5395
+ const errorBody = await response.text();
5396
+ throw new Error(
5397
+ `Failed to fetch project details: ${response.status}
5398
+ ${errorBody}`
5399
+ );
5400
+ }
5401
+ const data = await response.json();
5402
+ if (!data.id || !data.slug) {
5403
+ throw new Error("Invalid project response from server");
5404
+ }
5405
+ return data;
5406
+ }
5407
+
5408
+ // src/commands/checkout.ts
5409
+ var import_core12 = __toESM(require_dist());
5410
+
5411
+ // src/utils/http.ts
5412
+ async function fetchWithRetry(url, options, maxRetries = 3) {
5413
+ let lastError = null;
5414
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
5415
+ try {
5416
+ const response = await fetch(url, options);
5417
+ if (response.status === 401 || response.status === 404) {
5418
+ return response;
5419
+ }
5420
+ if (response.ok || response.status >= 400 && response.status < 500) {
5421
+ return response;
5422
+ }
5423
+ lastError = new Error(`Server error: ${response.status}`);
5424
+ } catch (error) {
5425
+ lastError = error instanceof Error ? error : new Error("Network error");
5426
+ }
5427
+ if (attempt < maxRetries - 1) {
5428
+ await new Promise((resolve4) => setTimeout(resolve4, Math.pow(2, attempt) * 1e3));
5429
+ }
5430
+ }
5431
+ throw lastError || new Error("Request failed after retries");
5432
+ }
5433
+
5434
+ // src/commands/checkout.ts
5435
+ async function checkoutCommand(moduleUid, options = {}) {
5436
+ if (!moduleUid || !moduleUid.match(/^EP\d+$/)) {
5437
+ throw new Error(
5438
+ "Invalid module UID format. Expected format: EP###\nExample: episoda checkout EP100"
5439
+ );
5440
+ }
5441
+ status.info(`Checking out ${moduleUid}...`);
5442
+ status.info("");
5443
+ const projectRoot = await findProjectRoot(process.cwd());
5444
+ if (!projectRoot) {
5445
+ throw new Error(
5446
+ "Not in a worktree project.\nRun this command from within a project cloned with `episoda clone`.\nOr cd to ~/episoda/{workspace}/{project}/"
5447
+ );
5448
+ }
5449
+ const config = await (0, import_core12.loadConfig)();
5450
+ if (!config || !config.access_token) {
5451
+ throw new Error(
5452
+ "Not authenticated. Please run `episoda auth` first."
5453
+ );
5454
+ }
5455
+ const apiUrl = options.apiUrl || config.api_url || "https://episoda.dev";
5456
+ const worktreeManager = new WorktreeManager(projectRoot);
5457
+ const initialized = await worktreeManager.initialize();
5458
+ if (!initialized) {
5459
+ throw new Error(
5460
+ `Invalid worktree project at ${projectRoot}.
5461
+ The .bare directory or .episoda/config.json may be missing.`
5462
+ );
5463
+ }
5464
+ const existing = worktreeManager.getWorktreeByModuleUid(moduleUid);
5465
+ if (existing) {
5466
+ status.success(`Module ${moduleUid} is already checked out.`);
5467
+ status.info("");
5468
+ status.info(`Path: ${existing.worktreePath}`);
5469
+ status.info(`Branch: ${existing.branchName}`);
5470
+ status.info("");
5471
+ status.info(`cd ${existing.worktreePath}`);
5472
+ return;
5473
+ }
5474
+ status.info("Fetching module details...");
5475
+ const worktreeConfig = worktreeManager.getConfig();
5476
+ if (!worktreeConfig) {
5477
+ throw new Error("Could not read worktree project config");
5478
+ }
5479
+ const moduleDetails = await fetchModuleDetails(
5480
+ apiUrl,
5481
+ worktreeConfig.projectId,
5482
+ moduleUid,
5483
+ config.access_token
5484
+ );
5485
+ const branchName = moduleDetails.branch_name || `feature/${moduleUid.toLowerCase()}`;
5486
+ let createBranch = !moduleDetails.branch_name || options.create;
5487
+ if (createBranch && !moduleDetails.branch_name) {
5488
+ const { GitExecutor: GitExecutor6 } = await Promise.resolve().then(() => __toESM(require_dist()));
5489
+ const gitExecutor = new GitExecutor6();
5490
+ const branchCheck = await gitExecutor.execute(
5491
+ { action: "branch_exists", branch: branchName },
5492
+ { cwd: worktreeManager.getBareRepoPath() }
5493
+ );
5494
+ if (branchCheck.success && branchCheck.details?.branchExists) {
5495
+ status.info(`Branch ${branchName} already exists, will use existing`);
5496
+ createBranch = false;
5497
+ }
5498
+ }
5499
+ status.success(`\u2713 Found module: ${moduleDetails.name || moduleUid}`);
5500
+ status.info(` Branch: ${branchName}${createBranch ? " (will be created)" : ""}`);
5501
+ status.info("");
5502
+ status.info("Creating worktree...");
5503
+ const result = await worktreeManager.createWorktree(
5504
+ moduleUid,
5505
+ branchName,
5506
+ createBranch
5507
+ );
5508
+ if (!result.success) {
5509
+ throw new Error(`Failed to create worktree: ${result.error}`);
5510
+ }
5511
+ status.success("\u2713 Worktree created");
5512
+ status.info("");
5513
+ const branchWasGenerated = !moduleDetails.branch_name;
5514
+ try {
5515
+ await updateModuleCheckout(
5516
+ apiUrl,
5517
+ moduleDetails.id,
5518
+ config.access_token,
5519
+ branchWasGenerated ? branchName : void 0
5520
+ );
5521
+ } catch (error) {
5522
+ status.warning("Could not update server with checkout status");
5523
+ }
5524
+ status.success(`Module ${moduleUid} checked out successfully!`);
5525
+ status.info("");
5526
+ status.info(`Path: ${result.worktreePath}`);
5527
+ status.info(`Branch: ${branchName}`);
5528
+ status.info("");
5529
+ status.info("Next step:");
5530
+ status.info(` cd ${result.worktreePath}`);
5531
+ status.info("");
5532
+ }
5533
+ async function fetchModuleDetails(apiUrl, projectId, moduleUid, accessToken) {
5534
+ const url = `${apiUrl}/api/modules/by-uid/${moduleUid}?project_id=${projectId}`;
5535
+ const response = await fetchWithRetry(url, {
5536
+ method: "GET",
5537
+ headers: {
5538
+ "Authorization": `Bearer ${accessToken}`,
5539
+ "Content-Type": "application/json"
5540
+ }
5541
+ });
5542
+ if (response.status === 404) {
5543
+ throw new Error(
5544
+ `Module "${moduleUid}" not found in this project.
5545
+ Check the module UID is correct.`
5546
+ );
5547
+ }
5548
+ if (response.status === 401) {
5549
+ throw new Error(
5550
+ "Authentication expired. Please run `episoda auth` again."
5551
+ );
5552
+ }
5553
+ if (!response.ok) {
5554
+ const errorBody = await response.text();
5555
+ throw new Error(
5556
+ `Failed to fetch module details: ${response.status}
5557
+ ${errorBody}`
5558
+ );
5559
+ }
5560
+ const data = await response.json();
5561
+ if (!data.id || !data.uid) {
5562
+ throw new Error("Invalid module response from server");
5563
+ }
5564
+ return data;
5565
+ }
5566
+ async function updateModuleCheckout(apiUrl, moduleId, accessToken, branchName) {
5567
+ const url = `${apiUrl}/api/modules/${moduleId}/checkout`;
5568
+ const body = {
5569
+ checked_out: true,
5570
+ checkout_type: "worktree"
5571
+ };
5572
+ if (branchName) {
5573
+ body.branch_name = branchName;
5574
+ }
5575
+ await fetchWithRetry(url, {
5576
+ method: "POST",
5577
+ headers: {
5578
+ "Authorization": `Bearer ${accessToken}`,
5579
+ "Content-Type": "application/json"
5580
+ },
5581
+ body: JSON.stringify(body)
5582
+ });
5583
+ }
5584
+
5585
+ // src/commands/release.ts
5586
+ var path12 = __toESM(require("path"));
5587
+ var import_core13 = __toESM(require_dist());
5588
+ async function releaseCommand(moduleUid, options = {}) {
5589
+ if (!moduleUid || !moduleUid.match(/^EP\d+$/)) {
5590
+ throw new Error(
5591
+ "Invalid module UID format. Expected format: EP###\nExample: episoda release EP100"
5592
+ );
5593
+ }
5594
+ status.info(`Releasing ${moduleUid}...`);
5595
+ status.info("");
5596
+ const projectRoot = await findProjectRoot(process.cwd());
5597
+ if (!projectRoot) {
5598
+ throw new Error(
5599
+ "Not in a worktree project.\nRun this command from within a project cloned with `episoda clone`."
5600
+ );
5601
+ }
5602
+ const worktreeManager = new WorktreeManager(projectRoot);
5603
+ const initialized = await worktreeManager.initialize();
5604
+ if (!initialized) {
5605
+ throw new Error(
5606
+ `Invalid worktree project at ${projectRoot}.`
5607
+ );
5608
+ }
5609
+ const existing = worktreeManager.getWorktreeByModuleUid(moduleUid);
5610
+ if (!existing) {
5611
+ status.warning(`Module ${moduleUid} is not checked out.`);
5612
+ status.info("");
5613
+ status.info("Available worktrees:");
5614
+ const worktrees = worktreeManager.listWorktrees();
5615
+ if (worktrees.length === 0) {
5616
+ status.info(" (none)");
5617
+ } else {
5618
+ for (const wt of worktrees) {
5619
+ status.info(` ${wt.moduleUid} \u2192 ${wt.branchName}`);
5620
+ }
5621
+ }
5622
+ return;
5623
+ }
5624
+ if (!options.force) {
5625
+ const hasChanges = await checkUncommittedChanges(existing.worktreePath);
5626
+ if (hasChanges) {
5627
+ throw new Error(
5628
+ `Module ${moduleUid} has uncommitted changes.
5629
+ Commit or stash your changes first, or use --force to discard them.`
5630
+ );
5631
+ }
5632
+ }
5633
+ const currentPath = path12.resolve(process.cwd());
5634
+ if (currentPath.startsWith(existing.worktreePath)) {
5635
+ status.warning("You are inside the worktree being released.");
5636
+ status.info(`Please cd to ${projectRoot} first.`);
5637
+ throw new Error("Cannot release worktree while inside it");
5638
+ }
5639
+ status.info("Removing worktree...");
5640
+ const result = await worktreeManager.removeWorktree(moduleUid, options.force);
5641
+ if (!result.success) {
5642
+ throw new Error(`Failed to remove worktree: ${result.error}`);
5643
+ }
5644
+ status.success("\u2713 Worktree removed");
5645
+ status.info("");
5646
+ try {
5647
+ const config = await (0, import_core13.loadConfig)();
5648
+ if (config?.access_token) {
5649
+ const worktreeConfig = worktreeManager.getConfig();
5650
+ if (worktreeConfig) {
5651
+ await updateModuleRelease(
5652
+ config.api_url || "https://episoda.dev",
5653
+ worktreeConfig.projectId,
5654
+ worktreeConfig.workspaceSlug,
5655
+ moduleUid,
5656
+ config.access_token
5657
+ );
5658
+ }
5659
+ }
5660
+ } catch (error) {
5661
+ }
5662
+ status.success(`Module ${moduleUid} released successfully!`);
5663
+ status.info("");
5664
+ status.info(`Branch "${existing.branchName}" is preserved in the repository.`);
5665
+ status.info("");
5666
+ status.info("To check out again:");
5667
+ status.info(` episoda checkout ${moduleUid}`);
5668
+ status.info("");
5669
+ }
5670
+ async function checkUncommittedChanges(worktreePath) {
5671
+ const gitExecutor = new import_core13.GitExecutor();
5672
+ const result = await gitExecutor.execute(
5673
+ { action: "status" },
5674
+ { cwd: worktreePath }
5675
+ );
5676
+ if (!result.success) {
5677
+ return true;
5678
+ }
5679
+ const uncommitted = result.details?.uncommittedFiles || [];
5680
+ return uncommitted.length > 0;
5681
+ }
5682
+ async function updateModuleRelease(apiUrl, projectId, workspaceSlug, moduleUid, accessToken) {
5683
+ const url = `${apiUrl}/api/modules/${moduleUid}/checkout/release`;
5684
+ await fetchWithRetry(url, {
5685
+ method: "POST",
5686
+ headers: {
5687
+ "Authorization": `Bearer ${accessToken}`,
5688
+ "Content-Type": "application/json",
5689
+ "X-Project-Id": projectId,
5690
+ "X-Workspace-Slug": workspaceSlug
5691
+ }
5692
+ });
5693
+ }
5694
+
5695
+ // src/commands/list.ts
5696
+ var fs11 = __toESM(require("fs"));
5697
+ var path13 = __toESM(require("path"));
5698
+ var import_chalk2 = __toESM(require("chalk"));
5699
+ var import_core14 = __toESM(require_dist());
5700
+ async function listCommand(subcommand, options = {}) {
5701
+ if (subcommand === "worktrees" || subcommand === "wt") {
5702
+ await listWorktrees(options);
5703
+ } else {
5704
+ await listProjects(options);
5705
+ }
5706
+ }
5707
+ async function listProjects(options) {
5708
+ const episodaRoot = getEpisodaRoot();
5709
+ if (!fs11.existsSync(episodaRoot)) {
5710
+ status.info("No projects cloned yet.");
5711
+ status.info("");
5712
+ status.info("Clone a project with:");
5713
+ status.info(" episoda clone {workspace}/{project}");
5714
+ return;
5715
+ }
5716
+ const projects = [];
5717
+ const workspaces = fs11.readdirSync(episodaRoot, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("."));
5718
+ for (const workspace of workspaces) {
5719
+ const workspacePath = path13.join(episodaRoot, workspace.name);
5720
+ const projectDirs = fs11.readdirSync(workspacePath, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("."));
5721
+ for (const projectDir of projectDirs) {
5722
+ const projectPath = path13.join(workspacePath, projectDir.name);
5723
+ if (await isWorktreeProject(projectPath)) {
5724
+ const manager = new WorktreeManager(projectPath);
5725
+ await manager.initialize();
5726
+ const worktrees = manager.listWorktrees();
5727
+ projects.push({
5728
+ workspaceSlug: workspace.name,
5729
+ projectSlug: projectDir.name,
5730
+ projectPath,
5731
+ worktreeCount: worktrees.length
5732
+ });
5733
+ }
5734
+ }
5735
+ }
5736
+ if (projects.length === 0) {
5737
+ status.info("No worktree projects found.");
5738
+ status.info("");
5739
+ status.info("Clone a project with:");
5740
+ status.info(" episoda clone {workspace}/{project}");
5741
+ return;
5742
+ }
5743
+ console.log("");
5744
+ console.log(import_chalk2.default.bold("Cloned Projects"));
5745
+ console.log("\u2500".repeat(60));
5746
+ for (const project of projects) {
5747
+ const worktreeLabel = project.worktreeCount === 0 ? import_chalk2.default.gray("(no worktrees)") : project.worktreeCount === 1 ? import_chalk2.default.cyan("1 worktree") : import_chalk2.default.cyan(`${project.worktreeCount} worktrees`);
5748
+ console.log("");
5749
+ console.log(` ${import_chalk2.default.bold(project.workspaceSlug)}/${import_chalk2.default.bold(project.projectSlug)} ${worktreeLabel}`);
5750
+ console.log(import_chalk2.default.gray(` ${project.projectPath}`));
5751
+ }
5752
+ console.log("");
5753
+ console.log(import_chalk2.default.gray("\u2500".repeat(60)));
5754
+ console.log(`${projects.length} project${projects.length === 1 ? "" : "s"}`);
5755
+ console.log("");
5756
+ if (options.verbose) {
5757
+ status.info("Commands:");
5758
+ status.info(" episoda list worktrees # Show worktrees for current project");
5759
+ status.info(" episoda checkout EP### # Create a new worktree");
5760
+ }
5761
+ }
5762
+ async function listWorktrees(options) {
5763
+ const projectRoot = await findProjectRoot(process.cwd());
5764
+ if (!projectRoot) {
5765
+ throw new Error(
5766
+ "Not in a worktree project.\nRun this from within a project cloned with `episoda clone`."
5767
+ );
5768
+ }
5769
+ const manager = new WorktreeManager(projectRoot);
5770
+ const initialized = await manager.initialize();
5771
+ if (!initialized) {
5772
+ throw new Error(`Invalid worktree project at ${projectRoot}`);
5773
+ }
5774
+ const config = manager.getConfig();
5775
+ const worktrees = manager.listWorktrees();
5776
+ console.log("");
5777
+ console.log(import_chalk2.default.bold(`Worktrees for ${config?.workspaceSlug}/${config?.projectSlug}`));
5778
+ console.log("\u2500".repeat(60));
5779
+ if (worktrees.length === 0) {
5780
+ console.log("");
5781
+ console.log(import_chalk2.default.gray(" No worktrees checked out"));
5782
+ console.log("");
5783
+ status.info("Create a worktree with:");
5784
+ status.info(" episoda checkout {moduleUid}");
5785
+ return;
5786
+ }
5787
+ const gitExecutor = new import_core14.GitExecutor();
5788
+ for (const wt of worktrees) {
5789
+ console.log("");
5790
+ let statusIndicator = import_chalk2.default.green("\u25CF");
5791
+ let statusNote = "";
5792
+ try {
5793
+ const result = await gitExecutor.execute(
5794
+ { action: "status" },
5795
+ { cwd: wt.worktreePath }
5796
+ );
5797
+ if (result.success) {
5798
+ const uncommitted = result.details?.uncommittedFiles || [];
5799
+ if (uncommitted.length > 0) {
5800
+ statusIndicator = import_chalk2.default.yellow("\u25CF");
5801
+ statusNote = import_chalk2.default.yellow(` (${uncommitted.length} uncommitted)`);
5802
+ }
5803
+ }
5804
+ } catch {
5805
+ statusIndicator = import_chalk2.default.gray("\u25CB");
5806
+ }
5807
+ console.log(` ${statusIndicator} ${import_chalk2.default.bold(wt.moduleUid)}${statusNote}`);
5808
+ console.log(import_chalk2.default.gray(` Branch: ${wt.branchName}`));
5809
+ console.log(import_chalk2.default.gray(` Path: ${wt.worktreePath}`));
5810
+ if (options.verbose) {
5811
+ console.log(import_chalk2.default.gray(` Created: ${new Date(wt.createdAt).toLocaleDateString()}`));
5812
+ console.log(import_chalk2.default.gray(` Last accessed: ${new Date(wt.lastAccessed).toLocaleDateString()}`));
5813
+ }
5814
+ }
5815
+ console.log("");
5816
+ console.log(import_chalk2.default.gray("\u2500".repeat(60)));
5817
+ console.log(`${worktrees.length} worktree${worktrees.length === 1 ? "" : "s"}`);
5818
+ console.log("");
5819
+ console.log(import_chalk2.default.gray("\u25CF clean ") + import_chalk2.default.yellow("\u25CF uncommitted changes"));
5820
+ console.log("");
5821
+ }
5822
+
4034
5823
  // src/index.ts
4035
- import_commander.program.name("episoda").description("Episoda local development workflow orchestration").version(import_core8.VERSION);
5824
+ import_commander.program.name("episoda").description("Episoda local development workflow orchestration").version(import_core15.VERSION);
4036
5825
  import_commander.program.command("auth").description("Authenticate to Episoda via OAuth and configure CLI").option("--api-url <url>", "API URL (default: https://episoda.dev)").action(async (options) => {
4037
5826
  try {
4038
5827
  await authCommand(options);
@@ -4088,6 +5877,46 @@ import_commander.program.command("disconnect").description("Disconnect from epis
4088
5877
  process.exit(1);
4089
5878
  }
4090
5879
  });
5880
+ import_commander.program.command("clone").description("Clone a project in worktree mode for multi-module development").argument("<workspace/project>", "Workspace and project slug (e.g., my-team/my-project)").option("--api-url <url>", "API URL (default: https://episoda.dev)").action(async (slug, options) => {
5881
+ try {
5882
+ await cloneCommand(slug, options);
5883
+ } catch (error) {
5884
+ status.error(`Clone failed: ${error instanceof Error ? error.message : String(error)}`);
5885
+ process.exit(1);
5886
+ }
5887
+ });
5888
+ import_commander.program.command("checkout").description("Create a worktree for a module").argument("<moduleUid>", "Module UID (e.g., EP100)").option("--api-url <url>", "API URL (default: https://episoda.dev)").option("--create", "Create branch if it doesn't exist").action(async (moduleUid, options) => {
5889
+ try {
5890
+ await checkoutCommand(moduleUid, options);
5891
+ } catch (error) {
5892
+ status.error(`Checkout failed: ${error instanceof Error ? error.message : String(error)}`);
5893
+ process.exit(1);
5894
+ }
5895
+ });
5896
+ import_commander.program.command("release").description("Remove a worktree for a module (preserves branch)").argument("<moduleUid>", "Module UID (e.g., EP100)").option("--force", "Force release even with uncommitted changes").option("--api-url <url>", "API URL (default: https://episoda.dev)").action(async (moduleUid, options) => {
5897
+ try {
5898
+ await releaseCommand(moduleUid, options);
5899
+ } catch (error) {
5900
+ status.error(`Release failed: ${error instanceof Error ? error.message : String(error)}`);
5901
+ process.exit(1);
5902
+ }
5903
+ });
5904
+ import_commander.program.command("list").description("List cloned projects and worktrees").argument("[subcommand]", 'Subcommand: "worktrees" or "wt" to list worktrees').option("--verbose", "Show detailed information").action(async (subcommand, options) => {
5905
+ try {
5906
+ await listCommand(subcommand, options);
5907
+ } catch (error) {
5908
+ status.error(`List failed: ${error instanceof Error ? error.message : String(error)}`);
5909
+ process.exit(1);
5910
+ }
5911
+ });
5912
+ import_commander.program.command("migrate").description("Convert existing project to worktree mode").option("--force", "Force migration even with uncommitted changes (auto-stash)").option("--dry-run", "Show what would be done without making changes").action(async (options) => {
5913
+ try {
5914
+ await migrateCommand(options);
5915
+ } catch (error) {
5916
+ status.error(`Migration failed: ${error instanceof Error ? error.message : String(error)}`);
5917
+ process.exit(1);
5918
+ }
5919
+ });
4091
5920
  import_commander.program.command("dev:restart").description("Restart the dev server for a module").argument("[moduleUid]", "Module UID (required)").action(async (moduleUid) => {
4092
5921
  try {
4093
5922
  if (!moduleUid) {