episoda 0.2.19 → 0.2.20
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 +1893 -129
- 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 =
|
|
2470
|
+
exports2.loadConfig = loadConfig7;
|
|
2135
2471
|
exports2.saveConfig = saveConfig4;
|
|
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) {
|
|
@@ -2187,7 +2523,7 @@ var require_auth = __commonJS({
|
|
|
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,824 @@ 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);
|
|
2785
3127
|
}
|
|
2786
3128
|
});
|
|
2787
3129
|
server.once("listening", () => {
|
|
2788
3130
|
server.close();
|
|
2789
|
-
|
|
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 projectData = await response.json();
|
|
3772
|
+
workspaceSlug = projectData.workspace_slug || workspaceSlug;
|
|
3773
|
+
projectSlug = projectData.slug || projectSlug;
|
|
3774
|
+
}
|
|
3775
|
+
} catch {
|
|
3776
|
+
}
|
|
3777
|
+
const targetPath = getProjectPath(workspaceSlug, projectSlug);
|
|
3778
|
+
status.info("Migration Plan:");
|
|
3779
|
+
status.info(` Current path: ${cwd}`);
|
|
3780
|
+
status.info(` Target path: ${targetPath}`);
|
|
3781
|
+
status.info(` Remote URL: ${remoteUrl}`);
|
|
3782
|
+
status.info(` Current branch: ${currentBranch}`);
|
|
3783
|
+
if (hasUncommitted) {
|
|
3784
|
+
status.warning(` Uncommitted changes will be stashed`);
|
|
3785
|
+
}
|
|
3786
|
+
status.info("");
|
|
3787
|
+
if (options.dryRun) {
|
|
3788
|
+
status.info("Dry run complete. No changes made.");
|
|
3789
|
+
status.info("Remove --dry-run to perform the migration.");
|
|
3790
|
+
return;
|
|
3791
|
+
}
|
|
3792
|
+
const uncommittedFiles = statusResult.details?.uncommittedFiles || [];
|
|
3793
|
+
if (hasUncommitted) {
|
|
3794
|
+
status.info(`Found ${uncommittedFiles.length} uncommitted file(s) to preserve...`);
|
|
3795
|
+
}
|
|
3796
|
+
try {
|
|
3797
|
+
status.info("Creating target directory...");
|
|
3798
|
+
const episodaRoot = getEpisodaRoot();
|
|
3799
|
+
fs5.mkdirSync(targetPath, { recursive: true });
|
|
3800
|
+
status.info("Cloning as bare repository...");
|
|
3801
|
+
const bareRepoPath = path6.join(targetPath, ".bare");
|
|
3802
|
+
const cloneResult = await gitExecutor.execute({
|
|
3803
|
+
action: "clone_bare",
|
|
3804
|
+
url: remoteUrl,
|
|
3805
|
+
path: bareRepoPath
|
|
3806
|
+
});
|
|
3807
|
+
if (!cloneResult.success) {
|
|
3808
|
+
throw new Error(`Failed to clone: ${cloneResult.output}`);
|
|
3809
|
+
}
|
|
3810
|
+
status.success("\u2713 Bare repository cloned");
|
|
3811
|
+
status.info("Creating worktree configuration...");
|
|
3812
|
+
const episodaDir = path6.join(targetPath, ".episoda");
|
|
3813
|
+
fs5.mkdirSync(episodaDir, { recursive: true });
|
|
3814
|
+
const worktreeConfig = {
|
|
3815
|
+
projectId,
|
|
3816
|
+
workspaceSlug,
|
|
3817
|
+
projectSlug,
|
|
3818
|
+
bareRepoPath,
|
|
3819
|
+
worktreeMode: true,
|
|
3820
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3821
|
+
worktrees: []
|
|
3822
|
+
};
|
|
3823
|
+
fs5.writeFileSync(
|
|
3824
|
+
path6.join(episodaDir, "config.json"),
|
|
3825
|
+
JSON.stringify(worktreeConfig, null, 2),
|
|
3826
|
+
"utf-8"
|
|
3827
|
+
);
|
|
3828
|
+
status.info(`Creating worktree for branch "${currentBranch}"...`);
|
|
3829
|
+
const moduleUid = extractModuleUid(currentBranch) || "main";
|
|
3830
|
+
const worktreePath = path6.join(targetPath, moduleUid);
|
|
3831
|
+
const worktreeResult = await gitExecutor.execute({
|
|
3832
|
+
action: "worktree_add",
|
|
3833
|
+
path: worktreePath,
|
|
3834
|
+
branch: currentBranch,
|
|
3835
|
+
create: false
|
|
3836
|
+
}, { cwd: bareRepoPath });
|
|
3837
|
+
if (!worktreeResult.success) {
|
|
3838
|
+
throw new Error(`Failed to create worktree: ${worktreeResult.output}`);
|
|
3839
|
+
}
|
|
3840
|
+
status.success(`\u2713 Worktree created at ${worktreePath}`);
|
|
3841
|
+
worktreeConfig.worktrees.push({
|
|
3842
|
+
moduleUid,
|
|
3843
|
+
branchName: currentBranch,
|
|
3844
|
+
worktreePath,
|
|
3845
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3846
|
+
lastAccessed: (/* @__PURE__ */ new Date()).toISOString()
|
|
3847
|
+
});
|
|
3848
|
+
fs5.writeFileSync(
|
|
3849
|
+
path6.join(episodaDir, "config.json"),
|
|
3850
|
+
JSON.stringify(worktreeConfig, null, 2),
|
|
3851
|
+
"utf-8"
|
|
3852
|
+
);
|
|
3853
|
+
if (hasUncommitted && uncommittedFiles.length > 0) {
|
|
3854
|
+
status.info("Copying uncommitted changes to new worktree...");
|
|
3855
|
+
let copiedCount = 0;
|
|
3856
|
+
let failedCount = 0;
|
|
3857
|
+
for (const file of uncommittedFiles) {
|
|
3858
|
+
const sourcePath = path6.join(cwd, file);
|
|
3859
|
+
const destPath = path6.join(worktreePath, file);
|
|
3860
|
+
try {
|
|
3861
|
+
if (fs5.existsSync(sourcePath)) {
|
|
3862
|
+
const destDir = path6.dirname(destPath);
|
|
3863
|
+
if (!fs5.existsSync(destDir)) {
|
|
3864
|
+
fs5.mkdirSync(destDir, { recursive: true });
|
|
3865
|
+
}
|
|
3866
|
+
fs5.copyFileSync(sourcePath, destPath);
|
|
3867
|
+
copiedCount++;
|
|
3868
|
+
}
|
|
3869
|
+
} catch (copyError) {
|
|
3870
|
+
failedCount++;
|
|
3871
|
+
status.warning(`Could not copy: ${file}`);
|
|
3872
|
+
}
|
|
3873
|
+
}
|
|
3874
|
+
if (copiedCount > 0) {
|
|
3875
|
+
status.success(`\u2713 Copied ${copiedCount} uncommitted file(s)`);
|
|
3876
|
+
}
|
|
3877
|
+
if (failedCount > 0) {
|
|
3878
|
+
status.warning(`${failedCount} file(s) could not be copied`);
|
|
3879
|
+
status.info("You may need to copy some files manually from the original directory");
|
|
3880
|
+
}
|
|
3881
|
+
}
|
|
3882
|
+
addProject2(projectId, targetPath, {
|
|
3883
|
+
worktreeMode: true,
|
|
3884
|
+
bareRepoPath
|
|
2790
3885
|
});
|
|
2791
|
-
|
|
2792
|
-
|
|
3886
|
+
status.success("\u2713 Project registered with daemon");
|
|
3887
|
+
status.info("");
|
|
3888
|
+
status.success("Migration complete!");
|
|
3889
|
+
status.info("");
|
|
3890
|
+
status.info("New project structure:");
|
|
3891
|
+
status.info(` ${targetPath}/`);
|
|
3892
|
+
status.info(" \u251C\u2500\u2500 .bare/ # Git repository");
|
|
3893
|
+
status.info(" \u251C\u2500\u2500 .episoda/");
|
|
3894
|
+
status.info(" \u2502 \u2514\u2500\u2500 config.json");
|
|
3895
|
+
status.info(` \u2514\u2500\u2500 ${moduleUid}/ # Your current worktree`);
|
|
3896
|
+
status.info("");
|
|
3897
|
+
status.info("Next steps:");
|
|
3898
|
+
status.info(` 1. cd ${worktreePath}`);
|
|
3899
|
+
status.info(" 2. Continue working on your current branch");
|
|
3900
|
+
status.info(" 3. Use `episoda checkout EP###` for other modules");
|
|
3901
|
+
status.info("");
|
|
3902
|
+
status.info("Your original repository at:");
|
|
3903
|
+
status.info(` ${cwd}`);
|
|
3904
|
+
status.info("can be safely removed after verifying the migration.");
|
|
3905
|
+
status.info("");
|
|
3906
|
+
} catch (error) {
|
|
3907
|
+
status.error("Migration failed, cleaning up...");
|
|
3908
|
+
if (fs5.existsSync(targetPath)) {
|
|
3909
|
+
try {
|
|
3910
|
+
fs5.rmSync(targetPath, { recursive: true, force: true });
|
|
3911
|
+
} catch {
|
|
3912
|
+
status.warning(`Could not remove ${targetPath}`);
|
|
3913
|
+
}
|
|
3914
|
+
}
|
|
3915
|
+
throw error;
|
|
3916
|
+
}
|
|
2793
3917
|
}
|
|
2794
|
-
function
|
|
2795
|
-
|
|
2796
|
-
|
|
3918
|
+
function extractModuleUid(branchName) {
|
|
3919
|
+
const match = branchName.match(/ep(\d+)/i);
|
|
3920
|
+
if (match) {
|
|
3921
|
+
return `EP${match[1]}`;
|
|
2797
3922
|
}
|
|
2798
|
-
return
|
|
3923
|
+
return null;
|
|
2799
3924
|
}
|
|
2800
3925
|
|
|
2801
3926
|
// src/commands/dev.ts
|
|
2802
3927
|
var CONNECTION_MAX_RETRIES = 3;
|
|
2803
3928
|
function findGitRoot(startDir) {
|
|
2804
3929
|
try {
|
|
2805
|
-
const result = (0,
|
|
3930
|
+
const result = (0, import_child_process3.execSync)("git rev-parse --show-toplevel", {
|
|
2806
3931
|
cwd: startDir,
|
|
2807
3932
|
encoding: "utf-8",
|
|
2808
3933
|
stdio: ["pipe", "pipe", "pipe"]
|
|
@@ -2814,7 +3939,7 @@ function findGitRoot(startDir) {
|
|
|
2814
3939
|
}
|
|
2815
3940
|
async function devCommand(options = {}) {
|
|
2816
3941
|
try {
|
|
2817
|
-
const config = await (0,
|
|
3942
|
+
const config = await (0, import_core6.loadConfig)();
|
|
2818
3943
|
if (!config || !config.access_token || !config.project_id) {
|
|
2819
3944
|
status.error("No authentication found. Please run:");
|
|
2820
3945
|
status.info("");
|
|
@@ -2853,7 +3978,7 @@ async function devCommand(options = {}) {
|
|
|
2853
3978
|
const killedCount = killAllEpisodaProcesses();
|
|
2854
3979
|
if (killedCount > 0) {
|
|
2855
3980
|
status.info(`Cleaned up ${killedCount} stale process${killedCount > 1 ? "es" : ""}`);
|
|
2856
|
-
await new Promise((
|
|
3981
|
+
await new Promise((resolve4) => setTimeout(resolve4, 2e3));
|
|
2857
3982
|
}
|
|
2858
3983
|
}
|
|
2859
3984
|
const serverUrl = config.api_url || process.env.EPISODA_API_URL || "https://episoda.dev";
|
|
@@ -2866,7 +3991,7 @@ async function devCommand(options = {}) {
|
|
|
2866
3991
|
status.debug(`Using cached project path: ${projectPath}`);
|
|
2867
3992
|
} else {
|
|
2868
3993
|
const detectedRoot = findGitRoot(options.cwd || process.cwd());
|
|
2869
|
-
projectPath = detectedRoot ||
|
|
3994
|
+
projectPath = detectedRoot || path7.resolve(options.cwd || process.cwd());
|
|
2870
3995
|
if (detectedRoot) {
|
|
2871
3996
|
status.debug(`Detected project root: ${projectPath}`);
|
|
2872
3997
|
} else {
|
|
@@ -2880,7 +4005,7 @@ async function devCommand(options = {}) {
|
|
|
2880
4005
|
cached_at: Date.now()
|
|
2881
4006
|
}
|
|
2882
4007
|
};
|
|
2883
|
-
await (0,
|
|
4008
|
+
await (0, import_core6.saveConfig)(updatedConfig);
|
|
2884
4009
|
status.debug("Cached project settings locally");
|
|
2885
4010
|
const settingsUrl = `${serverUrl}/api/projects/${config.project_id}/settings`;
|
|
2886
4011
|
fetch(settingsUrl, {
|
|
@@ -2893,6 +4018,23 @@ async function devCommand(options = {}) {
|
|
|
2893
4018
|
}).catch(() => {
|
|
2894
4019
|
});
|
|
2895
4020
|
}
|
|
4021
|
+
const hasGitDir = fs6.existsSync(path7.join(projectPath, ".git"));
|
|
4022
|
+
const isWorktree = await isWorktreeProject(projectPath);
|
|
4023
|
+
if (hasGitDir && !isWorktree) {
|
|
4024
|
+
status.info("");
|
|
4025
|
+
status.info("EP944: Migrating project to worktree mode...");
|
|
4026
|
+
status.info("This is a one-time operation that enables multi-module development.");
|
|
4027
|
+
status.info("");
|
|
4028
|
+
try {
|
|
4029
|
+
await migrateCommand({ cwd: projectPath, silent: false });
|
|
4030
|
+
status.success("Project migrated to worktree mode!");
|
|
4031
|
+
status.info("");
|
|
4032
|
+
} catch (error) {
|
|
4033
|
+
status.warning(`Migration skipped: ${error.message}`);
|
|
4034
|
+
status.info("You can run `episoda migrate` manually later.");
|
|
4035
|
+
status.info("");
|
|
4036
|
+
}
|
|
4037
|
+
}
|
|
2896
4038
|
let daemonPid = isDaemonRunning();
|
|
2897
4039
|
if (!daemonPid) {
|
|
2898
4040
|
status.info("Starting Episoda daemon...");
|
|
@@ -2917,7 +4059,7 @@ async function devCommand(options = {}) {
|
|
|
2917
4059
|
for (let retry = 0; retry < CONNECTION_MAX_RETRIES && !connected; retry++) {
|
|
2918
4060
|
if (retry > 0) {
|
|
2919
4061
|
status.info(`Retrying connection (attempt ${retry + 1}/${CONNECTION_MAX_RETRIES})...`);
|
|
2920
|
-
await new Promise((
|
|
4062
|
+
await new Promise((resolve4) => setTimeout(resolve4, 1e3));
|
|
2921
4063
|
}
|
|
2922
4064
|
try {
|
|
2923
4065
|
const result = await addProject(config.project_id, projectPath);
|
|
@@ -2985,7 +4127,7 @@ async function runDevServer(command, cwd, autoRestart) {
|
|
|
2985
4127
|
let shuttingDown = false;
|
|
2986
4128
|
const startServer = () => {
|
|
2987
4129
|
status.info(`Starting dev server: ${command.join(" ")}`);
|
|
2988
|
-
devProcess = (0,
|
|
4130
|
+
devProcess = (0, import_child_process3.spawn)(command[0], command.slice(1), {
|
|
2989
4131
|
cwd,
|
|
2990
4132
|
stdio: ["inherit", "inherit", "inherit"],
|
|
2991
4133
|
shell: true
|
|
@@ -3038,33 +4180,33 @@ Received ${signal}, shutting down...`);
|
|
|
3038
4180
|
|
|
3039
4181
|
// src/commands/auth.ts
|
|
3040
4182
|
var os = __toESM(require("os"));
|
|
3041
|
-
var
|
|
3042
|
-
var
|
|
3043
|
-
var
|
|
3044
|
-
var
|
|
4183
|
+
var fs8 = __toESM(require("fs"));
|
|
4184
|
+
var path9 = __toESM(require("path"));
|
|
4185
|
+
var import_child_process5 = require("child_process");
|
|
4186
|
+
var import_core8 = __toESM(require_dist());
|
|
3045
4187
|
|
|
3046
4188
|
// src/daemon/machine-id.ts
|
|
3047
|
-
var
|
|
3048
|
-
var
|
|
4189
|
+
var fs7 = __toESM(require("fs"));
|
|
4190
|
+
var path8 = __toESM(require("path"));
|
|
3049
4191
|
var crypto2 = __toESM(require("crypto"));
|
|
3050
|
-
var
|
|
3051
|
-
var
|
|
4192
|
+
var import_child_process4 = require("child_process");
|
|
4193
|
+
var import_core7 = __toESM(require_dist());
|
|
3052
4194
|
function isValidUUID(str) {
|
|
3053
4195
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
3054
4196
|
return uuidRegex.test(str);
|
|
3055
4197
|
}
|
|
3056
4198
|
async function getMachineId() {
|
|
3057
|
-
const machineIdPath =
|
|
4199
|
+
const machineIdPath = path8.join((0, import_core7.getConfigDir)(), "machine-id");
|
|
3058
4200
|
try {
|
|
3059
|
-
if (
|
|
3060
|
-
const existingId =
|
|
4201
|
+
if (fs7.existsSync(machineIdPath)) {
|
|
4202
|
+
const existingId = fs7.readFileSync(machineIdPath, "utf-8").trim();
|
|
3061
4203
|
if (existingId) {
|
|
3062
4204
|
if (isValidUUID(existingId)) {
|
|
3063
4205
|
return existingId;
|
|
3064
4206
|
}
|
|
3065
4207
|
console.log("[MachineId] Migrating legacy machine ID to UUID format...");
|
|
3066
4208
|
const newUUID = generateMachineId();
|
|
3067
|
-
|
|
4209
|
+
fs7.writeFileSync(machineIdPath, newUUID, "utf-8");
|
|
3068
4210
|
console.log(`[MachineId] Migrated: ${existingId} \u2192 ${newUUID}`);
|
|
3069
4211
|
return newUUID;
|
|
3070
4212
|
}
|
|
@@ -3073,11 +4215,11 @@ async function getMachineId() {
|
|
|
3073
4215
|
}
|
|
3074
4216
|
const machineId = generateMachineId();
|
|
3075
4217
|
try {
|
|
3076
|
-
const dir =
|
|
3077
|
-
if (!
|
|
3078
|
-
|
|
4218
|
+
const dir = path8.dirname(machineIdPath);
|
|
4219
|
+
if (!fs7.existsSync(dir)) {
|
|
4220
|
+
fs7.mkdirSync(dir, { recursive: true });
|
|
3079
4221
|
}
|
|
3080
|
-
|
|
4222
|
+
fs7.writeFileSync(machineIdPath, machineId, "utf-8");
|
|
3081
4223
|
} catch (error) {
|
|
3082
4224
|
console.error("Warning: Could not save machine ID to disk:", error);
|
|
3083
4225
|
}
|
|
@@ -3086,7 +4228,7 @@ async function getMachineId() {
|
|
|
3086
4228
|
function getHardwareUUID() {
|
|
3087
4229
|
try {
|
|
3088
4230
|
if (process.platform === "darwin") {
|
|
3089
|
-
const output = (0,
|
|
4231
|
+
const output = (0, import_child_process4.execSync)(
|
|
3090
4232
|
`ioreg -d2 -c IOPlatformExpertDevice | awk -F\\" '/IOPlatformUUID/{print $(NF-1)}'`,
|
|
3091
4233
|
{ encoding: "utf-8", timeout: 5e3 }
|
|
3092
4234
|
).trim();
|
|
@@ -3094,20 +4236,20 @@ function getHardwareUUID() {
|
|
|
3094
4236
|
return output;
|
|
3095
4237
|
}
|
|
3096
4238
|
} else if (process.platform === "linux") {
|
|
3097
|
-
if (
|
|
3098
|
-
const machineId =
|
|
4239
|
+
if (fs7.existsSync("/etc/machine-id")) {
|
|
4240
|
+
const machineId = fs7.readFileSync("/etc/machine-id", "utf-8").trim();
|
|
3099
4241
|
if (machineId && machineId.length > 0) {
|
|
3100
4242
|
return machineId;
|
|
3101
4243
|
}
|
|
3102
4244
|
}
|
|
3103
|
-
if (
|
|
3104
|
-
const dbusId =
|
|
4245
|
+
if (fs7.existsSync("/var/lib/dbus/machine-id")) {
|
|
4246
|
+
const dbusId = fs7.readFileSync("/var/lib/dbus/machine-id", "utf-8").trim();
|
|
3105
4247
|
if (dbusId && dbusId.length > 0) {
|
|
3106
4248
|
return dbusId;
|
|
3107
4249
|
}
|
|
3108
4250
|
}
|
|
3109
4251
|
} else if (process.platform === "win32") {
|
|
3110
|
-
const output = (0,
|
|
4252
|
+
const output = (0, import_child_process4.execSync)("wmic csproduct get uuid", {
|
|
3111
4253
|
encoding: "utf-8",
|
|
3112
4254
|
timeout: 5e3
|
|
3113
4255
|
});
|
|
@@ -3488,18 +4630,21 @@ async function authCommand(options = {}) {
|
|
|
3488
4630
|
status.info(` Project: ${tokenResponse.project_uid}`);
|
|
3489
4631
|
status.info(` Workspace: ${tokenResponse.workspace_uid}`);
|
|
3490
4632
|
status.info("");
|
|
3491
|
-
await (0,
|
|
4633
|
+
await (0, import_core8.saveConfig)({
|
|
3492
4634
|
project_id: tokenResponse.project_id,
|
|
3493
4635
|
user_id: tokenResponse.user_id,
|
|
3494
4636
|
workspace_id: tokenResponse.workspace_id,
|
|
4637
|
+
workspace_slug: tokenResponse.workspace_uid,
|
|
4638
|
+
// EP956: For worktree paths
|
|
4639
|
+
project_slug: tokenResponse.project_uid,
|
|
4640
|
+
// EP956: For worktree paths
|
|
3495
4641
|
access_token: tokenResponse.access_token,
|
|
3496
4642
|
refresh_token: tokenResponse.refresh_token,
|
|
3497
4643
|
expires_at: Date.now() + tokenResponse.expires_in * 1e3,
|
|
3498
4644
|
// Convert to Unix timestamp
|
|
3499
|
-
api_url: apiUrl
|
|
3500
|
-
cli_version: import_core5.VERSION
|
|
4645
|
+
api_url: apiUrl
|
|
3501
4646
|
});
|
|
3502
|
-
status.success(`\u2713 Configuration saved to ${(0,
|
|
4647
|
+
status.success(`\u2713 Configuration saved to ${(0, import_core8.getConfigPath)()}`);
|
|
3503
4648
|
status.info("");
|
|
3504
4649
|
status.info("Installing git credential helper...");
|
|
3505
4650
|
const credentialHelperInstalled = await installGitCredentialHelper(apiUrl);
|
|
@@ -3537,13 +4682,13 @@ async function initiateDeviceFlow(apiUrl, machineId) {
|
|
|
3537
4682
|
return await response.json();
|
|
3538
4683
|
}
|
|
3539
4684
|
async function monitorAuthorization(apiUrl, deviceCode, expiresIn) {
|
|
3540
|
-
return new Promise((
|
|
4685
|
+
return new Promise((resolve4) => {
|
|
3541
4686
|
const timeout = setTimeout(() => {
|
|
3542
4687
|
status.error("Authorization timed out");
|
|
3543
|
-
|
|
4688
|
+
resolve4(false);
|
|
3544
4689
|
}, expiresIn * 1e3);
|
|
3545
4690
|
const url = `${apiUrl}/api/oauth/authorize-stream?device_code=${deviceCode}`;
|
|
3546
|
-
const curlProcess = (0,
|
|
4691
|
+
const curlProcess = (0, import_child_process5.spawn)("curl", ["-N", url]);
|
|
3547
4692
|
let buffer = "";
|
|
3548
4693
|
curlProcess.stdout.on("data", (chunk) => {
|
|
3549
4694
|
buffer += chunk.toString();
|
|
@@ -3566,26 +4711,26 @@ async function monitorAuthorization(apiUrl, deviceCode, expiresIn) {
|
|
|
3566
4711
|
if (eventType === "authorized") {
|
|
3567
4712
|
clearTimeout(timeout);
|
|
3568
4713
|
curlProcess.kill();
|
|
3569
|
-
|
|
4714
|
+
resolve4(true);
|
|
3570
4715
|
return;
|
|
3571
4716
|
} else if (eventType === "denied") {
|
|
3572
4717
|
clearTimeout(timeout);
|
|
3573
4718
|
curlProcess.kill();
|
|
3574
4719
|
status.error("Authorization denied by user");
|
|
3575
|
-
|
|
4720
|
+
resolve4(false);
|
|
3576
4721
|
return;
|
|
3577
4722
|
} else if (eventType === "expired") {
|
|
3578
4723
|
clearTimeout(timeout);
|
|
3579
4724
|
curlProcess.kill();
|
|
3580
4725
|
status.error("Authorization code expired");
|
|
3581
|
-
|
|
4726
|
+
resolve4(false);
|
|
3582
4727
|
return;
|
|
3583
4728
|
} else if (eventType === "error") {
|
|
3584
4729
|
const errorData = JSON.parse(data);
|
|
3585
4730
|
clearTimeout(timeout);
|
|
3586
4731
|
curlProcess.kill();
|
|
3587
4732
|
status.error(`Authorization error: ${errorData.error_description || errorData.error || "Unknown error"}`);
|
|
3588
|
-
|
|
4733
|
+
resolve4(false);
|
|
3589
4734
|
return;
|
|
3590
4735
|
}
|
|
3591
4736
|
} catch (error) {
|
|
@@ -3595,13 +4740,13 @@ async function monitorAuthorization(apiUrl, deviceCode, expiresIn) {
|
|
|
3595
4740
|
curlProcess.on("error", (error) => {
|
|
3596
4741
|
clearTimeout(timeout);
|
|
3597
4742
|
status.error(`Failed to monitor authorization: ${error.message}`);
|
|
3598
|
-
|
|
4743
|
+
resolve4(false);
|
|
3599
4744
|
});
|
|
3600
4745
|
curlProcess.on("close", (code) => {
|
|
3601
4746
|
clearTimeout(timeout);
|
|
3602
4747
|
if (code !== 0 && code !== null) {
|
|
3603
4748
|
status.error(`Authorization monitoring failed with code ${code}`);
|
|
3604
|
-
|
|
4749
|
+
resolve4(false);
|
|
3605
4750
|
}
|
|
3606
4751
|
});
|
|
3607
4752
|
});
|
|
@@ -3647,7 +4792,7 @@ function openBrowser(url) {
|
|
|
3647
4792
|
break;
|
|
3648
4793
|
}
|
|
3649
4794
|
try {
|
|
3650
|
-
(0,
|
|
4795
|
+
(0, import_child_process5.spawn)(command, args, {
|
|
3651
4796
|
detached: true,
|
|
3652
4797
|
stdio: "ignore"
|
|
3653
4798
|
}).unref();
|
|
@@ -3658,17 +4803,17 @@ function openBrowser(url) {
|
|
|
3658
4803
|
async function installGitCredentialHelper(apiUrl) {
|
|
3659
4804
|
try {
|
|
3660
4805
|
const homeDir = os.homedir();
|
|
3661
|
-
const episodaBinDir =
|
|
3662
|
-
const helperPath =
|
|
3663
|
-
|
|
4806
|
+
const episodaBinDir = path9.join(homeDir, ".episoda", "bin");
|
|
4807
|
+
const helperPath = path9.join(episodaBinDir, "git-credential-episoda");
|
|
4808
|
+
fs8.mkdirSync(episodaBinDir, { recursive: true });
|
|
3664
4809
|
const scriptContent = generateCredentialHelperScript(apiUrl);
|
|
3665
|
-
|
|
4810
|
+
fs8.writeFileSync(helperPath, scriptContent, { mode: 493 });
|
|
3666
4811
|
try {
|
|
3667
|
-
|
|
4812
|
+
fs8.accessSync(helperPath, fs8.constants.X_OK);
|
|
3668
4813
|
} catch {
|
|
3669
4814
|
}
|
|
3670
4815
|
try {
|
|
3671
|
-
const allHelpers = (0,
|
|
4816
|
+
const allHelpers = (0, import_child_process5.execSync)("git config --global --get-all credential.helper", {
|
|
3672
4817
|
encoding: "utf8",
|
|
3673
4818
|
stdio: ["pipe", "pipe", "pipe"]
|
|
3674
4819
|
}).trim().split("\n");
|
|
@@ -3677,7 +4822,7 @@ async function installGitCredentialHelper(apiUrl) {
|
|
|
3677
4822
|
}
|
|
3678
4823
|
} catch {
|
|
3679
4824
|
}
|
|
3680
|
-
(0,
|
|
4825
|
+
(0, import_child_process5.execSync)(`git config --global --add credential.helper "${helperPath}"`, {
|
|
3681
4826
|
encoding: "utf8",
|
|
3682
4827
|
stdio: ["pipe", "pipe", "pipe"]
|
|
3683
4828
|
});
|
|
@@ -3695,19 +4840,19 @@ function updateShellProfile(binDir) {
|
|
|
3695
4840
|
}
|
|
3696
4841
|
const homeDir = os.homedir();
|
|
3697
4842
|
const profiles = [
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
4843
|
+
path9.join(homeDir, ".bashrc"),
|
|
4844
|
+
path9.join(homeDir, ".zshrc"),
|
|
4845
|
+
path9.join(homeDir, ".profile")
|
|
3701
4846
|
];
|
|
3702
4847
|
const exportLine = `export PATH="${binDir}:$PATH" # Added by episoda auth`;
|
|
3703
4848
|
for (const profile of profiles) {
|
|
3704
4849
|
try {
|
|
3705
|
-
if (
|
|
3706
|
-
const content =
|
|
4850
|
+
if (fs8.existsSync(profile)) {
|
|
4851
|
+
const content = fs8.readFileSync(profile, "utf8");
|
|
3707
4852
|
if (content.includes(".episoda/bin")) {
|
|
3708
4853
|
continue;
|
|
3709
4854
|
}
|
|
3710
|
-
|
|
4855
|
+
fs8.appendFileSync(profile, `
|
|
3711
4856
|
# Episoda CLI
|
|
3712
4857
|
${exportLine}
|
|
3713
4858
|
`);
|
|
@@ -3719,10 +4864,10 @@ ${exportLine}
|
|
|
3719
4864
|
|
|
3720
4865
|
// src/commands/connect.ts
|
|
3721
4866
|
var os2 = __toESM(require("os"));
|
|
3722
|
-
var
|
|
3723
|
-
var
|
|
3724
|
-
var
|
|
3725
|
-
var
|
|
4867
|
+
var fs9 = __toESM(require("fs"));
|
|
4868
|
+
var path10 = __toESM(require("path"));
|
|
4869
|
+
var import_child_process6 = require("child_process");
|
|
4870
|
+
var import_core9 = __toESM(require_dist());
|
|
3726
4871
|
async function connectCommand(options) {
|
|
3727
4872
|
const { code } = options;
|
|
3728
4873
|
const apiUrl = options.apiUrl || process.env.EPISODA_API_URL || "https://episoda.dev";
|
|
@@ -3738,15 +4883,14 @@ async function connectCommand(options) {
|
|
|
3738
4883
|
status.info(` Project: ${tokenResponse.project_uid}`);
|
|
3739
4884
|
status.info(` Workspace: ${tokenResponse.workspace_uid}`);
|
|
3740
4885
|
status.info("");
|
|
3741
|
-
await (0,
|
|
4886
|
+
await (0, import_core9.saveConfig)({
|
|
3742
4887
|
project_id: tokenResponse.project_id,
|
|
3743
4888
|
user_id: tokenResponse.user_id,
|
|
3744
4889
|
workspace_id: tokenResponse.workspace_id,
|
|
3745
4890
|
access_token: tokenResponse.access_token,
|
|
3746
|
-
api_url: apiUrl
|
|
3747
|
-
cli_version: import_core6.VERSION
|
|
4891
|
+
api_url: apiUrl
|
|
3748
4892
|
});
|
|
3749
|
-
status.success(`\u2713 Configuration saved to ${(0,
|
|
4893
|
+
status.success(`\u2713 Configuration saved to ${(0, import_core9.getConfigPath)()}`);
|
|
3750
4894
|
status.info("");
|
|
3751
4895
|
status.info("Installing git credential helper...");
|
|
3752
4896
|
const credentialHelperInstalled = await installGitCredentialHelper2(apiUrl);
|
|
@@ -3796,13 +4940,13 @@ async function exchangeUserCode(apiUrl, userCode, machineId) {
|
|
|
3796
4940
|
async function installGitCredentialHelper2(apiUrl) {
|
|
3797
4941
|
try {
|
|
3798
4942
|
const homeDir = os2.homedir();
|
|
3799
|
-
const episodaBinDir =
|
|
3800
|
-
const helperPath =
|
|
3801
|
-
|
|
4943
|
+
const episodaBinDir = path10.join(homeDir, ".episoda", "bin");
|
|
4944
|
+
const helperPath = path10.join(episodaBinDir, "git-credential-episoda");
|
|
4945
|
+
fs9.mkdirSync(episodaBinDir, { recursive: true });
|
|
3802
4946
|
const scriptContent = generateCredentialHelperScript(apiUrl);
|
|
3803
|
-
|
|
4947
|
+
fs9.writeFileSync(helperPath, scriptContent, { mode: 493 });
|
|
3804
4948
|
try {
|
|
3805
|
-
const allHelpers = (0,
|
|
4949
|
+
const allHelpers = (0, import_child_process6.execSync)("git config --global --get-all credential.helper", {
|
|
3806
4950
|
encoding: "utf8",
|
|
3807
4951
|
stdio: ["pipe", "pipe", "pipe"]
|
|
3808
4952
|
}).trim().split("\n");
|
|
@@ -3811,7 +4955,7 @@ async function installGitCredentialHelper2(apiUrl) {
|
|
|
3811
4955
|
}
|
|
3812
4956
|
} catch {
|
|
3813
4957
|
}
|
|
3814
|
-
(0,
|
|
4958
|
+
(0, import_child_process6.execSync)(`git config --global --add credential.helper "${helperPath}"`, {
|
|
3815
4959
|
encoding: "utf8",
|
|
3816
4960
|
stdio: ["pipe", "pipe", "pipe"]
|
|
3817
4961
|
});
|
|
@@ -3829,19 +4973,19 @@ function updateShellProfile2(binDir) {
|
|
|
3829
4973
|
}
|
|
3830
4974
|
const homeDir = os2.homedir();
|
|
3831
4975
|
const profiles = [
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
4976
|
+
path10.join(homeDir, ".bashrc"),
|
|
4977
|
+
path10.join(homeDir, ".zshrc"),
|
|
4978
|
+
path10.join(homeDir, ".profile")
|
|
3835
4979
|
];
|
|
3836
4980
|
const exportLine = `export PATH="${binDir}:$PATH" # Added by episoda`;
|
|
3837
4981
|
for (const profile of profiles) {
|
|
3838
4982
|
try {
|
|
3839
|
-
if (
|
|
3840
|
-
const content =
|
|
4983
|
+
if (fs9.existsSync(profile)) {
|
|
4984
|
+
const content = fs9.readFileSync(profile, "utf8");
|
|
3841
4985
|
if (content.includes(".episoda/bin")) {
|
|
3842
4986
|
continue;
|
|
3843
4987
|
}
|
|
3844
|
-
|
|
4988
|
+
fs9.appendFileSync(profile, `
|
|
3845
4989
|
# Episoda CLI
|
|
3846
4990
|
${exportLine}
|
|
3847
4991
|
`);
|
|
@@ -3852,11 +4996,11 @@ ${exportLine}
|
|
|
3852
4996
|
}
|
|
3853
4997
|
|
|
3854
4998
|
// src/commands/status.ts
|
|
3855
|
-
var
|
|
4999
|
+
var import_core10 = __toESM(require_dist());
|
|
3856
5000
|
async function statusCommand(options = {}) {
|
|
3857
5001
|
status.info("Checking CLI status...");
|
|
3858
5002
|
status.info("");
|
|
3859
|
-
const config = await (0,
|
|
5003
|
+
const config = await (0, import_core10.loadConfig)();
|
|
3860
5004
|
if (!config) {
|
|
3861
5005
|
status.error("\u2717 CLI not initialized");
|
|
3862
5006
|
status.info("");
|
|
@@ -3871,8 +5015,8 @@ async function statusCommand(options = {}) {
|
|
|
3871
5015
|
status.info("Configuration:");
|
|
3872
5016
|
status.info(` Project ID: ${config.project_id}`);
|
|
3873
5017
|
status.info(` API URL: ${config.api_url}`);
|
|
3874
|
-
status.info(` CLI Version: ${
|
|
3875
|
-
status.info(` Config file: ${(0,
|
|
5018
|
+
status.info(` CLI Version: ${import_core10.VERSION}`);
|
|
5019
|
+
status.info(` Config file: ${(0, import_core10.getConfigPath)()}`);
|
|
3876
5020
|
status.info("");
|
|
3877
5021
|
if (!config.access_token || config.access_token === "") {
|
|
3878
5022
|
status.warning("\u26A0 Not authenticated");
|
|
@@ -4031,8 +5175,588 @@ async function stopCommand(options = {}) {
|
|
|
4031
5175
|
}
|
|
4032
5176
|
}
|
|
4033
5177
|
|
|
5178
|
+
// src/commands/clone.ts
|
|
5179
|
+
var fs10 = __toESM(require("fs"));
|
|
5180
|
+
var path11 = __toESM(require("path"));
|
|
5181
|
+
var import_core11 = __toESM(require_dist());
|
|
5182
|
+
async function cloneCommand(slugArg, options = {}) {
|
|
5183
|
+
const slugParts = slugArg.split("/");
|
|
5184
|
+
if (slugParts.length !== 2 || !slugParts[0] || !slugParts[1]) {
|
|
5185
|
+
throw new Error(
|
|
5186
|
+
"Invalid format. Usage: episoda clone {workspace}/{project}\nExample: episoda clone my-team/my-project"
|
|
5187
|
+
);
|
|
5188
|
+
}
|
|
5189
|
+
const [workspaceSlug, projectSlug] = slugParts;
|
|
5190
|
+
status.info(`Cloning ${workspaceSlug}/${projectSlug}...`);
|
|
5191
|
+
status.info("");
|
|
5192
|
+
const config = await (0, import_core11.loadConfig)();
|
|
5193
|
+
if (!config || !config.access_token) {
|
|
5194
|
+
throw new Error(
|
|
5195
|
+
"Not authenticated. Please run `episoda auth` first."
|
|
5196
|
+
);
|
|
5197
|
+
}
|
|
5198
|
+
const apiUrl = options.apiUrl || config.api_url || "https://episoda.dev";
|
|
5199
|
+
const projectPath = getProjectPath(workspaceSlug, projectSlug);
|
|
5200
|
+
if (fs10.existsSync(projectPath)) {
|
|
5201
|
+
const bareRepoPath = path11.join(projectPath, ".bare");
|
|
5202
|
+
if (fs10.existsSync(bareRepoPath)) {
|
|
5203
|
+
status.warning(`Project already cloned at ${projectPath}`);
|
|
5204
|
+
status.info("");
|
|
5205
|
+
status.info("Next steps:");
|
|
5206
|
+
status.info(` \u2022 cd ${projectPath}`);
|
|
5207
|
+
status.info(" \u2022 episoda checkout {module}");
|
|
5208
|
+
return;
|
|
5209
|
+
}
|
|
5210
|
+
throw new Error(
|
|
5211
|
+
`Directory exists but is not a worktree project: ${projectPath}
|
|
5212
|
+
Please remove it manually or use a different location.`
|
|
5213
|
+
);
|
|
5214
|
+
}
|
|
5215
|
+
status.info("Fetching project details...");
|
|
5216
|
+
const projectDetails = await fetchProjectDetails(
|
|
5217
|
+
apiUrl,
|
|
5218
|
+
workspaceSlug,
|
|
5219
|
+
projectSlug,
|
|
5220
|
+
config.access_token
|
|
5221
|
+
);
|
|
5222
|
+
if (!projectDetails.repo_url) {
|
|
5223
|
+
throw new Error(
|
|
5224
|
+
`Project "${projectSlug}" has no repository configured.
|
|
5225
|
+
Please configure a repository in the project settings on episoda.dev.`
|
|
5226
|
+
);
|
|
5227
|
+
}
|
|
5228
|
+
validateRepoUrl(projectDetails.repo_url);
|
|
5229
|
+
status.success(`\u2713 Found project: ${projectDetails.name}`);
|
|
5230
|
+
status.info(` Repository: ${projectDetails.repo_url}`);
|
|
5231
|
+
status.info("");
|
|
5232
|
+
status.info("Creating project directory...");
|
|
5233
|
+
const episodaRoot = getEpisodaRoot();
|
|
5234
|
+
fs10.mkdirSync(projectPath, { recursive: true });
|
|
5235
|
+
status.success(`\u2713 Created ${projectPath}`);
|
|
5236
|
+
status.info("Cloning repository (bare)...");
|
|
5237
|
+
try {
|
|
5238
|
+
const worktreeManager = await WorktreeManager.createProject(
|
|
5239
|
+
projectPath,
|
|
5240
|
+
projectDetails.repo_url,
|
|
5241
|
+
projectDetails.id,
|
|
5242
|
+
workspaceSlug,
|
|
5243
|
+
projectSlug
|
|
5244
|
+
);
|
|
5245
|
+
status.success("\u2713 Repository cloned");
|
|
5246
|
+
status.info("");
|
|
5247
|
+
const bareRepoPath = worktreeManager.getBareRepoPath();
|
|
5248
|
+
addProject2(projectDetails.id, projectPath, {
|
|
5249
|
+
worktreeMode: true,
|
|
5250
|
+
bareRepoPath
|
|
5251
|
+
});
|
|
5252
|
+
status.success("\u2713 Project registered with daemon");
|
|
5253
|
+
status.info("");
|
|
5254
|
+
status.success("Project cloned successfully!");
|
|
5255
|
+
status.info("");
|
|
5256
|
+
status.info("Project structure:");
|
|
5257
|
+
status.info(` ${projectPath}/`);
|
|
5258
|
+
status.info(" \u251C\u2500\u2500 .bare/ # Git repository");
|
|
5259
|
+
status.info(" \u2514\u2500\u2500 .episoda/");
|
|
5260
|
+
status.info(" \u2514\u2500\u2500 config.json # Project config");
|
|
5261
|
+
status.info("");
|
|
5262
|
+
status.info("Next steps:");
|
|
5263
|
+
status.info(` 1. cd ${projectPath}`);
|
|
5264
|
+
status.info(" 2. episoda checkout {moduleUid} # e.g., episoda checkout EP100");
|
|
5265
|
+
status.info("");
|
|
5266
|
+
} catch (error) {
|
|
5267
|
+
if (fs10.existsSync(projectPath)) {
|
|
5268
|
+
try {
|
|
5269
|
+
fs10.rmSync(projectPath, { recursive: true, force: true });
|
|
5270
|
+
} catch {
|
|
5271
|
+
}
|
|
5272
|
+
}
|
|
5273
|
+
throw error;
|
|
5274
|
+
}
|
|
5275
|
+
}
|
|
5276
|
+
function validateRepoUrl(url) {
|
|
5277
|
+
const dangerousChars = /[;|&$`\\<>(){}[\]!#*?~'"]/;
|
|
5278
|
+
if (dangerousChars.test(url)) {
|
|
5279
|
+
throw new Error(
|
|
5280
|
+
"Repository URL contains invalid characters.\nPlease check the repository configuration on episoda.dev."
|
|
5281
|
+
);
|
|
5282
|
+
}
|
|
5283
|
+
let parsed;
|
|
5284
|
+
try {
|
|
5285
|
+
parsed = new URL(url);
|
|
5286
|
+
} catch {
|
|
5287
|
+
if (/^[\w.-]+@[\w.-]+:[\w./-]+$/.test(url)) {
|
|
5288
|
+
return;
|
|
5289
|
+
}
|
|
5290
|
+
throw new Error(
|
|
5291
|
+
`Repository URL is not a valid URL format.
|
|
5292
|
+
Received: ${url}`
|
|
5293
|
+
);
|
|
5294
|
+
}
|
|
5295
|
+
const allowedProtocols = ["https:", "http:", "git:", "ssh:"];
|
|
5296
|
+
if (!allowedProtocols.includes(parsed.protocol)) {
|
|
5297
|
+
throw new Error(
|
|
5298
|
+
`Repository URL uses unsupported protocol: ${parsed.protocol}
|
|
5299
|
+
Allowed protocols: https, http, git, ssh`
|
|
5300
|
+
);
|
|
5301
|
+
}
|
|
5302
|
+
if (!parsed.hostname || parsed.hostname.length < 3) {
|
|
5303
|
+
throw new Error(
|
|
5304
|
+
`Repository URL has invalid hostname.
|
|
5305
|
+
Received: ${url}`
|
|
5306
|
+
);
|
|
5307
|
+
}
|
|
5308
|
+
}
|
|
5309
|
+
async function fetchProjectDetails(apiUrl, workspaceSlug, projectSlug, accessToken) {
|
|
5310
|
+
const url = `${apiUrl}/api/projects/by-slug/${workspaceSlug}/${projectSlug}`;
|
|
5311
|
+
const response = await fetch(url, {
|
|
5312
|
+
method: "GET",
|
|
5313
|
+
headers: {
|
|
5314
|
+
"Authorization": `Bearer ${accessToken}`,
|
|
5315
|
+
"Content-Type": "application/json"
|
|
5316
|
+
}
|
|
5317
|
+
});
|
|
5318
|
+
if (response.status === 404) {
|
|
5319
|
+
throw new Error(
|
|
5320
|
+
`Project "${workspaceSlug}/${projectSlug}" not found.
|
|
5321
|
+
Check the workspace and project slugs are correct.`
|
|
5322
|
+
);
|
|
5323
|
+
}
|
|
5324
|
+
if (response.status === 401) {
|
|
5325
|
+
throw new Error(
|
|
5326
|
+
"Authentication expired. Please run `episoda auth` again."
|
|
5327
|
+
);
|
|
5328
|
+
}
|
|
5329
|
+
if (!response.ok) {
|
|
5330
|
+
const errorBody = await response.text();
|
|
5331
|
+
throw new Error(
|
|
5332
|
+
`Failed to fetch project details: ${response.status}
|
|
5333
|
+
${errorBody}`
|
|
5334
|
+
);
|
|
5335
|
+
}
|
|
5336
|
+
const data = await response.json();
|
|
5337
|
+
if (!data.id || !data.slug) {
|
|
5338
|
+
throw new Error("Invalid project response from server");
|
|
5339
|
+
}
|
|
5340
|
+
return data;
|
|
5341
|
+
}
|
|
5342
|
+
|
|
5343
|
+
// src/commands/checkout.ts
|
|
5344
|
+
var import_core12 = __toESM(require_dist());
|
|
5345
|
+
|
|
5346
|
+
// src/utils/http.ts
|
|
5347
|
+
async function fetchWithRetry(url, options, maxRetries = 3) {
|
|
5348
|
+
let lastError = null;
|
|
5349
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
5350
|
+
try {
|
|
5351
|
+
const response = await fetch(url, options);
|
|
5352
|
+
if (response.status === 401 || response.status === 404) {
|
|
5353
|
+
return response;
|
|
5354
|
+
}
|
|
5355
|
+
if (response.ok || response.status >= 400 && response.status < 500) {
|
|
5356
|
+
return response;
|
|
5357
|
+
}
|
|
5358
|
+
lastError = new Error(`Server error: ${response.status}`);
|
|
5359
|
+
} catch (error) {
|
|
5360
|
+
lastError = error instanceof Error ? error : new Error("Network error");
|
|
5361
|
+
}
|
|
5362
|
+
if (attempt < maxRetries - 1) {
|
|
5363
|
+
await new Promise((resolve4) => setTimeout(resolve4, Math.pow(2, attempt) * 1e3));
|
|
5364
|
+
}
|
|
5365
|
+
}
|
|
5366
|
+
throw lastError || new Error("Request failed after retries");
|
|
5367
|
+
}
|
|
5368
|
+
|
|
5369
|
+
// src/commands/checkout.ts
|
|
5370
|
+
async function checkoutCommand(moduleUid, options = {}) {
|
|
5371
|
+
if (!moduleUid || !moduleUid.match(/^EP\d+$/)) {
|
|
5372
|
+
throw new Error(
|
|
5373
|
+
"Invalid module UID format. Expected format: EP###\nExample: episoda checkout EP100"
|
|
5374
|
+
);
|
|
5375
|
+
}
|
|
5376
|
+
status.info(`Checking out ${moduleUid}...`);
|
|
5377
|
+
status.info("");
|
|
5378
|
+
const projectRoot = await findProjectRoot(process.cwd());
|
|
5379
|
+
if (!projectRoot) {
|
|
5380
|
+
throw new Error(
|
|
5381
|
+
"Not in a worktree project.\nRun this command from within a project cloned with `episoda clone`.\nOr cd to ~/episoda/{workspace}/{project}/"
|
|
5382
|
+
);
|
|
5383
|
+
}
|
|
5384
|
+
const config = await (0, import_core12.loadConfig)();
|
|
5385
|
+
if (!config || !config.access_token) {
|
|
5386
|
+
throw new Error(
|
|
5387
|
+
"Not authenticated. Please run `episoda auth` first."
|
|
5388
|
+
);
|
|
5389
|
+
}
|
|
5390
|
+
const apiUrl = options.apiUrl || config.api_url || "https://episoda.dev";
|
|
5391
|
+
const worktreeManager = new WorktreeManager(projectRoot);
|
|
5392
|
+
const initialized = await worktreeManager.initialize();
|
|
5393
|
+
if (!initialized) {
|
|
5394
|
+
throw new Error(
|
|
5395
|
+
`Invalid worktree project at ${projectRoot}.
|
|
5396
|
+
The .bare directory or .episoda/config.json may be missing.`
|
|
5397
|
+
);
|
|
5398
|
+
}
|
|
5399
|
+
const existing = worktreeManager.getWorktreeByModuleUid(moduleUid);
|
|
5400
|
+
if (existing) {
|
|
5401
|
+
status.success(`Module ${moduleUid} is already checked out.`);
|
|
5402
|
+
status.info("");
|
|
5403
|
+
status.info(`Path: ${existing.worktreePath}`);
|
|
5404
|
+
status.info(`Branch: ${existing.branchName}`);
|
|
5405
|
+
status.info("");
|
|
5406
|
+
status.info(`cd ${existing.worktreePath}`);
|
|
5407
|
+
return;
|
|
5408
|
+
}
|
|
5409
|
+
status.info("Fetching module details...");
|
|
5410
|
+
const worktreeConfig = worktreeManager.getConfig();
|
|
5411
|
+
if (!worktreeConfig) {
|
|
5412
|
+
throw new Error("Could not read worktree project config");
|
|
5413
|
+
}
|
|
5414
|
+
const moduleDetails = await fetchModuleDetails(
|
|
5415
|
+
apiUrl,
|
|
5416
|
+
worktreeConfig.projectId,
|
|
5417
|
+
moduleUid,
|
|
5418
|
+
config.access_token
|
|
5419
|
+
);
|
|
5420
|
+
const branchName = moduleDetails.branch_name || `feature/${moduleUid.toLowerCase()}`;
|
|
5421
|
+
let createBranch = !moduleDetails.branch_name || options.create;
|
|
5422
|
+
if (createBranch && !moduleDetails.branch_name) {
|
|
5423
|
+
const { GitExecutor: GitExecutor6 } = await Promise.resolve().then(() => __toESM(require_dist()));
|
|
5424
|
+
const gitExecutor = new GitExecutor6();
|
|
5425
|
+
const branchCheck = await gitExecutor.execute(
|
|
5426
|
+
{ action: "branch_exists", branch: branchName },
|
|
5427
|
+
{ cwd: worktreeManager.getBareRepoPath() }
|
|
5428
|
+
);
|
|
5429
|
+
if (branchCheck.success && branchCheck.details?.branchExists) {
|
|
5430
|
+
status.info(`Branch ${branchName} already exists, will use existing`);
|
|
5431
|
+
createBranch = false;
|
|
5432
|
+
}
|
|
5433
|
+
}
|
|
5434
|
+
status.success(`\u2713 Found module: ${moduleDetails.name || moduleUid}`);
|
|
5435
|
+
status.info(` Branch: ${branchName}${createBranch ? " (will be created)" : ""}`);
|
|
5436
|
+
status.info("");
|
|
5437
|
+
status.info("Creating worktree...");
|
|
5438
|
+
const result = await worktreeManager.createWorktree(
|
|
5439
|
+
moduleUid,
|
|
5440
|
+
branchName,
|
|
5441
|
+
createBranch
|
|
5442
|
+
);
|
|
5443
|
+
if (!result.success) {
|
|
5444
|
+
throw new Error(`Failed to create worktree: ${result.error}`);
|
|
5445
|
+
}
|
|
5446
|
+
status.success("\u2713 Worktree created");
|
|
5447
|
+
status.info("");
|
|
5448
|
+
const branchWasGenerated = !moduleDetails.branch_name;
|
|
5449
|
+
try {
|
|
5450
|
+
await updateModuleCheckout(
|
|
5451
|
+
apiUrl,
|
|
5452
|
+
moduleDetails.id,
|
|
5453
|
+
config.access_token,
|
|
5454
|
+
branchWasGenerated ? branchName : void 0
|
|
5455
|
+
);
|
|
5456
|
+
} catch (error) {
|
|
5457
|
+
status.warning("Could not update server with checkout status");
|
|
5458
|
+
}
|
|
5459
|
+
status.success(`Module ${moduleUid} checked out successfully!`);
|
|
5460
|
+
status.info("");
|
|
5461
|
+
status.info(`Path: ${result.worktreePath}`);
|
|
5462
|
+
status.info(`Branch: ${branchName}`);
|
|
5463
|
+
status.info("");
|
|
5464
|
+
status.info("Next step:");
|
|
5465
|
+
status.info(` cd ${result.worktreePath}`);
|
|
5466
|
+
status.info("");
|
|
5467
|
+
}
|
|
5468
|
+
async function fetchModuleDetails(apiUrl, projectId, moduleUid, accessToken) {
|
|
5469
|
+
const url = `${apiUrl}/api/modules/by-uid/${moduleUid}?project_id=${projectId}`;
|
|
5470
|
+
const response = await fetchWithRetry(url, {
|
|
5471
|
+
method: "GET",
|
|
5472
|
+
headers: {
|
|
5473
|
+
"Authorization": `Bearer ${accessToken}`,
|
|
5474
|
+
"Content-Type": "application/json"
|
|
5475
|
+
}
|
|
5476
|
+
});
|
|
5477
|
+
if (response.status === 404) {
|
|
5478
|
+
throw new Error(
|
|
5479
|
+
`Module "${moduleUid}" not found in this project.
|
|
5480
|
+
Check the module UID is correct.`
|
|
5481
|
+
);
|
|
5482
|
+
}
|
|
5483
|
+
if (response.status === 401) {
|
|
5484
|
+
throw new Error(
|
|
5485
|
+
"Authentication expired. Please run `episoda auth` again."
|
|
5486
|
+
);
|
|
5487
|
+
}
|
|
5488
|
+
if (!response.ok) {
|
|
5489
|
+
const errorBody = await response.text();
|
|
5490
|
+
throw new Error(
|
|
5491
|
+
`Failed to fetch module details: ${response.status}
|
|
5492
|
+
${errorBody}`
|
|
5493
|
+
);
|
|
5494
|
+
}
|
|
5495
|
+
const data = await response.json();
|
|
5496
|
+
if (!data.id || !data.uid) {
|
|
5497
|
+
throw new Error("Invalid module response from server");
|
|
5498
|
+
}
|
|
5499
|
+
return data;
|
|
5500
|
+
}
|
|
5501
|
+
async function updateModuleCheckout(apiUrl, moduleId, accessToken, branchName) {
|
|
5502
|
+
const url = `${apiUrl}/api/modules/${moduleId}/checkout`;
|
|
5503
|
+
const body = {
|
|
5504
|
+
checked_out: true,
|
|
5505
|
+
checkout_type: "worktree"
|
|
5506
|
+
};
|
|
5507
|
+
if (branchName) {
|
|
5508
|
+
body.branch_name = branchName;
|
|
5509
|
+
}
|
|
5510
|
+
await fetchWithRetry(url, {
|
|
5511
|
+
method: "POST",
|
|
5512
|
+
headers: {
|
|
5513
|
+
"Authorization": `Bearer ${accessToken}`,
|
|
5514
|
+
"Content-Type": "application/json"
|
|
5515
|
+
},
|
|
5516
|
+
body: JSON.stringify(body)
|
|
5517
|
+
});
|
|
5518
|
+
}
|
|
5519
|
+
|
|
5520
|
+
// src/commands/release.ts
|
|
5521
|
+
var path12 = __toESM(require("path"));
|
|
5522
|
+
var import_core13 = __toESM(require_dist());
|
|
5523
|
+
async function releaseCommand(moduleUid, options = {}) {
|
|
5524
|
+
if (!moduleUid || !moduleUid.match(/^EP\d+$/)) {
|
|
5525
|
+
throw new Error(
|
|
5526
|
+
"Invalid module UID format. Expected format: EP###\nExample: episoda release EP100"
|
|
5527
|
+
);
|
|
5528
|
+
}
|
|
5529
|
+
status.info(`Releasing ${moduleUid}...`);
|
|
5530
|
+
status.info("");
|
|
5531
|
+
const projectRoot = await findProjectRoot(process.cwd());
|
|
5532
|
+
if (!projectRoot) {
|
|
5533
|
+
throw new Error(
|
|
5534
|
+
"Not in a worktree project.\nRun this command from within a project cloned with `episoda clone`."
|
|
5535
|
+
);
|
|
5536
|
+
}
|
|
5537
|
+
const worktreeManager = new WorktreeManager(projectRoot);
|
|
5538
|
+
const initialized = await worktreeManager.initialize();
|
|
5539
|
+
if (!initialized) {
|
|
5540
|
+
throw new Error(
|
|
5541
|
+
`Invalid worktree project at ${projectRoot}.`
|
|
5542
|
+
);
|
|
5543
|
+
}
|
|
5544
|
+
const existing = worktreeManager.getWorktreeByModuleUid(moduleUid);
|
|
5545
|
+
if (!existing) {
|
|
5546
|
+
status.warning(`Module ${moduleUid} is not checked out.`);
|
|
5547
|
+
status.info("");
|
|
5548
|
+
status.info("Available worktrees:");
|
|
5549
|
+
const worktrees = worktreeManager.listWorktrees();
|
|
5550
|
+
if (worktrees.length === 0) {
|
|
5551
|
+
status.info(" (none)");
|
|
5552
|
+
} else {
|
|
5553
|
+
for (const wt of worktrees) {
|
|
5554
|
+
status.info(` ${wt.moduleUid} \u2192 ${wt.branchName}`);
|
|
5555
|
+
}
|
|
5556
|
+
}
|
|
5557
|
+
return;
|
|
5558
|
+
}
|
|
5559
|
+
if (!options.force) {
|
|
5560
|
+
const hasChanges = await checkUncommittedChanges(existing.worktreePath);
|
|
5561
|
+
if (hasChanges) {
|
|
5562
|
+
throw new Error(
|
|
5563
|
+
`Module ${moduleUid} has uncommitted changes.
|
|
5564
|
+
Commit or stash your changes first, or use --force to discard them.`
|
|
5565
|
+
);
|
|
5566
|
+
}
|
|
5567
|
+
}
|
|
5568
|
+
const currentPath = path12.resolve(process.cwd());
|
|
5569
|
+
if (currentPath.startsWith(existing.worktreePath)) {
|
|
5570
|
+
status.warning("You are inside the worktree being released.");
|
|
5571
|
+
status.info(`Please cd to ${projectRoot} first.`);
|
|
5572
|
+
throw new Error("Cannot release worktree while inside it");
|
|
5573
|
+
}
|
|
5574
|
+
status.info("Removing worktree...");
|
|
5575
|
+
const result = await worktreeManager.removeWorktree(moduleUid, options.force);
|
|
5576
|
+
if (!result.success) {
|
|
5577
|
+
throw new Error(`Failed to remove worktree: ${result.error}`);
|
|
5578
|
+
}
|
|
5579
|
+
status.success("\u2713 Worktree removed");
|
|
5580
|
+
status.info("");
|
|
5581
|
+
try {
|
|
5582
|
+
const config = await (0, import_core13.loadConfig)();
|
|
5583
|
+
if (config?.access_token) {
|
|
5584
|
+
const worktreeConfig = worktreeManager.getConfig();
|
|
5585
|
+
if (worktreeConfig) {
|
|
5586
|
+
await updateModuleRelease(
|
|
5587
|
+
config.api_url || "https://episoda.dev",
|
|
5588
|
+
worktreeConfig.projectId,
|
|
5589
|
+
worktreeConfig.workspaceSlug,
|
|
5590
|
+
moduleUid,
|
|
5591
|
+
config.access_token
|
|
5592
|
+
);
|
|
5593
|
+
}
|
|
5594
|
+
}
|
|
5595
|
+
} catch (error) {
|
|
5596
|
+
}
|
|
5597
|
+
status.success(`Module ${moduleUid} released successfully!`);
|
|
5598
|
+
status.info("");
|
|
5599
|
+
status.info(`Branch "${existing.branchName}" is preserved in the repository.`);
|
|
5600
|
+
status.info("");
|
|
5601
|
+
status.info("To check out again:");
|
|
5602
|
+
status.info(` episoda checkout ${moduleUid}`);
|
|
5603
|
+
status.info("");
|
|
5604
|
+
}
|
|
5605
|
+
async function checkUncommittedChanges(worktreePath) {
|
|
5606
|
+
const gitExecutor = new import_core13.GitExecutor();
|
|
5607
|
+
const result = await gitExecutor.execute(
|
|
5608
|
+
{ action: "status" },
|
|
5609
|
+
{ cwd: worktreePath }
|
|
5610
|
+
);
|
|
5611
|
+
if (!result.success) {
|
|
5612
|
+
return true;
|
|
5613
|
+
}
|
|
5614
|
+
const uncommitted = result.details?.uncommittedFiles || [];
|
|
5615
|
+
return uncommitted.length > 0;
|
|
5616
|
+
}
|
|
5617
|
+
async function updateModuleRelease(apiUrl, projectId, workspaceSlug, moduleUid, accessToken) {
|
|
5618
|
+
const url = `${apiUrl}/api/modules/${moduleUid}/checkout/release`;
|
|
5619
|
+
await fetchWithRetry(url, {
|
|
5620
|
+
method: "POST",
|
|
5621
|
+
headers: {
|
|
5622
|
+
"Authorization": `Bearer ${accessToken}`,
|
|
5623
|
+
"Content-Type": "application/json",
|
|
5624
|
+
"X-Project-Id": projectId,
|
|
5625
|
+
"X-Workspace-Slug": workspaceSlug
|
|
5626
|
+
}
|
|
5627
|
+
});
|
|
5628
|
+
}
|
|
5629
|
+
|
|
5630
|
+
// src/commands/list.ts
|
|
5631
|
+
var fs11 = __toESM(require("fs"));
|
|
5632
|
+
var path13 = __toESM(require("path"));
|
|
5633
|
+
var import_chalk2 = __toESM(require("chalk"));
|
|
5634
|
+
var import_core14 = __toESM(require_dist());
|
|
5635
|
+
async function listCommand(subcommand, options = {}) {
|
|
5636
|
+
if (subcommand === "worktrees" || subcommand === "wt") {
|
|
5637
|
+
await listWorktrees(options);
|
|
5638
|
+
} else {
|
|
5639
|
+
await listProjects(options);
|
|
5640
|
+
}
|
|
5641
|
+
}
|
|
5642
|
+
async function listProjects(options) {
|
|
5643
|
+
const episodaRoot = getEpisodaRoot();
|
|
5644
|
+
if (!fs11.existsSync(episodaRoot)) {
|
|
5645
|
+
status.info("No projects cloned yet.");
|
|
5646
|
+
status.info("");
|
|
5647
|
+
status.info("Clone a project with:");
|
|
5648
|
+
status.info(" episoda clone {workspace}/{project}");
|
|
5649
|
+
return;
|
|
5650
|
+
}
|
|
5651
|
+
const projects = [];
|
|
5652
|
+
const workspaces = fs11.readdirSync(episodaRoot, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("."));
|
|
5653
|
+
for (const workspace of workspaces) {
|
|
5654
|
+
const workspacePath = path13.join(episodaRoot, workspace.name);
|
|
5655
|
+
const projectDirs = fs11.readdirSync(workspacePath, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("."));
|
|
5656
|
+
for (const projectDir of projectDirs) {
|
|
5657
|
+
const projectPath = path13.join(workspacePath, projectDir.name);
|
|
5658
|
+
if (await isWorktreeProject(projectPath)) {
|
|
5659
|
+
const manager = new WorktreeManager(projectPath);
|
|
5660
|
+
await manager.initialize();
|
|
5661
|
+
const worktrees = manager.listWorktrees();
|
|
5662
|
+
projects.push({
|
|
5663
|
+
workspaceSlug: workspace.name,
|
|
5664
|
+
projectSlug: projectDir.name,
|
|
5665
|
+
projectPath,
|
|
5666
|
+
worktreeCount: worktrees.length
|
|
5667
|
+
});
|
|
5668
|
+
}
|
|
5669
|
+
}
|
|
5670
|
+
}
|
|
5671
|
+
if (projects.length === 0) {
|
|
5672
|
+
status.info("No worktree projects found.");
|
|
5673
|
+
status.info("");
|
|
5674
|
+
status.info("Clone a project with:");
|
|
5675
|
+
status.info(" episoda clone {workspace}/{project}");
|
|
5676
|
+
return;
|
|
5677
|
+
}
|
|
5678
|
+
console.log("");
|
|
5679
|
+
console.log(import_chalk2.default.bold("Cloned Projects"));
|
|
5680
|
+
console.log("\u2500".repeat(60));
|
|
5681
|
+
for (const project of projects) {
|
|
5682
|
+
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`);
|
|
5683
|
+
console.log("");
|
|
5684
|
+
console.log(` ${import_chalk2.default.bold(project.workspaceSlug)}/${import_chalk2.default.bold(project.projectSlug)} ${worktreeLabel}`);
|
|
5685
|
+
console.log(import_chalk2.default.gray(` ${project.projectPath}`));
|
|
5686
|
+
}
|
|
5687
|
+
console.log("");
|
|
5688
|
+
console.log(import_chalk2.default.gray("\u2500".repeat(60)));
|
|
5689
|
+
console.log(`${projects.length} project${projects.length === 1 ? "" : "s"}`);
|
|
5690
|
+
console.log("");
|
|
5691
|
+
if (options.verbose) {
|
|
5692
|
+
status.info("Commands:");
|
|
5693
|
+
status.info(" episoda list worktrees # Show worktrees for current project");
|
|
5694
|
+
status.info(" episoda checkout EP### # Create a new worktree");
|
|
5695
|
+
}
|
|
5696
|
+
}
|
|
5697
|
+
async function listWorktrees(options) {
|
|
5698
|
+
const projectRoot = await findProjectRoot(process.cwd());
|
|
5699
|
+
if (!projectRoot) {
|
|
5700
|
+
throw new Error(
|
|
5701
|
+
"Not in a worktree project.\nRun this from within a project cloned with `episoda clone`."
|
|
5702
|
+
);
|
|
5703
|
+
}
|
|
5704
|
+
const manager = new WorktreeManager(projectRoot);
|
|
5705
|
+
const initialized = await manager.initialize();
|
|
5706
|
+
if (!initialized) {
|
|
5707
|
+
throw new Error(`Invalid worktree project at ${projectRoot}`);
|
|
5708
|
+
}
|
|
5709
|
+
const config = manager.getConfig();
|
|
5710
|
+
const worktrees = manager.listWorktrees();
|
|
5711
|
+
console.log("");
|
|
5712
|
+
console.log(import_chalk2.default.bold(`Worktrees for ${config?.workspaceSlug}/${config?.projectSlug}`));
|
|
5713
|
+
console.log("\u2500".repeat(60));
|
|
5714
|
+
if (worktrees.length === 0) {
|
|
5715
|
+
console.log("");
|
|
5716
|
+
console.log(import_chalk2.default.gray(" No worktrees checked out"));
|
|
5717
|
+
console.log("");
|
|
5718
|
+
status.info("Create a worktree with:");
|
|
5719
|
+
status.info(" episoda checkout {moduleUid}");
|
|
5720
|
+
return;
|
|
5721
|
+
}
|
|
5722
|
+
const gitExecutor = new import_core14.GitExecutor();
|
|
5723
|
+
for (const wt of worktrees) {
|
|
5724
|
+
console.log("");
|
|
5725
|
+
let statusIndicator = import_chalk2.default.green("\u25CF");
|
|
5726
|
+
let statusNote = "";
|
|
5727
|
+
try {
|
|
5728
|
+
const result = await gitExecutor.execute(
|
|
5729
|
+
{ action: "status" },
|
|
5730
|
+
{ cwd: wt.worktreePath }
|
|
5731
|
+
);
|
|
5732
|
+
if (result.success) {
|
|
5733
|
+
const uncommitted = result.details?.uncommittedFiles || [];
|
|
5734
|
+
if (uncommitted.length > 0) {
|
|
5735
|
+
statusIndicator = import_chalk2.default.yellow("\u25CF");
|
|
5736
|
+
statusNote = import_chalk2.default.yellow(` (${uncommitted.length} uncommitted)`);
|
|
5737
|
+
}
|
|
5738
|
+
}
|
|
5739
|
+
} catch {
|
|
5740
|
+
statusIndicator = import_chalk2.default.gray("\u25CB");
|
|
5741
|
+
}
|
|
5742
|
+
console.log(` ${statusIndicator} ${import_chalk2.default.bold(wt.moduleUid)}${statusNote}`);
|
|
5743
|
+
console.log(import_chalk2.default.gray(` Branch: ${wt.branchName}`));
|
|
5744
|
+
console.log(import_chalk2.default.gray(` Path: ${wt.worktreePath}`));
|
|
5745
|
+
if (options.verbose) {
|
|
5746
|
+
console.log(import_chalk2.default.gray(` Created: ${new Date(wt.createdAt).toLocaleDateString()}`));
|
|
5747
|
+
console.log(import_chalk2.default.gray(` Last accessed: ${new Date(wt.lastAccessed).toLocaleDateString()}`));
|
|
5748
|
+
}
|
|
5749
|
+
}
|
|
5750
|
+
console.log("");
|
|
5751
|
+
console.log(import_chalk2.default.gray("\u2500".repeat(60)));
|
|
5752
|
+
console.log(`${worktrees.length} worktree${worktrees.length === 1 ? "" : "s"}`);
|
|
5753
|
+
console.log("");
|
|
5754
|
+
console.log(import_chalk2.default.gray("\u25CF clean ") + import_chalk2.default.yellow("\u25CF uncommitted changes"));
|
|
5755
|
+
console.log("");
|
|
5756
|
+
}
|
|
5757
|
+
|
|
4034
5758
|
// src/index.ts
|
|
4035
|
-
import_commander.program.name("episoda").description("Episoda local development workflow orchestration").version(
|
|
5759
|
+
import_commander.program.name("episoda").description("Episoda local development workflow orchestration").version(import_core15.VERSION);
|
|
4036
5760
|
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
5761
|
try {
|
|
4038
5762
|
await authCommand(options);
|
|
@@ -4088,6 +5812,46 @@ import_commander.program.command("disconnect").description("Disconnect from epis
|
|
|
4088
5812
|
process.exit(1);
|
|
4089
5813
|
}
|
|
4090
5814
|
});
|
|
5815
|
+
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) => {
|
|
5816
|
+
try {
|
|
5817
|
+
await cloneCommand(slug, options);
|
|
5818
|
+
} catch (error) {
|
|
5819
|
+
status.error(`Clone failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
5820
|
+
process.exit(1);
|
|
5821
|
+
}
|
|
5822
|
+
});
|
|
5823
|
+
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) => {
|
|
5824
|
+
try {
|
|
5825
|
+
await checkoutCommand(moduleUid, options);
|
|
5826
|
+
} catch (error) {
|
|
5827
|
+
status.error(`Checkout failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
5828
|
+
process.exit(1);
|
|
5829
|
+
}
|
|
5830
|
+
});
|
|
5831
|
+
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) => {
|
|
5832
|
+
try {
|
|
5833
|
+
await releaseCommand(moduleUid, options);
|
|
5834
|
+
} catch (error) {
|
|
5835
|
+
status.error(`Release failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
5836
|
+
process.exit(1);
|
|
5837
|
+
}
|
|
5838
|
+
});
|
|
5839
|
+
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) => {
|
|
5840
|
+
try {
|
|
5841
|
+
await listCommand(subcommand, options);
|
|
5842
|
+
} catch (error) {
|
|
5843
|
+
status.error(`List failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
5844
|
+
process.exit(1);
|
|
5845
|
+
}
|
|
5846
|
+
});
|
|
5847
|
+
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) => {
|
|
5848
|
+
try {
|
|
5849
|
+
await migrateCommand(options);
|
|
5850
|
+
} catch (error) {
|
|
5851
|
+
status.error(`Migration failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
5852
|
+
process.exit(1);
|
|
5853
|
+
}
|
|
5854
|
+
});
|
|
4091
5855
|
import_commander.program.command("dev:restart").description("Restart the dev server for a module").argument("[moduleUid]", "Module UID (required)").action(async (moduleUid) => {
|
|
4092
5856
|
try {
|
|
4093
5857
|
if (!moduleUid) {
|