@roulabs/mx 2.4.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/bin/mx.js +179 -102
- package/package.json +1 -1
- package/templates/CLAUDE.md +4 -0
package/README.md
CHANGED
|
@@ -45,6 +45,7 @@ Inside a work folder or worktree you can drop `-n` — mx infers the work/repo f
|
|
|
45
45
|
| `mx update` | self-update the CLI within its major (`npm i -g`); flags a newer major if one exists |
|
|
46
46
|
| `mx migrate [--dry-run]` | upgrade an older-version runtime to the version this CLI supports (the only command allowed on a version-mismatched runtime); `--dry-run` previews the plan without changing anything |
|
|
47
47
|
| `mx repo add <git-url> [--name <n>]` | clone a pristine repo (into `repos/<repo>/git`; stamps its `hydrate.sh`/`health.sh`) |
|
|
48
|
+
| `mx repo new <name> [--quick] [-o]` | create a fresh local repo with no remote (git init on main + README + initial commit); `--quick` also makes a `dev-<name>` work + a `develop` worktree (quick-experiment one-shot) |
|
|
48
49
|
| `mx repo ls` / `mx repo -n <name> fetch\|info\|rm` | manage pristine repos |
|
|
49
50
|
| `mx repo health` / `mx repo -n <name> health` | local-only health check (augmented by the repo's `health.sh`) |
|
|
50
51
|
| `mx work new <name> [--description <t>] [-o]` | create a work; `-o` opens a fullscreen Terminal + editor (macOS) |
|
package/bin/mx.js
CHANGED
|
@@ -546,6 +546,28 @@ function repoAdd(root, url, name0) {
|
|
|
546
546
|
git(["clone", url, gitdir], { stdio: ["ignore", "inherit", "inherit"] });
|
|
547
547
|
return { name, path: container, remote: remoteUrl(gitdir), branch: currentBranch(gitdir) };
|
|
548
548
|
}
|
|
549
|
+
function repoNew(root, name, opts = {}) {
|
|
550
|
+
if (!name || name.includes("/") || name.includes("\\") || name === "." || name === "..") {
|
|
551
|
+
throw new MxError(`invalid repo name: ${JSON.stringify(name)}`, "BAD_ARGS");
|
|
552
|
+
}
|
|
553
|
+
const container = repoPath(root, name);
|
|
554
|
+
if (exists(container)) throw new MxError(`repo already exists: ${name}`, "EXISTS");
|
|
555
|
+
const gitdir = repoGitDir(root, name);
|
|
556
|
+
fs5.mkdirSync(gitdir, { recursive: true });
|
|
557
|
+
git(["-C", gitdir, "init", "-q", "-b", "main"]);
|
|
558
|
+
const withReadme = opts.readme !== false;
|
|
559
|
+
if (withReadme) {
|
|
560
|
+
fs5.writeFileSync(path4.join(gitdir, "README.md"), `# ${name}
|
|
561
|
+
`);
|
|
562
|
+
git(["-C", gitdir, "add", "README.md"]);
|
|
563
|
+
}
|
|
564
|
+
const haveIdentity = gitQuiet(["-C", gitdir, "config", "user.name"]) !== null && gitQuiet(["-C", gitdir, "config", "user.email"]) !== null;
|
|
565
|
+
const idArgs = haveIdentity ? [] : ["-c", "user.name=mx", "-c", "user.email=mx@localhost"];
|
|
566
|
+
const commitArgs = ["-C", gitdir, ...idArgs, "commit", "-q", "-m", "init"];
|
|
567
|
+
if (!withReadme) commitArgs.push("--allow-empty");
|
|
568
|
+
git(commitArgs);
|
|
569
|
+
return { name, path: container, remote: null, branch: currentBranch(gitdir) };
|
|
570
|
+
}
|
|
549
571
|
function listReposInfo(root) {
|
|
550
572
|
return listRepoNames(root).map((name) => ({
|
|
551
573
|
name,
|
|
@@ -1021,7 +1043,8 @@ function parseArgs(argv) {
|
|
|
1021
1043
|
archived: false,
|
|
1022
1044
|
open: false,
|
|
1023
1045
|
noHydrate: false,
|
|
1024
|
-
dryRun: false
|
|
1046
|
+
dryRun: false,
|
|
1047
|
+
quick: false
|
|
1025
1048
|
};
|
|
1026
1049
|
for (let i = 0; i < argv.length; i++) {
|
|
1027
1050
|
const a = argv[i];
|
|
@@ -1045,6 +1068,8 @@ function parseArgs(argv) {
|
|
|
1045
1068
|
flags.noHydrate = true;
|
|
1046
1069
|
} else if (a === "--dry-run") {
|
|
1047
1070
|
flags.dryRun = true;
|
|
1071
|
+
} else if (a === "--quick") {
|
|
1072
|
+
flags.quick = true;
|
|
1048
1073
|
} else if (a.startsWith("--") && a.includes("=")) {
|
|
1049
1074
|
const eq = a.indexOf("=");
|
|
1050
1075
|
const key = VALUE_FLAGS[a.slice(0, eq)];
|
|
@@ -1135,6 +1160,7 @@ Global:
|
|
|
1135
1160
|
|
|
1136
1161
|
Repos (pristine clones):
|
|
1137
1162
|
mx repo add <git-url> [--name <n>] clone a repo into the runtime
|
|
1163
|
+
mx repo new <name> [--quick] [-o] create a fresh local repo (no remote); --quick also makes a dev-<name> work + develop worktree
|
|
1138
1164
|
mx repo ls [--porcelain]
|
|
1139
1165
|
mx repo -n <name> path print the repo container path (cd "$(mx repo -n <name> path)")
|
|
1140
1166
|
mx repo -n <name> fetch git fetch (+ ff the checked-out and base branches)
|
|
@@ -1412,6 +1438,107 @@ function renderStatus(data) {
|
|
|
1412
1438
|
console.log();
|
|
1413
1439
|
}
|
|
1414
1440
|
|
|
1441
|
+
// src/open.ts
|
|
1442
|
+
import { execFileSync as execFileSync3 } from "child_process";
|
|
1443
|
+
function osascript(script) {
|
|
1444
|
+
try {
|
|
1445
|
+
execFileSync3("osascript", ["-e", script], { stdio: ["ignore", "ignore", "pipe"] });
|
|
1446
|
+
} catch (e) {
|
|
1447
|
+
const err = e;
|
|
1448
|
+
throw new MxError(
|
|
1449
|
+
`osascript failed: ${(err.stderr ?? err.message ?? "").toString().trim()}`,
|
|
1450
|
+
"OSASCRIPT"
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
function aplStr(s) {
|
|
1455
|
+
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
1456
|
+
}
|
|
1457
|
+
function openWorkLayout(workdir, workspace) {
|
|
1458
|
+
if (process.platform !== "darwin") {
|
|
1459
|
+
throw new MxError("-o/--open is only supported on macOS", "UNSUPPORTED");
|
|
1460
|
+
}
|
|
1461
|
+
let editorProcess = "Cursor";
|
|
1462
|
+
try {
|
|
1463
|
+
execFileSync3("open", ["-a", "Cursor", workspace], { stdio: "ignore" });
|
|
1464
|
+
} catch {
|
|
1465
|
+
try {
|
|
1466
|
+
execFileSync3("open", ["-a", "Visual Studio Code", workspace], { stdio: "ignore" });
|
|
1467
|
+
editorProcess = "Code";
|
|
1468
|
+
} catch {
|
|
1469
|
+
editorProcess = "";
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
osascript(
|
|
1473
|
+
[
|
|
1474
|
+
'tell application "Terminal"',
|
|
1475
|
+
" activate",
|
|
1476
|
+
" set winCountBefore to count of windows",
|
|
1477
|
+
` do script "cd \\"${aplStr(workdir)}\\""`,
|
|
1478
|
+
" delay 0.5",
|
|
1479
|
+
" set winCountAfter to count of windows",
|
|
1480
|
+
"end tell",
|
|
1481
|
+
"if winCountAfter is less than or equal to winCountBefore then",
|
|
1482
|
+
' tell application "System Events" to tell process "Terminal"',
|
|
1483
|
+
" try",
|
|
1484
|
+
' click menu item "Move Tab to New Window" of menu "Window" of menu bar 1',
|
|
1485
|
+
" delay 0.4",
|
|
1486
|
+
" end try",
|
|
1487
|
+
" end tell",
|
|
1488
|
+
"end if",
|
|
1489
|
+
'tell application "System Events" to tell process "Terminal"',
|
|
1490
|
+
" try",
|
|
1491
|
+
' set value of attribute "AXFullScreen" of window 1 to true',
|
|
1492
|
+
" end try",
|
|
1493
|
+
"end tell"
|
|
1494
|
+
].join("\n")
|
|
1495
|
+
);
|
|
1496
|
+
if (editorProcess) {
|
|
1497
|
+
const appName = editorProcess === "Code" ? "Visual Studio Code" : "Cursor";
|
|
1498
|
+
osascript(
|
|
1499
|
+
[
|
|
1500
|
+
`tell application "${appName}" to activate`,
|
|
1501
|
+
// wait until the app is frontmost (cold launch can take a few seconds)
|
|
1502
|
+
'tell application "System Events"',
|
|
1503
|
+
" set n to 0",
|
|
1504
|
+
` repeat until (exists process "${editorProcess}") and (frontmost of process "${editorProcess}" is true)`,
|
|
1505
|
+
" delay 0.3",
|
|
1506
|
+
" set n to n + 1",
|
|
1507
|
+
" if n > 40 then exit repeat",
|
|
1508
|
+
" end repeat",
|
|
1509
|
+
"end tell",
|
|
1510
|
+
// let the workbench finish loading so it accepts the keybinding
|
|
1511
|
+
"delay 1.2",
|
|
1512
|
+
'tell application "System Events" to key code 3 using {control down, command down}'
|
|
1513
|
+
].join("\n")
|
|
1514
|
+
);
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// src/hydrate.ts
|
|
1519
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
1520
|
+
import { existsSync as existsSync2 } from "fs";
|
|
1521
|
+
function runWorktreeHydrate(ctx, quiet) {
|
|
1522
|
+
const script = repoHydrateScript(ctx.root, ctx.repo);
|
|
1523
|
+
if (!existsSync2(script)) return { ran: false, ok: true, missing: true };
|
|
1524
|
+
const env = {
|
|
1525
|
+
...process.env,
|
|
1526
|
+
MX_RUNTIME: ctx.root,
|
|
1527
|
+
MX_WORK: ctx.work,
|
|
1528
|
+
MX_REPO: ctx.repo,
|
|
1529
|
+
MX_WORKTREE_PATH: ctx.worktreePath,
|
|
1530
|
+
MX_BRANCH: ctx.branch,
|
|
1531
|
+
MX_BASE: ctx.base ?? "",
|
|
1532
|
+
MX_WORK_PATH: workPath(ctx.root, ctx.work).path
|
|
1533
|
+
};
|
|
1534
|
+
const r = spawnSync3(script, [ctx.worktreePath, ctx.branch], {
|
|
1535
|
+
cwd: ctx.worktreePath,
|
|
1536
|
+
env,
|
|
1537
|
+
stdio: quiet ? ["ignore", "ignore", "ignore"] : "inherit"
|
|
1538
|
+
});
|
|
1539
|
+
return { ran: true, ok: r.status === 0, missing: false };
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1415
1542
|
// src/commands/repo.ts
|
|
1416
1543
|
function need(v, msg) {
|
|
1417
1544
|
if (v == null || v === "") throw new MxError(msg, "BAD_ARGS");
|
|
@@ -1429,6 +1556,57 @@ function dispatchRepo(positionals, flags) {
|
|
|
1429
1556
|
emit(() => console.log(`${check()} cloned ${bold(res.name)} ${dim(`\u2192 ${res.path}`)}`), res);
|
|
1430
1557
|
return;
|
|
1431
1558
|
}
|
|
1559
|
+
case "new": {
|
|
1560
|
+
const name = need(
|
|
1561
|
+
positionals[2],
|
|
1562
|
+
"usage: mx repo new <name> [--quick] [-o] [--description <t>]"
|
|
1563
|
+
);
|
|
1564
|
+
const res = repoNew(root, name);
|
|
1565
|
+
stampRepoScripts(repoPath(root, res.name), templatesDir());
|
|
1566
|
+
if (!flags.quick) {
|
|
1567
|
+
emit(() => {
|
|
1568
|
+
console.log(`${check()} created repo ${bold(res.name)} ${dim("(local, no remote)")}`);
|
|
1569
|
+
console.log(` ${dim(res.path)}`);
|
|
1570
|
+
console.log(` ${dim(`next: mx repo new ${res.name} --quick -o (or add it to a work yourself)`)}`);
|
|
1571
|
+
}, res);
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
const workName = `dev-${name}`;
|
|
1575
|
+
const workRes = workNew(root, workName, flags.description ?? "");
|
|
1576
|
+
const wtRes = worktreeAdd(root, workName, name, { branch: "develop" });
|
|
1577
|
+
if (!flags.noHydrate) {
|
|
1578
|
+
const outcome = runWorktreeHydrate(
|
|
1579
|
+
{ root, work: workName, repo: name, worktreePath: wtRes.path, branch: wtRes.branch },
|
|
1580
|
+
flags.porcelain
|
|
1581
|
+
);
|
|
1582
|
+
if (outcome.ran && !outcome.ok && !flags.porcelain) {
|
|
1583
|
+
process.stderr.write(
|
|
1584
|
+
`${warn()} ${dim(`hydrate.sh exited non-zero \u2014 worktree kept. Re-run: mx work -n ${workName} worktree hydrate ${name}`)}
|
|
1585
|
+
`
|
|
1586
|
+
);
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
let opened = false;
|
|
1590
|
+
if (flags.open) {
|
|
1591
|
+
try {
|
|
1592
|
+
openWorkLayout(workRes.path, workspaceFile(root, workName));
|
|
1593
|
+
opened = true;
|
|
1594
|
+
} catch (e) {
|
|
1595
|
+
const msg = e instanceof MxError ? e.message : String(e);
|
|
1596
|
+
process.stderr.write(`${warn()} ${dim(`could not open layout: ${msg}`)}
|
|
1597
|
+
`);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
emit(() => {
|
|
1601
|
+
console.log(`${check()} created repo ${bold(res.name)} ${dim("(local, no remote)")}`);
|
|
1602
|
+
console.log(`${check()} created work ${bold(workRes.name)} ${dim(`\u2192 ${workRes.path}`)}`);
|
|
1603
|
+
console.log(
|
|
1604
|
+
`${check()} added worktree ${bold(wtRes.repo)} ${dim(`[${wtRes.branch}]`)} ${dim(`\u2192 ${wtRes.path}`)}`
|
|
1605
|
+
);
|
|
1606
|
+
if (opened) console.log(`${check()} opened ${dim("(Terminal + editor)")}`);
|
|
1607
|
+
}, { repo: res, work: workRes, worktree: wtRes, opened });
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1432
1610
|
case "ls": {
|
|
1433
1611
|
const repos = listReposInfo(root);
|
|
1434
1612
|
emit(() => {
|
|
@@ -1626,107 +1804,6 @@ function renderHealthDetail(h) {
|
|
|
1626
1804
|
// src/commands/work.ts
|
|
1627
1805
|
import * as path8 from "path";
|
|
1628
1806
|
|
|
1629
|
-
// src/open.ts
|
|
1630
|
-
import { execFileSync as execFileSync3 } from "child_process";
|
|
1631
|
-
function osascript(script) {
|
|
1632
|
-
try {
|
|
1633
|
-
execFileSync3("osascript", ["-e", script], { stdio: ["ignore", "ignore", "pipe"] });
|
|
1634
|
-
} catch (e) {
|
|
1635
|
-
const err = e;
|
|
1636
|
-
throw new MxError(
|
|
1637
|
-
`osascript failed: ${(err.stderr ?? err.message ?? "").toString().trim()}`,
|
|
1638
|
-
"OSASCRIPT"
|
|
1639
|
-
);
|
|
1640
|
-
}
|
|
1641
|
-
}
|
|
1642
|
-
function aplStr(s) {
|
|
1643
|
-
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
1644
|
-
}
|
|
1645
|
-
function openWorkLayout(workdir, workspace) {
|
|
1646
|
-
if (process.platform !== "darwin") {
|
|
1647
|
-
throw new MxError("-o/--open is only supported on macOS", "UNSUPPORTED");
|
|
1648
|
-
}
|
|
1649
|
-
let editorProcess = "Cursor";
|
|
1650
|
-
try {
|
|
1651
|
-
execFileSync3("open", ["-a", "Cursor", workspace], { stdio: "ignore" });
|
|
1652
|
-
} catch {
|
|
1653
|
-
try {
|
|
1654
|
-
execFileSync3("open", ["-a", "Visual Studio Code", workspace], { stdio: "ignore" });
|
|
1655
|
-
editorProcess = "Code";
|
|
1656
|
-
} catch {
|
|
1657
|
-
editorProcess = "";
|
|
1658
|
-
}
|
|
1659
|
-
}
|
|
1660
|
-
osascript(
|
|
1661
|
-
[
|
|
1662
|
-
'tell application "Terminal"',
|
|
1663
|
-
" activate",
|
|
1664
|
-
" set winCountBefore to count of windows",
|
|
1665
|
-
` do script "cd \\"${aplStr(workdir)}\\""`,
|
|
1666
|
-
" delay 0.5",
|
|
1667
|
-
" set winCountAfter to count of windows",
|
|
1668
|
-
"end tell",
|
|
1669
|
-
"if winCountAfter is less than or equal to winCountBefore then",
|
|
1670
|
-
' tell application "System Events" to tell process "Terminal"',
|
|
1671
|
-
" try",
|
|
1672
|
-
' click menu item "Move Tab to New Window" of menu "Window" of menu bar 1',
|
|
1673
|
-
" delay 0.4",
|
|
1674
|
-
" end try",
|
|
1675
|
-
" end tell",
|
|
1676
|
-
"end if",
|
|
1677
|
-
'tell application "System Events" to tell process "Terminal"',
|
|
1678
|
-
" try",
|
|
1679
|
-
' set value of attribute "AXFullScreen" of window 1 to true',
|
|
1680
|
-
" end try",
|
|
1681
|
-
"end tell"
|
|
1682
|
-
].join("\n")
|
|
1683
|
-
);
|
|
1684
|
-
if (editorProcess) {
|
|
1685
|
-
const appName = editorProcess === "Code" ? "Visual Studio Code" : "Cursor";
|
|
1686
|
-
osascript(
|
|
1687
|
-
[
|
|
1688
|
-
`tell application "${appName}" to activate`,
|
|
1689
|
-
// wait until the app is frontmost (cold launch can take a few seconds)
|
|
1690
|
-
'tell application "System Events"',
|
|
1691
|
-
" set n to 0",
|
|
1692
|
-
` repeat until (exists process "${editorProcess}") and (frontmost of process "${editorProcess}" is true)`,
|
|
1693
|
-
" delay 0.3",
|
|
1694
|
-
" set n to n + 1",
|
|
1695
|
-
" if n > 40 then exit repeat",
|
|
1696
|
-
" end repeat",
|
|
1697
|
-
"end tell",
|
|
1698
|
-
// let the workbench finish loading so it accepts the keybinding
|
|
1699
|
-
"delay 1.2",
|
|
1700
|
-
'tell application "System Events" to key code 3 using {control down, command down}'
|
|
1701
|
-
].join("\n")
|
|
1702
|
-
);
|
|
1703
|
-
}
|
|
1704
|
-
}
|
|
1705
|
-
|
|
1706
|
-
// src/hydrate.ts
|
|
1707
|
-
import { spawnSync as spawnSync3 } from "child_process";
|
|
1708
|
-
import { existsSync as existsSync2 } from "fs";
|
|
1709
|
-
function runWorktreeHydrate(ctx, quiet) {
|
|
1710
|
-
const script = repoHydrateScript(ctx.root, ctx.repo);
|
|
1711
|
-
if (!existsSync2(script)) return { ran: false, ok: true, missing: true };
|
|
1712
|
-
const env = {
|
|
1713
|
-
...process.env,
|
|
1714
|
-
MX_RUNTIME: ctx.root,
|
|
1715
|
-
MX_WORK: ctx.work,
|
|
1716
|
-
MX_REPO: ctx.repo,
|
|
1717
|
-
MX_WORKTREE_PATH: ctx.worktreePath,
|
|
1718
|
-
MX_BRANCH: ctx.branch,
|
|
1719
|
-
MX_BASE: ctx.base ?? "",
|
|
1720
|
-
MX_WORK_PATH: workPath(ctx.root, ctx.work).path
|
|
1721
|
-
};
|
|
1722
|
-
const r = spawnSync3(script, [ctx.worktreePath, ctx.branch], {
|
|
1723
|
-
cwd: ctx.worktreePath,
|
|
1724
|
-
env,
|
|
1725
|
-
stdio: quiet ? ["ignore", "ignore", "ignore"] : "inherit"
|
|
1726
|
-
});
|
|
1727
|
-
return { ran: true, ok: r.status === 0, missing: false };
|
|
1728
|
-
}
|
|
1729
|
-
|
|
1730
1807
|
// src/workhooks.ts
|
|
1731
1808
|
import { spawnSync as spawnSync4 } from "child_process";
|
|
1732
1809
|
import { existsSync as existsSync3 } from "fs";
|
package/package.json
CHANGED
package/templates/CLAUDE.md
CHANGED
|
@@ -293,6 +293,10 @@ clarity; dropping it works while you're inside the work.
|
|
|
293
293
|
- After the worktree is created, the repo's `repos/<repo>/hydrate.sh` runs automatically with the new
|
|
294
294
|
worktree as the working directory (copy a `.env`, install deps, etc.). Pass `--no-hydrate` to skip it,
|
|
295
295
|
or re-run it later with `mx work -n <feature> worktree hydrate <repo>`.
|
|
296
|
+
- **Spin up a quick local app (no remote):** for a throwaway/experiment repo you don't want on GitHub,
|
|
297
|
+
`mx repo new <name>` creates a fresh local repo (git init on `main` + README + initial commit). Add
|
|
298
|
+
`--quick` (and `-o`) to also create a `dev-<name>` work + a worktree on `develop` and open it in one
|
|
299
|
+
shot: `mx repo new <name> --quick -o`. Like adding any repo/worktree, **ask the user first.**
|
|
296
300
|
- **Allocate a port:** `mx work -n <feature> port set <repo> <service>` returns a free port (unique
|
|
297
301
|
across all works). This only records the port in `work.json` — **you** must then wire that port
|
|
298
302
|
into the repo's own env/config (`.env`, `PORT=`, etc.) and remap any outbound URL to a sibling
|