@roulabs/mx 2.3.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 +2 -1
- package/bin/mx.js +235 -124
- package/package.json +1 -1
- package/templates/CLAUDE.md +4 -0
package/README.md
CHANGED
|
@@ -43,8 +43,9 @@ Inside a work folder or worktree you can drop `-n` — mx infers the work/repo f
|
|
|
43
43
|
| `mx info [--all] [--porcelain]` | list repos, works, worktrees, ports |
|
|
44
44
|
| `mx sync` | re-stamp the runtime's mx-owned files (`CLAUDE.md`, per-repo/per-work scaffolding) from the current CLI — same-major, non-destructive |
|
|
45
45
|
| `mx update` | self-update the CLI within its major (`npm i -g`); flags a newer major if one exists |
|
|
46
|
-
| `mx migrate` | upgrade an older-version runtime to the version this CLI supports (the only command allowed on a version-mismatched runtime) |
|
|
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
|
@@ -222,13 +222,18 @@ function requireRuntime(opts = {}) {
|
|
|
222
222
|
function listRepoNames(root) {
|
|
223
223
|
return listDirs(reposDir(root)).filter((n) => isGitRepo(repoGitDir(root, n)));
|
|
224
224
|
}
|
|
225
|
-
function migrateRepoLayout(root) {
|
|
225
|
+
function migrateRepoLayout(root, opts = {}) {
|
|
226
|
+
const dry = opts.dryRun === true;
|
|
226
227
|
const migrated = [];
|
|
227
228
|
for (const name of listDirs(reposDir(root))) {
|
|
228
229
|
const container = repoPath(root, name);
|
|
229
230
|
const gitdir = repoGitDir(root, name);
|
|
230
231
|
if (isGitRepo(gitdir)) continue;
|
|
231
232
|
if (!isGitRepo(container)) continue;
|
|
233
|
+
if (dry) {
|
|
234
|
+
migrated.push(container);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
232
237
|
const tmp = path3.join(reposDir(root), `.${name}.mxmig`);
|
|
233
238
|
if (exists(tmp)) fs4.rmSync(tmp, { recursive: true, force: true });
|
|
234
239
|
fs4.renameSync(container, tmp);
|
|
@@ -242,7 +247,8 @@ function migrateRepoLayout(root) {
|
|
|
242
247
|
}
|
|
243
248
|
return migrated;
|
|
244
249
|
}
|
|
245
|
-
function migrateWorkLayout(root) {
|
|
250
|
+
function migrateWorkLayout(root, opts = {}) {
|
|
251
|
+
const dry = opts.dryRun === true;
|
|
246
252
|
const changed = [];
|
|
247
253
|
for (const name of listWorkNames(root)) {
|
|
248
254
|
let work;
|
|
@@ -257,6 +263,10 @@ function migrateWorkLayout(root) {
|
|
|
257
263
|
const flat = path3.join(wd, wt.repo);
|
|
258
264
|
const dest = path3.join(wtDir, wt.repo);
|
|
259
265
|
if (exists(dest) || !exists(flat)) continue;
|
|
266
|
+
if (dry) {
|
|
267
|
+
changed.push(dest);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
260
270
|
fs4.mkdirSync(wtDir, { recursive: true });
|
|
261
271
|
try {
|
|
262
272
|
git(["-C", repoGitDir(root, wt.repo), "worktree", "move", flat, dest]);
|
|
@@ -276,12 +286,12 @@ function migrateWorkLayout(root) {
|
|
|
276
286
|
let touched = false;
|
|
277
287
|
for (const f of ws.folders ?? []) {
|
|
278
288
|
if (f.path && !f.path.startsWith("wt/") && repos.has(f.path)) {
|
|
279
|
-
f.path = `wt/${f.path}`;
|
|
289
|
+
if (!dry) f.path = `wt/${f.path}`;
|
|
280
290
|
touched = true;
|
|
281
291
|
}
|
|
282
292
|
}
|
|
283
293
|
if (touched) {
|
|
284
|
-
writeJson(wsFile, ws);
|
|
294
|
+
if (!dry) writeJson(wsFile, ws);
|
|
285
295
|
changed.push(wsFile);
|
|
286
296
|
}
|
|
287
297
|
}
|
|
@@ -360,32 +370,37 @@ function initRuntime(target0, templatesDir2) {
|
|
|
360
370
|
removeStaleRuntimeReadme(target);
|
|
361
371
|
return { runtime: target, created };
|
|
362
372
|
}
|
|
363
|
-
function ensureWorkScaffolding(root, workName) {
|
|
373
|
+
function ensureWorkScaffolding(root, workName, opts = {}) {
|
|
374
|
+
const dry = opts.dryRun === true;
|
|
364
375
|
const created = [];
|
|
365
376
|
const wd = workDir(root, workName);
|
|
366
377
|
for (const d of ["wt", "scripts", "bin", "files", "tmp", "sessions", "hooks"]) {
|
|
367
378
|
const p = path3.join(wd, d);
|
|
368
379
|
if (!exists(p)) {
|
|
369
|
-
fs4.mkdirSync(p, { recursive: true });
|
|
380
|
+
if (!dry) fs4.mkdirSync(p, { recursive: true });
|
|
370
381
|
created.push(p);
|
|
371
382
|
}
|
|
372
383
|
}
|
|
373
384
|
const claudeMd = path3.join(wd, "CLAUDE.md");
|
|
374
385
|
if (!exists(claudeMd)) {
|
|
375
|
-
fs4.writeFileSync(claudeMd, workClaudeMd(workName));
|
|
386
|
+
if (!dry) fs4.writeFileSync(claudeMd, workClaudeMd(workName));
|
|
376
387
|
created.push(claudeMd);
|
|
377
388
|
}
|
|
378
389
|
const settings = path3.join(wd, ".claude", "settings.json");
|
|
379
390
|
if (!exists(settings)) {
|
|
380
|
-
|
|
381
|
-
|
|
391
|
+
if (!dry) {
|
|
392
|
+
fs4.mkdirSync(path3.dirname(settings), { recursive: true });
|
|
393
|
+
fs4.writeFileSync(settings, workClaudeSettings(root));
|
|
394
|
+
}
|
|
382
395
|
created.push(settings);
|
|
383
396
|
}
|
|
384
397
|
for (const event of WORK_HOOK_EVENTS) {
|
|
385
398
|
const hook = workHookScript(root, workName, event);
|
|
386
399
|
if (!exists(hook)) {
|
|
387
|
-
|
|
388
|
-
|
|
400
|
+
if (!dry) {
|
|
401
|
+
fs4.writeFileSync(hook, workHookScriptBody(event));
|
|
402
|
+
fs4.chmodSync(hook, 493);
|
|
403
|
+
}
|
|
389
404
|
created.push(hook);
|
|
390
405
|
}
|
|
391
406
|
}
|
|
@@ -472,20 +487,23 @@ var STEPS = {
|
|
|
472
487
|
1: {
|
|
473
488
|
from: 1,
|
|
474
489
|
to: 2,
|
|
475
|
-
run: (root) => {
|
|
490
|
+
run: (root, { dryRun }) => {
|
|
476
491
|
const changed = [];
|
|
477
|
-
changed.push(...migrateRepoLayout(root));
|
|
478
|
-
changed.push(...migrateWorkLayout(root));
|
|
479
|
-
for (const work of listWorkNames(root))
|
|
480
|
-
|
|
492
|
+
changed.push(...migrateRepoLayout(root, { dryRun }));
|
|
493
|
+
changed.push(...migrateWorkLayout(root, { dryRun }));
|
|
494
|
+
for (const work of listWorkNames(root)) {
|
|
495
|
+
changed.push(...ensureWorkScaffolding(root, work, { dryRun }));
|
|
496
|
+
}
|
|
497
|
+
if (!dryRun) writeRuntimeVersion(root, 2);
|
|
481
498
|
return changed;
|
|
482
499
|
}
|
|
483
500
|
}
|
|
484
501
|
};
|
|
485
|
-
function migrateRuntime(root) {
|
|
502
|
+
function migrateRuntime(root, opts = {}) {
|
|
503
|
+
const dryRun = opts.dryRun === true;
|
|
486
504
|
const from = readRuntimeVersion(root);
|
|
487
505
|
if (from === RUNTIME_VERSION) {
|
|
488
|
-
return { from, to: RUNTIME_VERSION, applied: [], changed: [] };
|
|
506
|
+
return { from, to: RUNTIME_VERSION, applied: [], changed: [], dryRun };
|
|
489
507
|
}
|
|
490
508
|
if (from > RUNTIME_VERSION) {
|
|
491
509
|
throw new MxError(
|
|
@@ -505,10 +523,10 @@ function migrateRuntime(root) {
|
|
|
505
523
|
const changed = [];
|
|
506
524
|
for (let v = from; v < RUNTIME_VERSION; v++) {
|
|
507
525
|
const step = STEPS[v];
|
|
508
|
-
changed.push(...step.run(root));
|
|
526
|
+
changed.push(...step.run(root, { dryRun }));
|
|
509
527
|
applied.push({ from: step.from, to: step.to });
|
|
510
528
|
}
|
|
511
|
-
return { from, to: RUNTIME_VERSION, applied, changed };
|
|
529
|
+
return { from, to: RUNTIME_VERSION, applied, changed, dryRun };
|
|
512
530
|
}
|
|
513
531
|
|
|
514
532
|
// ../../packages/core/src/repos.ts
|
|
@@ -528,6 +546,28 @@ function repoAdd(root, url, name0) {
|
|
|
528
546
|
git(["clone", url, gitdir], { stdio: ["ignore", "inherit", "inherit"] });
|
|
529
547
|
return { name, path: container, remote: remoteUrl(gitdir), branch: currentBranch(gitdir) };
|
|
530
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
|
+
}
|
|
531
571
|
function listReposInfo(root) {
|
|
532
572
|
return listRepoNames(root).map((name) => ({
|
|
533
573
|
name,
|
|
@@ -1002,7 +1042,9 @@ function parseArgs(argv) {
|
|
|
1002
1042
|
all: false,
|
|
1003
1043
|
archived: false,
|
|
1004
1044
|
open: false,
|
|
1005
|
-
noHydrate: false
|
|
1045
|
+
noHydrate: false,
|
|
1046
|
+
dryRun: false,
|
|
1047
|
+
quick: false
|
|
1006
1048
|
};
|
|
1007
1049
|
for (let i = 0; i < argv.length; i++) {
|
|
1008
1050
|
const a = argv[i];
|
|
@@ -1024,6 +1066,10 @@ function parseArgs(argv) {
|
|
|
1024
1066
|
flags.open = true;
|
|
1025
1067
|
} else if (a === "--no-hydrate") {
|
|
1026
1068
|
flags.noHydrate = true;
|
|
1069
|
+
} else if (a === "--dry-run") {
|
|
1070
|
+
flags.dryRun = true;
|
|
1071
|
+
} else if (a === "--quick") {
|
|
1072
|
+
flags.quick = true;
|
|
1027
1073
|
} else if (a.startsWith("--") && a.includes("=")) {
|
|
1028
1074
|
const eq = a.indexOf("=");
|
|
1029
1075
|
const key = VALUE_FLAGS[a.slice(0, eq)];
|
|
@@ -1109,11 +1155,12 @@ Global:
|
|
|
1109
1155
|
mx info [--all] [--porcelain] show runtime version, repos, works, ports (active only by default; --all to include archived; alias: mx i)
|
|
1110
1156
|
mx sync re-stamp runtime files (CLAUDE.md, scaffolding) from current templates \u2014 same-major, non-breaking
|
|
1111
1157
|
mx update self-update the mx CLI within its major (npm i -g); flags a newer major if one exists
|
|
1112
|
-
mx migrate
|
|
1158
|
+
mx migrate [--dry-run] upgrade an older-version runtime to the version this CLI supports (the only command allowed on a mismatched runtime); --dry-run previews the plan without changing anything
|
|
1113
1159
|
mx help | version
|
|
1114
1160
|
|
|
1115
1161
|
Repos (pristine clones):
|
|
1116
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
|
|
1117
1164
|
mx repo ls [--porcelain]
|
|
1118
1165
|
mx repo -n <name> path print the repo container path (cd "$(mx repo -n <name> path)")
|
|
1119
1166
|
mx repo -n <name> fetch git fetch (+ ff the checked-out and base branches)
|
|
@@ -1276,13 +1323,26 @@ function runGlobal(positionals, flags) {
|
|
|
1276
1323
|
}
|
|
1277
1324
|
case "migrate": {
|
|
1278
1325
|
const root = requireRuntime({ runtime: flags.runtime, allowVersionMismatch: true });
|
|
1279
|
-
const res = migrateRuntime(root);
|
|
1326
|
+
const res = migrateRuntime(root, { dryRun: flags.dryRun });
|
|
1280
1327
|
emit(() => {
|
|
1281
1328
|
if (res.applied.length === 0) {
|
|
1282
1329
|
console.log(`${check()} Runtime already at v${res.to} \u2014 nothing to migrate.`);
|
|
1283
1330
|
return;
|
|
1284
1331
|
}
|
|
1285
1332
|
const steps = res.applied.map((a) => `v${a.from}\u2192v${a.to}`).join(", ");
|
|
1333
|
+
if (res.dryRun) {
|
|
1334
|
+
console.log(
|
|
1335
|
+
`${dim("[dry run]")} Would migrate runtime ${bold(`v${res.from}\u2192v${res.to}`)} ${dim(`(${steps})`)}`
|
|
1336
|
+
);
|
|
1337
|
+
if (res.changed.length === 0) {
|
|
1338
|
+
console.log(` ${dim("(no path changes \u2014 version stamp only)")}`);
|
|
1339
|
+
} else {
|
|
1340
|
+
for (const p of res.changed) console.log(` ${dim(`+ ${p}`)}`);
|
|
1341
|
+
}
|
|
1342
|
+
console.log();
|
|
1343
|
+
console.log(` ${dim("No changes were made. Re-run without --dry-run to apply.")}`);
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1286
1346
|
console.log(
|
|
1287
1347
|
`${check()} Migrated runtime ${bold(`v${res.from}\u2192v${res.to}`)} ${dim(`(${steps})`)}`
|
|
1288
1348
|
);
|
|
@@ -1378,6 +1438,107 @@ function renderStatus(data) {
|
|
|
1378
1438
|
console.log();
|
|
1379
1439
|
}
|
|
1380
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
|
+
|
|
1381
1542
|
// src/commands/repo.ts
|
|
1382
1543
|
function need(v, msg) {
|
|
1383
1544
|
if (v == null || v === "") throw new MxError(msg, "BAD_ARGS");
|
|
@@ -1395,6 +1556,57 @@ function dispatchRepo(positionals, flags) {
|
|
|
1395
1556
|
emit(() => console.log(`${check()} cloned ${bold(res.name)} ${dim(`\u2192 ${res.path}`)}`), res);
|
|
1396
1557
|
return;
|
|
1397
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
|
+
}
|
|
1398
1610
|
case "ls": {
|
|
1399
1611
|
const repos = listReposInfo(root);
|
|
1400
1612
|
emit(() => {
|
|
@@ -1592,107 +1804,6 @@ function renderHealthDetail(h) {
|
|
|
1592
1804
|
// src/commands/work.ts
|
|
1593
1805
|
import * as path8 from "path";
|
|
1594
1806
|
|
|
1595
|
-
// src/open.ts
|
|
1596
|
-
import { execFileSync as execFileSync3 } from "child_process";
|
|
1597
|
-
function osascript(script) {
|
|
1598
|
-
try {
|
|
1599
|
-
execFileSync3("osascript", ["-e", script], { stdio: ["ignore", "ignore", "pipe"] });
|
|
1600
|
-
} catch (e) {
|
|
1601
|
-
const err = e;
|
|
1602
|
-
throw new MxError(
|
|
1603
|
-
`osascript failed: ${(err.stderr ?? err.message ?? "").toString().trim()}`,
|
|
1604
|
-
"OSASCRIPT"
|
|
1605
|
-
);
|
|
1606
|
-
}
|
|
1607
|
-
}
|
|
1608
|
-
function aplStr(s) {
|
|
1609
|
-
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
1610
|
-
}
|
|
1611
|
-
function openWorkLayout(workdir, workspace) {
|
|
1612
|
-
if (process.platform !== "darwin") {
|
|
1613
|
-
throw new MxError("-o/--open is only supported on macOS", "UNSUPPORTED");
|
|
1614
|
-
}
|
|
1615
|
-
let editorProcess = "Cursor";
|
|
1616
|
-
try {
|
|
1617
|
-
execFileSync3("open", ["-a", "Cursor", workspace], { stdio: "ignore" });
|
|
1618
|
-
} catch {
|
|
1619
|
-
try {
|
|
1620
|
-
execFileSync3("open", ["-a", "Visual Studio Code", workspace], { stdio: "ignore" });
|
|
1621
|
-
editorProcess = "Code";
|
|
1622
|
-
} catch {
|
|
1623
|
-
editorProcess = "";
|
|
1624
|
-
}
|
|
1625
|
-
}
|
|
1626
|
-
osascript(
|
|
1627
|
-
[
|
|
1628
|
-
'tell application "Terminal"',
|
|
1629
|
-
" activate",
|
|
1630
|
-
" set winCountBefore to count of windows",
|
|
1631
|
-
` do script "cd \\"${aplStr(workdir)}\\""`,
|
|
1632
|
-
" delay 0.5",
|
|
1633
|
-
" set winCountAfter to count of windows",
|
|
1634
|
-
"end tell",
|
|
1635
|
-
"if winCountAfter is less than or equal to winCountBefore then",
|
|
1636
|
-
' tell application "System Events" to tell process "Terminal"',
|
|
1637
|
-
" try",
|
|
1638
|
-
' click menu item "Move Tab to New Window" of menu "Window" of menu bar 1',
|
|
1639
|
-
" delay 0.4",
|
|
1640
|
-
" end try",
|
|
1641
|
-
" end tell",
|
|
1642
|
-
"end if",
|
|
1643
|
-
'tell application "System Events" to tell process "Terminal"',
|
|
1644
|
-
" try",
|
|
1645
|
-
' set value of attribute "AXFullScreen" of window 1 to true',
|
|
1646
|
-
" end try",
|
|
1647
|
-
"end tell"
|
|
1648
|
-
].join("\n")
|
|
1649
|
-
);
|
|
1650
|
-
if (editorProcess) {
|
|
1651
|
-
const appName = editorProcess === "Code" ? "Visual Studio Code" : "Cursor";
|
|
1652
|
-
osascript(
|
|
1653
|
-
[
|
|
1654
|
-
`tell application "${appName}" to activate`,
|
|
1655
|
-
// wait until the app is frontmost (cold launch can take a few seconds)
|
|
1656
|
-
'tell application "System Events"',
|
|
1657
|
-
" set n to 0",
|
|
1658
|
-
` repeat until (exists process "${editorProcess}") and (frontmost of process "${editorProcess}" is true)`,
|
|
1659
|
-
" delay 0.3",
|
|
1660
|
-
" set n to n + 1",
|
|
1661
|
-
" if n > 40 then exit repeat",
|
|
1662
|
-
" end repeat",
|
|
1663
|
-
"end tell",
|
|
1664
|
-
// let the workbench finish loading so it accepts the keybinding
|
|
1665
|
-
"delay 1.2",
|
|
1666
|
-
'tell application "System Events" to key code 3 using {control down, command down}'
|
|
1667
|
-
].join("\n")
|
|
1668
|
-
);
|
|
1669
|
-
}
|
|
1670
|
-
}
|
|
1671
|
-
|
|
1672
|
-
// src/hydrate.ts
|
|
1673
|
-
import { spawnSync as spawnSync3 } from "child_process";
|
|
1674
|
-
import { existsSync as existsSync2 } from "fs";
|
|
1675
|
-
function runWorktreeHydrate(ctx, quiet) {
|
|
1676
|
-
const script = repoHydrateScript(ctx.root, ctx.repo);
|
|
1677
|
-
if (!existsSync2(script)) return { ran: false, ok: true, missing: true };
|
|
1678
|
-
const env = {
|
|
1679
|
-
...process.env,
|
|
1680
|
-
MX_RUNTIME: ctx.root,
|
|
1681
|
-
MX_WORK: ctx.work,
|
|
1682
|
-
MX_REPO: ctx.repo,
|
|
1683
|
-
MX_WORKTREE_PATH: ctx.worktreePath,
|
|
1684
|
-
MX_BRANCH: ctx.branch,
|
|
1685
|
-
MX_BASE: ctx.base ?? "",
|
|
1686
|
-
MX_WORK_PATH: workPath(ctx.root, ctx.work).path
|
|
1687
|
-
};
|
|
1688
|
-
const r = spawnSync3(script, [ctx.worktreePath, ctx.branch], {
|
|
1689
|
-
cwd: ctx.worktreePath,
|
|
1690
|
-
env,
|
|
1691
|
-
stdio: quiet ? ["ignore", "ignore", "ignore"] : "inherit"
|
|
1692
|
-
});
|
|
1693
|
-
return { ran: true, ok: r.status === 0, missing: false };
|
|
1694
|
-
}
|
|
1695
|
-
|
|
1696
1807
|
// src/workhooks.ts
|
|
1697
1808
|
import { spawnSync as spawnSync4 } from "child_process";
|
|
1698
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
|