@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roulabs/mx",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "mx — run several features in parallel across shared repos using git worktrees",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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