Skip to content

Commit b9dd1ed

Browse files
committed
Merge remote-tracking branch 'origin/main'
# Conflicts: # .github/workflows/ci.yml
2 parents 6d15156 + d951424 commit b9dd1ed

11 files changed

Lines changed: 313 additions & 38 deletions

File tree

.github/workflows/ci.yml

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,43 @@
1+
# Managed by sh1pt Actions Fleet
2+
# pack: node-pnpm-ci@1.2.0
3+
# install: sh1pt-actions-store
4+
# hash: sha256:4c5b9b88dee99de5f463194b4784ab267b400789a6d0f1aaf5b5dede9236376f
15
name: CI
26

37
on:
48
push:
5-
branches: [main]
9+
branches: [main, master]
610
pull_request:
711

812
permissions:
913
contents: read
1014

1115
concurrency:
12-
group: ci-${{ github.ref }}
16+
group: ci-${{ github.workflow }}-${{ github.ref }}
1317
cancel-in-progress: true
1418

1519
jobs:
16-
test:
20+
build:
1721
runs-on: ubuntu-latest
1822
timeout-minutes: 15
19-
strategy:
20-
matrix:
21-
node-version: [22, 24]
2223
steps:
2324
- uses: actions/checkout@v4
2425

26+
# pnpm version is read from the "packageManager" field in package.json.
27+
# Do not pin a version here — it conflicts with packageManager and fails
28+
# with ERR_PNPM_BAD_PM_VERSION.
29+
- uses: pnpm/action-setup@v4
30+
2531
- uses: actions/setup-node@v4
2632
with:
27-
node-version: ${{ matrix.node-version }}
33+
node-version: '22'
34+
cache: pnpm
35+
36+
- run: pnpm install --frozen-lockfile
2837

29-
- run: npm install --no-audit --no-fund
38+
# typecheck / test default to `pnpm run --if-present <script>` so a repo
39+
# that hasn't defined these scripts yet gets a green workflow instead of
40+
# failing on the first step. Once the scripts exist they run normally.
41+
- run: pnpm run --if-present typecheck
3042

31-
- run: npm test
32-
env:
33-
CI: true
43+
- run: pnpm run --if-present test

.github/workflows/test.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Managed by sh1pt Actions Fleet
2+
# pack: node-pnpm-test@1.2.0
3+
# install: sh1pt-actions-store
4+
# hash: sha256:53499e840c5023f2235444afd4f6a36d2c0bf8a40c52cfdf84615b17624de69b
5+
name: test
6+
7+
on:
8+
pull_request:
9+
push:
10+
branches: [master]
11+
12+
permissions:
13+
contents: read
14+
15+
jobs:
16+
test:
17+
runs-on: ubuntu-latest
18+
timeout-minutes: 15
19+
steps:
20+
- uses: actions/checkout@v4
21+
22+
# pnpm version is read from the "packageManager" field in package.json.
23+
# Do not pin a version here — it conflicts with packageManager and fails
24+
# with ERR_PNPM_BAD_PM_VERSION.
25+
- uses: pnpm/action-setup@v4
26+
27+
- uses: actions/setup-node@v4
28+
with:
29+
node-version: 22
30+
cache: pnpm
31+
32+
- run: pnpm install --frozen-lockfile
33+
34+
# test defaults to `pnpm run --if-present test` so a repo that hasn't
35+
# defined a test script yet gets a green workflow instead of failing on
36+
# the first step. Once the script exists it runs normally.
37+
- run: pnpm run --if-present test
38+
env:
39+
CI: true

bin/moshcode.mjs

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
#!/usr/bin/env node
22
import fs from "node:fs";
3-
import { spawn } from "node:child_process";
43
import { fileURLToPath } from "node:url";
54
import path from "node:path";
65
import { runScript } from "../src/runtime.mjs";
@@ -12,6 +11,7 @@ import {
1211
engineStatus,
1312
resolveEngine,
1413
openSession,
14+
runCmd,
1515
} from "../src/engines.mjs";
1616
import { TOOLS, toolList, toolStatus, resolveTool, openTool } from "../src/tools.mjs";
1717
import { runUpgrade } from "../src/upgrade.mjs";
@@ -20,6 +20,7 @@ import { locate, tilde } from "../src/pwd.mjs";
2020
import { createPrd, listPrds, authoringPrompt } from "../src/prd.mjs";
2121
import { login, loginDevice, whoami, logout } from "../src/auth.mjs";
2222
import { tui } from "../src/tui.mjs";
23+
import { moshcodeVersion } from "../src/ui.mjs";
2324

