codex-webstrapper 0.1.5 → 0.2.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.
@@ -17,9 +17,20 @@ OPEN_FLAG="0"
17
17
  TOKEN_FILE="${CODEX_WEBSTRAP_TOKEN_FILE:-}"
18
18
  CODEX_APP="${CODEX_WEBSTRAP_CODEX_APP:-}"
19
19
  INTERNAL_WS_PORT="${CODEX_WEBSTRAP_INTERNAL_WS_PORT:-38080}"
20
+ PORT_SET="0"
21
+ BIND_SET="0"
20
22
  COPY_FLAG="0"
21
23
  COMMAND="serve"
22
24
 
25
+ # Treat non-empty env overrides as explicit selections so open-mode runtime
26
+ # autodetection cannot replace them.
27
+ if [[ -n "${CODEX_WEBSTRAP_PORT:-}" ]]; then
28
+ PORT_SET="1"
29
+ fi
30
+ if [[ -n "${CODEX_WEBSTRAP_BIND:-}" ]]; then
31
+ BIND_SET="1"
32
+ fi
33
+
23
34
  DEFAULT_TOKEN_FILE="${HOME}/.codex-webstrap/token"
24
35
  if [[ -z "$TOKEN_FILE" ]]; then
25
36
  TOKEN_FILE="$DEFAULT_TOKEN_FILE"
@@ -70,14 +81,20 @@ fi
70
81
 
71
82
  while [[ $# -gt 0 ]]; do
72
83
  case "$1" in
84
+ open)
85
+ COMMAND="open"
86
+ shift
87
+ ;;
73
88
  --port)
74
89
  require_value "$1" "${2:-}"
75
90
  PORT="$2"
91
+ PORT_SET="1"
76
92
  shift 2
77
93
  ;;
78
94
  --bind)
79
95
  require_value "$1" "${2:-}"
80
96
  BIND="$2"
97
+ BIND_SET="1"
81
98
  shift 2
82
99
  ;;
83
100
  --token-file)
@@ -126,6 +143,36 @@ if [[ "$COMMAND" == "open" ]]; then
126
143
  exit 1
127
144
  fi
128
145
 
146
+ if [[ "$PORT_SET" == "0" || "$BIND_SET" == "0" ]]; then
147
+ RUNTIME_FILE="${TOKEN_FILE}.runtime"
148
+ if [[ -f "$RUNTIME_FILE" ]]; then
149
+ RUNTIME_VALUES="$(node -e 'const fs = require("node:fs"); const filePath = process.argv[2]; try { const data = JSON.parse(fs.readFileSync(filePath, "utf8")); const bind = data.bind || ""; const port = data.port || ""; if (!bind || !port) { process.exit(1); } process.stdout.write(`${bind}\n${port}\n`); } catch { process.exit(1); }' "$TOKEN_FILE" "$RUNTIME_FILE" 2>/dev/null || true)"
150
+ if [[ -n "$RUNTIME_VALUES" ]]; then
151
+ RUNTIME_BIND="$(printf '%s' "$RUNTIME_VALUES" | sed -n '1p')"
152
+ RUNTIME_PORT="$(printf '%s' "$RUNTIME_VALUES" | sed -n '2p')"
153
+ if [[ -n "$RUNTIME_BIND" && -n "$RUNTIME_PORT" ]]; then
154
+ if command -v curl >/dev/null 2>&1; then
155
+ if curl -fsS --max-time 1 --connect-timeout 1 "http://${RUNTIME_BIND}:${RUNTIME_PORT}/__webstrapper/healthz" >/dev/null 2>&1; then
156
+ if [[ "$BIND_SET" == "0" ]]; then
157
+ BIND="$RUNTIME_BIND"
158
+ fi
159
+ if [[ "$PORT_SET" == "0" ]]; then
160
+ PORT="$RUNTIME_PORT"
161
+ fi
162
+ fi
163
+ else
164
+ if [[ "$BIND_SET" == "0" ]]; then
165
+ BIND="$RUNTIME_BIND"
166
+ fi
167
+ if [[ "$PORT_SET" == "0" ]]; then
168
+ PORT="$RUNTIME_PORT"
169
+ fi
170
+ fi
171
+ fi
172
+ fi
173
+ fi
174
+ fi
175
+
129
176
  ENCODED_TOKEN="$(node -e 'process.stdout.write(encodeURIComponent(process.argv[1] || ""))' "$TOKEN")"
