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/daemon/daemon-process.js +1442 -272
- package/dist/daemon/daemon-process.js.map +1 -1
- package/dist/index.js +1964 -135
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
1538
|
+
await fs12.access(rebaseMergePath);
|
|
1526
1539
|
inRebase = true;
|
|
1527
1540
|
} catch {
|
|
1528
1541
|
try {
|
|
1529
|
-
await
|
|
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 =
|
|
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((
|
|
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
|
-
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
2020
|
-
|
|
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(
|
|
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 =
|
|
2468
|
+
exports2.getConfigDir = getConfigDir5;
|
|
2133
2469
|
exports2.getConfigPath = getConfigPath4;
|
|
2134
|
-
exports2.loadConfig =
|
|
2135
|
-
exports2.saveConfig =
|
|
2470
|
+
exports2.loadConfig = loadConfig7;
|
|
2471
|
+
exports2.saveConfig = saveConfig5;
|
|
2136
2472
|
exports2.validateToken = validateToken;
|
|
2137
|
-
var
|
|
2138
|
-
var
|
|
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
|
|
2143
|
-
return process.env.EPISODA_CONFIG_DIR ||
|
|
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
|
|
2485
|
+
return path14.join(getConfigDir5(), DEFAULT_CONFIG_FILE);
|
|
2150
2486
|
}
|
|
2151
2487
|
function ensureConfigDir(configPath) {
|
|
2152
|
-
const dir =
|
|
2153
|
-
const isNew = !
|
|
2488
|
+
const dir = path14.dirname(configPath);
|
|
2489
|
+
const isNew = !fs12.existsSync(dir);
|
|
2154
2490
|
if (isNew) {
|
|
2155
|
-
|
|
2491
|
+
fs12.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
2156
2492
|
}
|
|
2157
2493
|
if (process.platform === "darwin") {
|
|
2158
|
-
const nosyncPath =
|
|
2159
|
-
if (isNew || !
|
|
2494
|
+
const nosyncPath = path14.join(dir, ".nosync");
|
|
2495
|
+
if (isNew || !fs12.existsSync(nosyncPath)) {
|
|
2160
2496
|
try {
|
|
2161
|
-
|
|
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
|
|
2507
|
+
async function loadConfig7(configPath) {
|
|
2172
2508
|
const fullPath = getConfigPath4(configPath);
|
|
2173
|
-
if (!
|
|
2509
|
+
if (!fs12.existsSync(fullPath)) {
|
|
2174
2510
|
return null;
|
|
2175
2511
|
}
|
|
2176
2512
|
try {
|
|
2177
|
-
const content =
|
|
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
|
|
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
|
-
|
|
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
|
|
2637
|
+
var import_core15 = __toESM(require_dist());
|
|
2297
2638
|
|
|
2298
2639
|
// src/commands/dev.ts
|
|
2299
|
-
var
|
|
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((
|
|
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((
|
|
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((
|
|
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
|
-
|
|
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
|
|
2773
|
-
var
|
|
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((
|
|
3120
|
+
return new Promise((resolve4) => {
|
|
2779
3121
|
const server = net2.createServer();
|
|
2780
3122
|
server.once("error", (err) => {
|
|
2781
3123
|
if (err.code === "EADDRINUSE") {
|
|
2782
|
-
|
|
3124
|
+
resolve4(true);
|
|
2783
3125
|
} else {
|
|
2784
|
-
|
|
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
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
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
|
|
2795
|
-
|
|
2796
|
-
|
|
3983
|
+
function extractModuleUid(branchName) {
|
|
3984
|
+
const match = branchName.match(/ep(\d+)/i);
|
|
3985
|
+
if (match) {
|
|
3986
|
+
return `EP${match[1]}`;
|
|
2797
3987
|
}
|
|
2798
|
-
return
|
|
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,
|
|
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,
|
|
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((
|
|
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 ||
|
|
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,
|
|
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((
|
|
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,
|
|
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
|
|
3042
|
-
var
|
|
3043
|
-
var
|
|
3044
|
-
var
|
|
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
|
|
3048
|
-
var
|
|
4254
|
+
var fs7 = __toESM(require("fs"));
|
|
4255
|
+
var path8 = __toESM(require("path"));
|
|
3049
4256
|
var crypto2 = __toESM(require("crypto"));
|
|
3050
|
-
var
|
|
3051
|
-
var
|
|
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 =
|
|
4264
|
+
const machineIdPath = path8.join((0, import_core7.getConfigDir)(), "machine-id");
|
|
3058
4265
|
try {
|
|
3059
|
-
if (
|
|
3060
|
-
const existingId =
|
|
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
|
-
|
|
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 =
|
|
3077
|
-
if (!
|
|
3078
|
-
|
|
4283
|
+
const dir = path8.dirname(machineIdPath);
|
|
4284
|
+
if (!fs7.existsSync(dir)) {
|
|
4285
|
+
fs7.mkdirSync(dir, { recursive: true });
|
|
3079
4286
|
}
|
|
3080
|
-
|
|
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,
|
|
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 (
|
|
3098
|
-
const machineId =
|
|
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 (
|
|
3104
|
-
const dbusId =
|
|
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,
|
|
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,
|
|
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,
|
|
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((
|
|
4750
|
+
return new Promise((resolve4) => {
|
|
3541
4751
|
const timeout = setTimeout(() => {
|
|
3542
4752
|
status.error("Authorization timed out");
|
|
3543
|
-
|
|
4753
|
+
resolve4(false);
|
|
3544
4754
|
}, expiresIn * 1e3);
|
|
3545
4755
|
const url = `${apiUrl}/api/oauth/authorize-stream?device_code=${deviceCode}`;
|
|
3546
|
-
const curlProcess = (0,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
3662
|
-
const helperPath =
|
|
3663
|
-
|
|
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
|
-
|
|
4875
|
+
fs8.writeFileSync(helperPath, scriptContent, { mode: 493 });
|
|
3666
4876
|
try {
|
|
3667
|
-
|
|
4877
|
+
fs8.accessSync(helperPath, fs8.constants.X_OK);
|
|
3668
4878
|
} catch {
|
|
3669
4879
|
}
|
|
3670
4880
|
try {
|
|
3671
|
-
const allHelpers = (0,
|
|
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,
|
|
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
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
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 (
|
|
3706
|
-
const content =
|
|
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
|
-
|
|
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
|
|
3723
|
-
var
|
|
3724
|
-
var
|
|
3725
|
-
var
|
|
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,
|
|
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,
|
|
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 =
|
|
3800
|
-
const helperPath =
|
|
3801
|
-
|
|
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
|
-
|
|
5012
|
+
fs9.writeFileSync(helperPath, scriptContent, { mode: 493 });
|
|
3804
5013
|
try {
|
|
3805
|
-
const allHelpers = (0,
|
|
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,
|
|
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
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
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 (
|
|
3840
|
-
const content =
|
|
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
|
-
|
|
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
|
|
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,
|
|
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: ${
|
|
3875
|
-
status.info(` Config file: ${(0,
|
|
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(
|
|
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) {
|