2425
const HERE = path.dirname(fileURLToPath(import.meta.url));
2526
const EXAMPLE = path.join(HERE, "..", "examples", "alive.mosh");
@@ -171,6 +172,11 @@ async function main() {
171172
// No args → open the interactive TUI shell (/agents <engine>, etc.).
172173
if (cmd === undefined) return tui();
173174

175+
if (cmd === "--version" || cmd === "-v" || cmd === "version") {
176+
console.log(moshcodeVersion() || "unknown");
177+
return;
178+
}
179+
174180
if (cmd === "engines") {
175181
printEngineStatus();
176182
return;
@@ -222,13 +228,14 @@ async function main() {
222228
}
223229
const { install, desc, bin } = entry;
224230
console.log(`🎸 installing ${target}${desc}\n$ ${install.cmd} ${install.args.join(" ")}\n`);
225-
const child = spawn(install.cmd, install.args, { stdio: "inherit" });
226-
child.on("error", (e) => { console.error(`install failed: ${e.message}`); process.exit(1); });
227-
child.on("exit", (code) => {
228-
if (code === 0) console.log(`\n✓ ${target} installed. run it with \`${bin}\`. 🤘`);
229-
backToPit(`install ${target}`, code);
230-
});
231-
return;
231+
const result = await runCmd(install.cmd, install.args);
232+
if (!result.ok) {
233+
console.error(`install failed: ${result.error?.message || result.error || "unknown error"}`);
234+
process.exitCode = 1;
235+
return;
236+
}
237+
if (result.code === 0) console.log(`\n✓ ${target} installed. run it with \`${bin}\`. 🤘`);
238+
return backToPit(`install ${target}`, result.code);
232239
}
233240
if (cmd === "upgrade" || cmd === "update") {
234241
console.log("🎸 moshcode upgrade — updating moshcode + installed engines/tools 🤘");

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "moshcode",
33
"version": "0.11.0",
44
"type": "module",
5-
"description": "moshcode a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript",
5+
"description": "moshcode \u2014 a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript",
66
"bin": {
77
"moshcode": "./bin/moshcode.mjs",
88
"moshscript": "./bin/moshscript.mjs"
@@ -20,5 +20,6 @@
2020
"install.sh",
2121
"README.md"
2222
],
23-
"license": "MIT"
23+
"license": "MIT",
24+
"packageManager": "pnpm@10.32.1"
2425
}

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/commands.mjs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ const COMMANDS = [
158158

159159
{
160160
name: "shell",
161-
summary: "run a shell command (blocking, spawnSync $SHELL -c)",
161+
summary: "run a shell command (blocking, cmd.exe on Windows or $SHELL -c elsewhere)",
162162
// The moshscript system verb for arbitrary shell commands. Blocking
163163
// (spawnSync + inherited stdio) so it runs inline in the no-`await` style,
164164
// and the child owns the terminal for interactive commands. Returns
@@ -171,10 +171,12 @@ const COMMANDS = [
171171
ctx.out(` ▶ shell(${JSON.stringify(cmd)}) → would run: $SHELL -c ${JSON.stringify(cmd)}`);
172172
return { ok: true, dryRun: true };
173173
}
174-
const sh = process.env.SHELL
175-
|| (process.platform === "win32" ? (process.env.COMSPEC || "cmd.exe") : "/bin/sh");
174+
const sh = process.platform === "win32"
175+
? (process.env.COMSPEC || "cmd.exe")
176+
: (process.env.SHELL || "/bin/sh");
177+
const shArgs = process.platform === "win32" ? ["/d", "/s", "/c", cmd] : ["-c", cmd];
176178
ctx.out(` ▶ shell: ${cmd}`);
177-
const res = spawnSync(sh, ["-c", cmd], { stdio: "inherit" });
179+
const res = spawnSync(sh, shArgs, { stdio: "inherit" });
178180
if (res.error) throw res.error;
179181
const code = res.status ?? 1;
180182
if (code !== 0) {

src/engines.mjs

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
// `agentsView` fall back to `agentArgs` — an autonomous session with native
1212
// approvals bypassed/auto-approved.
1313
import { spawn } from "node:child_process";
14-
import { existsSync, statSync } from "node:fs";
14+
import { existsSync, readFileSync, statSync } from "node:fs";
1515
import path from "node:path";
1616

1717
export const ENGINES = {
@@ -82,15 +82,54 @@ export function resolveEngine(token) {
8282
return key ? [key, ENGINES[key]] : null;
8383
}
8484

85-
/** Is `bin` an executable on PATH? (cross-platform-ish) */
86-
export function isInstalled(bin) {
87-
const exts = process.platform === "win32" ? (process.env.PATHEXT || ".EXE;.CMD;.BAT").split(";") : [""];
88-
for (const dir of (process.env.PATH || "").split(path.delimiter).filter(Boolean)) {
85+
function executableCandidates(bin) {
86+
const exts = process.platform === "win32" ? ["", ...(process.env.PATHEXT || ".EXE;.CMD;.BAT").split(";")] : [""];
87+
const dirs = path.isAbsolute(bin) || bin.includes(path.sep) ? [""] : (process.env.PATH || "").split(path.delimiter).filter(Boolean);
88+
const seen = new Set();
89+
const candidates = [];
90+
for (const dir of dirs) {
8991
for (const ext of exts) {
90-
try { if (existsSync(path.join(dir, bin + ext)) && statSync(path.join(dir, bin + ext)).isFile()) return true; } catch { /* keep looking */ }
92+
const candidate = dir ? path.join(dir, bin + ext) : bin + ext;
93+
const key = candidate.toLowerCase();
94+
if (!seen.has(key)) {
95+
seen.add(key);
96+
candidates.push(candidate);
97+
}
9198
}
9299
}
93-
return false;
100+
return candidates;
101+
}
102+
103+
function resolveExecutable(bin) {
104+
for (const candidate of executableCandidates(bin)) {
105+
try {
106+
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
107+
} catch { /* keep looking */ }
108+
}
109+
return null;
110+
}
111+
112+
function nodeShebang(file) {
113+
try {
114+
const head = readFileSync(file, "utf8").slice(0, 80);
115+
return /^#!.*\bnode(?:\.exe)?\b/.test(head);
116+
} catch {
117+
return false;
118+
}
119+
}
120+
121+
function spawnSpec(bin, args = []) {
122+
const resolved = resolveExecutable(bin);
123+
if (!resolved) return { cmd: bin, args };
124+
if (process.platform === "win32" && path.extname(resolved) === "" && nodeShebang(resolved)) {
125+
return { cmd: process.execPath, args: [resolved, ...args] };
126+
}
127+
return { cmd: resolved, args };
128+
}
129+
130+
/** Is `bin` an executable on PATH? (cross-platform-ish) */
131+
export function isInstalled(bin) {
132+
return Boolean(resolveExecutable(bin));
94133
}
95134

96135
// Headless "run one prompt, print the answer, exit" invocation per engine — the
@@ -147,7 +186,8 @@ export function agentLaunchArgs(engine, args = []) {
147186
export function runCmd(cmd, args = []) {
148187
return new Promise((resolve) => {
149188
let child;
150-
try { child = spawn(cmd, args, { stdio: "inherit" }); }
189+
const spec = spawnSpec(cmd, args);
190+
try { child = spawn(spec.cmd, spec.args, { stdio: "inherit" }); }
151191
catch (e) { resolve({ ok: false, error: e }); return; }
152192
child.on("error", (e) => resolve({ ok: false, error: e }));
153193
child.on("exit", (code, signal) => resolve({ ok: true, code, signal }));
@@ -168,7 +208,8 @@ export function openPassthrough(target, args = []) {
168208
for (const k of target.stripEnv) delete env[k];
169209
}
170210
let child;
171-
try { child = spawn(target.bin, args, { stdio: "inherit", env }); }
211+
const spec = spawnSpec(target.bin, args);
212+
try { child = spawn(spec.cmd, spec.args, { stdio: "inherit", env }); }
172213
catch (e) { resolve({ ok: false, error: e }); return; }
173214
child.on("error", (e) => resolve({ ok: false, error: e }));
174215
child.on("exit", (code, signal) => resolve({ ok: true, code, signal }));

src/tools.mjs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,66 @@ export function toolUpgradeSpec(tool) {
6565
export function openTool(tool, args = []) {
6666
return openPassthrough(tool, args);
6767
}
68+
69+
// Generic utilities used by the app/package surface.
70+
71+
/**
72+
* Format a number as currency
73+
*/
74+
export function formatCurrency(amount, currency = 'USD') {
75+
return new Intl.NumberFormat('en-US', {
76+
style: 'currency',
77+
currency
78+
}).format(amount);
79+
}
80+
81+
/**
82+
* Generate a random ID
83+
*/
84+
export function generateId(length = 8) {
85+
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
86+
let result = '';
87+
for (let i = 0; i < length; i++) {
88+
result += chars.charAt(Math.floor(Math.random() * chars.length));
89+
}
90+
return result;
91+
}
92+
93+
/**
94+
* Debounce function calls
95+
*/
96+
export function debounce(fn, delay = 300) {
97+
let timeoutId;
98+
return function (...args) {
99+
clearTimeout(timeoutId);
100+
timeoutId = setTimeout(() => fn.apply(this, args), delay);
101+
};
102+
}
103+
104+
/**
105+
* Deep clone an object
106+
*/
107+
export function deepClone(obj) {
108+
return JSON.parse(JSON.stringify(obj));
109+
}
110+
111+
/**
112+
* Sleep for ms milliseconds
113+
*/
114+
export function sleep(ms) {
115+
return new Promise(resolve => setTimeout(resolve, ms));
116+
}
117+
118+
/**
119+
* Retry a function with exponential backoff
120+
*/
121+
export async function retry(fn, maxAttempts = 3, baseDelay = 1000) {
122+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
123+
try {
124+
return await fn();
125+
} catch (error) {
126+
if (attempt === maxAttempts) throw error;
127+
await sleep(baseDelay * Math.pow(2, attempt - 1));
128+
}
129+
}
130+
}

0 commit comments

Comments
 (0)