130
177
  AUTH_URL="http://${BIND}:${PORT}/__webstrapper/auth?token=${ENCODED_TOKEN}"
131
178
 
@@ -134,9 +181,8 @@ if [[ "$COMMAND" == "open" ]]; then
134
181
  echo "pbcopy not found in PATH" >&2
135
182
  exit 1
136
183
  fi
137
- printf '%s' "$AUTH_URL" | pbcopy
184
+ printf '%s' "$AUTH_URL" | pbcopy >/dev/null 2>&1
138
185
  printf 'Copied auth URL to clipboard.\n'
139
- printf '%s\n' "$AUTH_URL"
140
186
  else
141
187
  open "$AUTH_URL"
142
188
  printf 'Opened auth URL in browser.\n'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-webstrapper",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Web wrapper for Codex desktop assets with bridge + token auth",
6
6
  "license": "MIT",
@@ -599,6 +599,18 @@
599
599
  }
600
600
 
601
601
  function sendMessageFromView(payload) {
602
+ if (payload?.type === "open-in-browser") {
603
+ const target = payload?.url || payload?.href || null;
604
+ if (target) {
605
+ const opened = window.open(target, "_blank", "noopener,noreferrer");
606
+ if (!opened) {
607
+ // Fallback when popup is blocked.
608
+ window.location.href = target;
609
+ }
610
+ }
611
+ return Promise.resolve();
612
+ }
613
+
602
614
  sendEnvelope({
603
615
  type: "view-message",
604
616
  payload
@@ -1051,6 +1051,16 @@ export class MessageRouter {
1051
1051
  };
1052
1052
  break;
1053
1053
  }
1054
+ case "git-create-branch": {
1055
+ payload = await this._handleGitCreateBranch(params);
1056
+ status = payload.ok ? 200 : 500;
1057
+ break;
1058
+ }
1059
+ case "git-checkout-branch": {
1060
+ payload = await this._handleGitCheckoutBranch(params);
1061
+ status = payload.ok ? 200 : 500;
1062
+ break;
1063
+ }
1054
1064
  case "git-push": {
1055
1065
  payload = await this._handleGitPush(params);
1056
1066
  status = payload.ok ? 200 : 500;
@@ -1108,14 +1118,29 @@ export class MessageRouter {
1108
1118
  payload = await this._resolveGhPrStatus({ cwd, headBranch });
1109
1119
  break;
1110
1120
  }
1121
+ case "generate-pull-request-message":
1122
+ payload = await this._handleGeneratePullRequestMessage(params);
1123
+ break;
1124
+ case "gh-pr-create":
1125
+ payload = await this._handleGhPrCreate(params);
1126
+ break;
1111
1127
  case "paths-exist": {
1112
1128
  const paths = Array.isArray(params?.paths) ? params.paths.filter((p) => typeof p === "string") : [];
1113
1129
  payload = { existingPaths: paths };
1114
1130
  break;
1115
1131
  }
1116
1132
  default:
1117
- this.logger.warn("Unhandled vscode fetch endpoint", { endpoint });
1118
- payload = {};
1133
+ if (endpoint.startsWith("git-")) {
1134
+ this.logger.warn("Unhandled git vscode fetch endpoint", { endpoint });
1135
+ payload = {
1136
+ ok: false,
1137
+ error: `unhandled git endpoint: ${endpoint}`
1138
+ };
1139
+ status = 500;
1140
+ } else {
1141
+ this.logger.warn("Unhandled vscode fetch endpoint", { endpoint });
1142
+ payload = {};
1143
+ }
1119
1144
  }
1120
1145
 
1121
1146
  this._sendFetchJson(ws, {
@@ -1318,6 +1343,350 @@ export class MessageRouter {
1318
1343
  };
1319
1344
  }
1320
1345
 
1346
+ async _handleGeneratePullRequestMessage(params) {
1347
+ const cwd = typeof params?.cwd === "string" && params.cwd.length > 0
1348
+ ? params.cwd
1349
+ : process.cwd();
1350
+ const prompt = typeof params?.prompt === "string" ? params.prompt : "";
1351
+ const generated = await this._generatePullRequestMessageWithCodex({ cwd, prompt });
1352
+ const fallback = generated || await this._generateFallbackPullRequestMessage({ cwd, prompt });
1353
+ const title = this._normalizePullRequestTitle(fallback.title);
1354
+ const body = this._normalizePullRequestBody(fallback.body);
1355
+
1356
+ return {
1357
+ status: "success",
1358
+ title,
1359
+ body,
1360
+ // Older clients read `bodyInstructions`; keep it in sync with the generated body.
1361
+ bodyInstructions: body
1362
+ };
1363
+ }
1364
+
1365
+ _normalizePullRequestTitle(title) {
1366
+ if (typeof title !== "string") {
1367
+ return "Update project files";
1368
+ }
1369
+
1370
+ const normalized = title.replace(/\s+/g, " ").trim();
1371
+ if (normalized.length === 0) {
1372
+ return "Update project files";
1373
+ }
1374
+ if (normalized.length <= 120) {
1375
+ return normalized;
1376
+ }
1377
+ return `${normalized.slice(0, 117).trimEnd()}...`;
1378
+ }
1379
+
1380
+ _normalizePullRequestBody(body) {
1381
+ if (typeof body !== "string") {
1382
+ return "## Summary\n- Update project files.\n\n## Testing\n- Not run (context not provided).";
1383
+ }
1384
+
1385
+ const normalized = body.trim();
1386
+ if (normalized.length > 0) {
1387
+ return normalized;
1388
+ }
1389
+ return "## Summary\n- Update project files.\n\n## Testing\n- Not run (context not provided).";
1390
+ }
1391
+
1392
+ _buildPullRequestCodexPrompt(prompt) {
1393
+ const context = typeof prompt === "string" && prompt.trim().length > 0
1394
+ ? prompt.trim().slice(0, 20_000)
1395
+ : "No additional context was provided.";
1396
+
1397
+ return [
1398
+ "Generate a GitHub pull request title and body.",
1399
+ "Return JSON that matches the provided schema.",
1400
+ "Requirements:",
1401
+ "- title: concise imperative sentence, maximum 72 characters.",
1402
+ "- body: markdown with sections exactly '## Summary' and '## Testing'.",
1403
+ "- Summary should include 2 to 6 concrete bullet points.",
1404
+ "- Testing should include bullet points. If unknown, say '- Not run (context not provided).'.",
1405
+ "- Do not wrap output in code fences.",
1406
+ "- Use only the provided context.",
1407
+ "",
1408
+ "Context:",
1409
+ context
1410
+ ].join("\n");
1411
+ }
1412
+
1413
+ async _generatePullRequestMessageWithCodex({ cwd, prompt }) {
1414
+ let tempDir = null;
1415
+ try {
1416
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "codex-webstrap-prmsg-"));
1417
+ const schemaPath = path.join(tempDir, "schema.json");
1418
+ const outputPath = path.join(tempDir, "output.json");
1419
+ const schema = {
1420
+ $schema: "http://json-schema.org/draft-07/schema#",
1421
+ type: "object",
1422
+ required: ["title", "body"],
1423
+ additionalProperties: false,
1424
+ properties: {
1425
+ title: { type: "string" },
1426
+ body: { type: "string" }
1427
+ }
1428
+ };
1429
+ await fs.writeFile(schemaPath, JSON.stringify(schema), "utf8");
1430
+
1431
+ const result = await this._runCommand(
1432
+ "codex",
1433
+ [
1434
+ "exec",
1435
+ "--ephemeral",
1436
+ "--sandbox",
1437
+ "read-only",
1438
+ "--output-schema",
1439
+ schemaPath,
1440
+ "--output-last-message",
1441
+ outputPath,
1442
+ this._buildPullRequestCodexPrompt(prompt)
1443
+ ],
1444
+ {
1445
+ timeoutMs: 120_000,
1446
+ allowNonZero: true,
1447
+ cwd
1448
+ }
1449
+ );
1450
+
1451
+ if (!result.ok) {
1452
+ this.logger.warn("PR message generation via codex failed", {
1453
+ cwd,
1454
+ error: result.error || result.stderr || "unknown error"
1455
+ });
1456
+ return null;
1457
+ }
1458
+
1459
+ const rawOutput = await fs.readFile(outputPath, "utf8");
1460
+ const parsed = safeJsonParse(rawOutput);
1461
+ if (!parsed || typeof parsed !== "object") {
1462
+ return null;
1463
+ }
1464
+
1465
+ const title = this._normalizePullRequestTitle(parsed.title);
1466
+ const body = this._normalizePullRequestBody(parsed.body);
1467
+ return { title, body };
1468
+ } catch (error) {
1469
+ this.logger.warn("PR message generation via codex errored", {
1470
+ cwd,
1471
+ error: toErrorMessage(error)
1472
+ });
1473
+ return null;
1474
+ } finally {
1475
+ if (tempDir) {
1476
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
1477
+ }
1478
+ }
1479
+ }
1480
+
1481
+ async _resolvePullRequestBaseRef(cwd) {
1482
+ const originHead = await this._runCommand(
1483
+ "git",
1484
+ ["-C", cwd, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
1485
+ { timeoutMs: 5_000, allowNonZero: true, cwd }
1486
+ );
1487
+ if (originHead.ok && originHead.stdout) {
1488
+ return originHead.stdout;
1489
+ }
1490
+
1491
+ const candidates = ["origin/main", "origin/master", "main", "master"];
1492
+ for (const candidate of candidates) {
1493
+ const exists = await this._runCommand(
1494
+ "git",
1495
+ ["-C", cwd, "rev-parse", "--verify", "--quiet", candidate],
1496
+ { timeoutMs: 5_000, allowNonZero: true, cwd }
1497
+ );
1498
+ if (exists.code === 0) {
1499
+ return candidate;
1500
+ }
1501
+ }
1502
+
1503
+ return null;
1504
+ }
1505
+
1506
+ async _generateFallbackPullRequestMessage({ cwd, prompt }) {
1507
+ const baseRef = await this._resolvePullRequestBaseRef(cwd);
1508
+ const logArgs = baseRef
1509
+ ? ["-C", cwd, "log", "--no-merges", "--pretty=format:%s", `${baseRef}..HEAD`, "-n", "6"]
1510
+ : ["-C", cwd, "log", "--no-merges", "--pretty=format:%s", "-n", "6"];
1511
+ const logResult = await this._runCommand("git", logArgs, {
1512
+ timeoutMs: 8_000,
1513
+ allowNonZero: true,
1514
+ cwd
1515
+ });
1516
+ const commitSubjects = (logResult.stdout || "")
1517
+ .split("\n")
1518
+ .map((line) => line.trim())
1519
+ .filter(Boolean);
1520
+
1521
+ const range = baseRef ? `${baseRef}...HEAD` : "HEAD~1..HEAD";
1522
+ const filesResult = await this._runCommand(
1523
+ "git",
1524
+ ["-C", cwd, "diff", "--name-only", "--diff-filter=ACMR", range],
1525
+ { timeoutMs: 8_000, allowNonZero: true, cwd }
1526
+ );
1527
+ const changedFiles = (filesResult.stdout || "")
1528
+ .split("\n")
1529
+ .map((line) => line.trim())
1530
+ .filter(Boolean);
1531
+
1532
+ const statsResult = await this._runCommand(
1533
+ "git",
1534
+ ["-C", cwd, "diff", "--numstat", range],
1535
+ { timeoutMs: 8_000, allowNonZero: true, cwd }
1536
+ );
1537
+ let additions = 0;
1538
+ let deletions = 0;
1539
+ for (const line of (statsResult.stdout || "").split("\n")) {
1540
+ const [addedRaw, deletedRaw] = line.split("\t");
1541
+ const added = Number.parseInt(addedRaw, 10);
1542
+ const deleted = Number.parseInt(deletedRaw, 10);
1543
+ additions += Number.isFinite(added) ? added : 0;
1544
+ deletions += Number.isFinite(deleted) ? deleted : 0;
1545
+ }
1546
+
1547
+ const branch = await this._resolveGitCurrentBranch(cwd);
1548
+ const title = this._normalizePullRequestTitle(
1549
+ commitSubjects[0] || (branch ? `Update ${branch}` : "Update project files")
1550
+ );
1551
+
1552
+ const summaryBullets = [];
1553
+ for (const subject of commitSubjects.slice(0, 3)) {
1554
+ summaryBullets.push(subject);
1555
+ }
1556
+ if (summaryBullets.length === 0) {
1557
+ summaryBullets.push("Update project files.");
1558
+ }
1559
+ if (changedFiles.length > 0) {
1560
+ summaryBullets.push(`Modify ${changedFiles.length} file${changedFiles.length === 1 ? "" : "s"}.`);
1561
+ }
1562
+ if (additions > 0 || deletions > 0) {
1563
+ summaryBullets.push(`Diff summary: +${additions} / -${deletions} lines.`);
1564
+ }
1565
+ if (baseRef) {
1566
+ summaryBullets.push(`Base branch: ${baseRef}.`);
1567
+ }
1568
+
1569
+ const bodyLines = ["## Summary"];
1570
+ for (const bullet of summaryBullets.slice(0, 6)) {
1571
+ bodyLines.push(`- ${bullet}`);
1572
+ }
1573
+
1574
+ bodyLines.push("", "## Testing", "- Not run (context not provided).");
1575
+
1576
+ if (changedFiles.length > 0) {
1577
+ bodyLines.push("", "## Files Changed");
1578
+ for (const file of changedFiles.slice(0, 12)) {
1579
+ bodyLines.push(`- \`${file}\``);
1580
+ }
1581
+ if (changedFiles.length > 12) {
1582
+ bodyLines.push(`- \`...and ${changedFiles.length - 12} more\``);
1583
+ }
1584
+ } else if (typeof prompt === "string" && prompt.trim().length > 0) {
1585
+ bodyLines.push("", "## Notes", "- Generated from available thread context.");
1586
+ }
1587
+
1588
+ return {
1589
+ title,
1590
+ body: this._normalizePullRequestBody(bodyLines.join("\n"))
1591
+ };
1592
+ }
1593
+
1594
+ _extractGithubPrUrl(text) {
1595
+ if (typeof text !== "string" || text.length === 0) {
1596
+ return null;
1597
+ }
1598
+ const match = text.match(/https:\/\/github\.com\/[^\s/]+\/[^\s/]+\/pull\/\d+/);
1599
+ return match ? match[0] : null;
1600
+ }
1601
+
1602
+ async _handleGhPrCreate(params) {
1603
+ const cwd = typeof params?.cwd === "string" && params.cwd.length > 0
1604
+ ? params.cwd
1605
+ : process.cwd();
1606
+ const headBranch = typeof params?.headBranch === "string" ? params.headBranch.trim() : "";
1607
+ const baseBranch = typeof params?.baseBranch === "string" ? params.baseBranch.trim() : "";
1608
+ const bodyInstructions = typeof params?.bodyInstructions === "string" ? params.bodyInstructions : "";
1609
+ const titleOverride = typeof params?.titleOverride === "string" ? params.titleOverride.trim() : "";
1610
+ const bodyOverride = typeof params?.bodyOverride === "string" ? params.bodyOverride.trim() : "";
1611
+
1612
+ if (!headBranch || !baseBranch) {
1613
+ return {
1614
+ status: "error",
1615
+ error: "headBranch and baseBranch are required",
1616
+ url: null,
1617
+ number: null
1618
+ };
1619
+ }
1620
+
1621
+ const ghStatus = await this._resolveGhCliStatus();
1622
+ if (!ghStatus.isInstalled || !ghStatus.isAuthenticated) {
1623
+ return {
1624
+ status: "error",
1625
+ error: "gh cli unavailable or unauthenticated",
1626
+ url: null,
1627
+ number: null
1628
+ };
1629
+ }
1630
+
1631
+ const args = [
1632
+ "pr",
1633
+ "create",
1634
+ "--head",
1635
+ headBranch,
1636
+ "--base",
1637
+ baseBranch
1638
+ ];
1639
+ const shouldFill = titleOverride.length === 0 || bodyOverride.length === 0;
1640
+ if (shouldFill) {
1641
+ args.push("--fill");
1642
+ }
1643
+ if (titleOverride.length > 0) {
1644
+ args.push("--title", titleOverride);
1645
+ }
1646
+ if (bodyOverride.length > 0) {
1647
+ args.push("--body", bodyOverride);
1648
+ } else if (bodyInstructions.trim().length > 0) {
1649
+ args.push("--body", bodyInstructions);
1650
+ }
1651
+
1652
+ const result = await this._runCommand("gh", args, {
1653
+ timeoutMs: 30_000,
1654
+ allowNonZero: true,
1655
+ cwd
1656
+ });
1657
+
1658
+ if (result.ok) {
1659
+ const url = this._extractGithubPrUrl(result.stdout) || this._extractGithubPrUrl(result.stderr);
1660
+ const numberMatch = url ? url.match(/\/pull\/(\d+)/) : null;
1661
+ const number = numberMatch ? Number.parseInt(numberMatch[1], 10) : null;
1662
+ return {
1663
+ status: "success",
1664
+ url: url || null,
1665
+ number: Number.isFinite(number) ? number : null
1666
+ };
1667
+ }
1668
+
1669
+ const combinedOutput = `${result.stdout || ""}\n${result.stderr || ""}`;
1670
+ const existingUrl = this._extractGithubPrUrl(combinedOutput);
1671
+ const alreadyExists = /already exists|a pull request for branch/i.test(combinedOutput);
1672
+ if (alreadyExists && existingUrl) {
1673
+ const numberMatch = existingUrl.match(/\/pull\/(\d+)/);
1674
+ const number = numberMatch ? Number.parseInt(numberMatch[1], 10) : null;
1675
+ return {
1676
+ status: "success",
1677
+ url: existingUrl,
1678
+ number: Number.isFinite(number) ? number : null
1679
+ };
1680
+ }
1681
+
1682
+ return {
1683
+ status: "error",
1684
+ error: result.error || result.stderr || "failed to create pull request",
1685
+ url: null,
1686
+ number: null
1687
+ };
1688
+ }
1689
+
1321
1690
  async _resolveGitMergeBase({ gitRoot, baseBranch }) {
1322
1691
  if (!baseBranch) {
1323
1692
  return {
@@ -1339,16 +1708,138 @@ export class MessageRouter {
1339
1708
  };
1340
1709
  }
1341
1710
 
1711
+ async _resolveGitCurrentBranch(cwd) {
1712
+ const result = await this._runCommand("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], {
1713
+ timeoutMs: 5_000,
1714
+ allowNonZero: true,
1715
+ cwd
1716
+ });
1717
+ if (!result.ok || !result.stdout || result.stdout === "HEAD") {
1718
+ return null;
1719
+ }
1720
+ return result.stdout;
1721
+ }
1722
+
1723
+ async _handleGitCreateBranch(params) {
1724
+ const cwd = typeof params?.cwd === "string" && params.cwd.length > 0
1725
+ ? params.cwd
1726
+ : process.cwd();
1727
+ const branch = typeof params?.branch === "string" && params.branch.trim().length > 0
1728
+ ? params.branch.trim()
1729
+ : null;
1730
+
1731
+ if (!branch) {
1732
+ return {
1733
+ ok: false,
1734
+ code: null,
1735
+ error: "branch is required",
1736
+ stdout: "",
1737
+ stderr: ""
1738
+ };
1739
+ }
1740
+
1741
+ const existingResult = await this._runCommand("git", ["-C", cwd, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`], {
1742
+ cwd,
1743
+ timeoutMs: 10_000,
1744
+ allowNonZero: true
1745
+ });
1746
+ if (existingResult.code === 0) {
1747
+ return {
1748
+ ok: true,
1749
+ code: 0,
1750
+ branch,
1751
+ created: false,
1752
+ alreadyExists: true,
1753
+ stdout: existingResult.stdout || "",
1754
+ stderr: existingResult.stderr || ""
1755
+ };
1756
+ }
1757
+
1758
+ const createResult = await this._runCommand("git", ["-C", cwd, "branch", branch], {
1759
+ cwd,
1760
+ timeoutMs: 10_000,
1761
+ allowNonZero: true
1762
+ });
1763
+ if (createResult.ok) {
1764
+ return {
1765
+ ok: true,
1766
+ code: createResult.code,
1767
+ branch,
1768
+ created: true,
1769
+ alreadyExists: false,
1770
+ stdout: createResult.stdout || "",
1771
+ stderr: createResult.stderr || ""
1772
+ };
1773
+ }
1774
+
1775
+ return {
1776
+ ok: false,
1777
+ code: createResult.code,
1778
+ error: createResult.error || createResult.stderr || "git branch failed",
1779
+ stdout: createResult.stdout || "",
1780
+ stderr: createResult.stderr || ""
1781
+ };
1782
+ }
1783
+
1784
+ async _handleGitCheckoutBranch(params) {
1785
+ const cwd = typeof params?.cwd === "string" && params.cwd.length > 0
1786
+ ? params.cwd
1787
+ : process.cwd();
1788
+ const branch = typeof params?.branch === "string" && params.branch.trim().length > 0
1789
+ ? params.branch.trim()
1790
+ : null;
1791
+
1792
+ if (!branch) {
1793
+ return {
1794
+ ok: false,
1795
+ code: null,
1796
+ error: "branch is required",
1797
+ stdout: "",
1798
+ stderr: ""
1799
+ };
1800
+ }
1801
+
1802
+ const checkoutResult = await this._runCommand("git", ["-C", cwd, "checkout", branch], {
1803
+ cwd,
1804
+ timeoutMs: 20_000,
1805
+ allowNonZero: true
1806
+ });
1807
+ if (!checkoutResult.ok) {
1808
+ return {
1809
+ ok: false,
1810
+ code: checkoutResult.code,
1811
+ error: checkoutResult.error || checkoutResult.stderr || "git checkout failed",
1812
+ stdout: checkoutResult.stdout || "",
1813
+ stderr: checkoutResult.stderr || ""
1814
+ };
1815
+ }
1816
+
1817
+ const currentBranch = await this._resolveGitCurrentBranch(cwd);
1818
+ return {
1819
+ ok: true,
1820
+ code: checkoutResult.code,
1821
+ branch: currentBranch || branch,
1822
+ stdout: checkoutResult.stdout || "",
1823
+ stderr: checkoutResult.stderr || ""
1824
+ };
1825
+ }
1826
+
1342
1827
  async _handleGitPush(params) {
1343
1828
  const cwd = typeof params?.cwd === "string" && params.cwd.length > 0
1344
1829
  ? params.cwd
1345
1830
  : process.cwd();
1346
- const remote = typeof params?.remote === "string" && params.remote.trim().length > 0
1831
+ const explicitRemote = typeof params?.remote === "string" && params.remote.trim().length > 0
1347
1832
  ? params.remote.trim()
1348
1833
  : null;
1349
1834
  const branch = typeof params?.branch === "string" && params.branch.trim().length > 0
1350
1835
  ? params.branch.trim()
1351
1836
  : null;
1837
+ const refspec = typeof params?.refspec === "string" && params.refspec.trim().length > 0
1838
+ ? params.refspec.trim()
1839
+ : null;
1840
+ const remote = explicitRemote || (
1841
+ params?.setUpstream === true && (branch || refspec) ? "origin" : null
1842
+ );
1352
1843
 
1353
1844
  const args = ["-C", cwd, "push"];
1354
1845
  if (params?.force === true || params?.forceWithLease === true) {
@@ -1360,7 +1851,9 @@ export class MessageRouter {
1360
1851
  if (remote) {
1361
1852
  args.push(remote);
1362
1853
  }
1363
- if (branch) {
1854
+ if (refspec) {
1855
+ args.push(refspec);
1856
+ } else if (branch) {
1364
1857
  args.push(branch);
1365
1858
  }
1366
1859
 
package/src/server.mjs CHANGED
@@ -114,6 +114,7 @@ async function main() {
114
114
  const config = parseConfig();
115
115
 
116
116
  const tokenResult = await ensurePersistentToken(config.tokenFile);
117
+ const runtimeMetadataPath = `${tokenResult.tokenFilePath}.runtime`;
117
118
  const sessionStore = new SessionStore({ ttlMs: 1000 * 60 * 60 * 12 });
118
119
  const auth = createAuthController({ token: tokenResult.token, sessionStore });
119
120
 
@@ -299,6 +300,25 @@ async function main() {
299
300
  server.listen(config.port, config.bind, resolve);
300
301
  });
301
302
 
303
+ try {
304
+ await fs.writeFile(
305
+ runtimeMetadataPath,
306
+ JSON.stringify({
307
+ bind: config.bind,
308
+ port: config.port,
309
+ tokenFile: tokenResult.tokenFilePath,
310
+ pid: process.pid,
311
+ startedAt: Date.now()
312
+ }) + "\n",
313
+ { mode: 0o600 }
314
+ );
315
+ } catch (error) {
316
+ logger.warn("Failed to write runtime metadata file", {
317
+ path: runtimeMetadataPath,
318
+ error: toErrorMessage(error)
319
+ });
320
+ }
321
+
302
322
  const authHint = `http://${config.bind}:${config.port}/__webstrapper/auth?token=<redacted>`;
303
323
  const loginCommand = `open \"http://${config.bind}:${config.port}/__webstrapper/auth?token=$(cat ${tokenResult.tokenFilePath})\"`;
304
324
 
@@ -356,6 +376,11 @@ async function main() {
356
376
  await new Promise((resolve) => {
357
377
  server.close(() => resolve());
358
378
  });
379
+ try {
380
+ await fs.unlink(runtimeMetadataPath);
381
+ } catch {
382
+ // ignore
383
+ }
359
384
 
360
385
  process.exit(0);
361
386
  }