@pushpalsdev/cli 1.0.1 → 1.0.2

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
@@ -25,10 +25,16 @@ pushpals
25
25
  The CLI hard-fails if:
26
26
 
27
27
  - current directory is not a git repository
28
- - LocalBuddy is unavailable
29
28
  - LocalBuddy is attached to a different repo root
30
29
  - Bun runtime is not installed (for npm-installed entrypoint execution)
31
30
 
31
+ Behavior:
32
+
33
+ - If LocalBuddy is unavailable, CLI auto-start bootstraps embedded
34
+ `server + localbuddy + remotebuddy + source_control_manager`.
35
+ - Runtime clone path defaults to `~/.pushpals/runtime`.
36
+ - Disable auto-start with `pushpals --no-auto-start`.
37
+
32
38
  ## No npm/Bun install
33
39
 
34
40
  Download native binaries from GitHub Releases:
@@ -2,8 +2,8 @@
2
2
  // @bun
3
3
 
4
4
  // ../../scripts/pushpals-cli.ts
5
- import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
6
- import { dirname, resolve as resolve2 } from "path";
5
+ import { copyFileSync, existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
6
+ import { dirname, join as join2, resolve as resolve2 } from "path";
7
7
  import { createInterface } from "readline";
8
8
 
9
9
  // ../shared/src/config.ts
@@ -743,6 +743,9 @@ var MONITOR_SCAN_PORTS = 32;
743
743
  var HTTP_TIMEOUT_MS = 2500;
744
744
  var LOCALBUDDY_TIMEOUT_MS = 4000;
745
745
  var SSE_RECONNECT_MS = 1500;
746
+ var DEFAULT_RUNTIME_BOOT_TIMEOUT_MS = 90000;
747
+ var DEFAULT_RUNTIME_BOOT_POLL_MS = 1000;
748
+ var DEFAULT_RUNTIME_REPO = "https://github.com/PushPalsDev/pushpals.git";
746
749
  var stateVersion = 1;
747
750
  function printUsage() {
748
751
  console.log("PushPals CLI");
@@ -755,6 +758,9 @@ function printUsage() {
755
758
  console.log(" --local-agent-url <url> Override LocalBuddy URL");
756
759
  console.log(" --session-id <id> Override session ID");
757
760
  console.log(" --hub-url <url> Override monitoring hub URL");
761
+ console.log(" --runtime-root <path> Override embedded runtime directory for auto-start");
762
+ console.log(" --runtime-repo <url> Override embedded runtime git clone URL");
763
+ console.log(" --no-auto-start Disable runtime auto-start when LocalBuddy is down");
758
764
  console.log(" --no-stream Disable live session event stream");
759
765
  console.log(" -h, --help Show this help");
760
766
  console.log("");
@@ -766,10 +772,11 @@ function printUsage() {
766
772
  console.log("");
767
773
  console.log("Notes:");
768
774
  console.log(" - Must be run from inside a git repository.");
769
- console.log(" - LocalBuddy must be running and attached to the same repo root.");
775
+ console.log(" - Auto-start can bootstrap server/localbuddy/remotebuddy/source_control_manager.");
776
+ console.log(" - LocalBuddy must be attached to the same repo root.");
770
777
  }
771
778
  function parseArgs(argv) {
772
- const options = { noStream: false };
779
+ const options = { noAutoStart: false, noStream: false };
773
780
  for (let i = 0;i < argv.length; i++) {
774
781
  const arg = argv[i];
775
782
  if (arg === "-h" || arg === "--help") {
@@ -780,6 +787,10 @@ function parseArgs(argv) {
780
787
  options.noStream = true;
781
788
  continue;
782
789
  }
790
+ if (arg === "--no-auto-start") {
791
+ options.noAutoStart = true;
792
+ continue;
793
+ }
783
794
  if (arg === "--server-url") {
784
795
  options.serverUrl = argv[++i];
785
796
  continue;
@@ -796,6 +807,14 @@ function parseArgs(argv) {
796
807
  options.monitoringHubUrl = argv[++i];
797
808
  continue;
798
809
  }
810
+ if (arg === "--runtime-root") {
811
+ options.runtimeRoot = argv[++i];
812
+ continue;
813
+ }
814
+ if (arg === "--runtime-repo") {
815
+ options.runtimeRepo = argv[++i];
816
+ continue;
817
+ }
799
818
  console.error(`[pushpals] Unknown argument: ${arg}`);
800
819
  printUsage();
801
820
  process.exit(2);
@@ -841,6 +860,114 @@ async function resolveCurrentGitRepoRoot(cwd) {
841
860
  return null;
842
861
  return resolve2(root.stdout);
843
862
  }
863
+ async function runCommand(command, cwd, timeoutMs = 120000) {
864
+ const proc = Bun.spawn(command, {
865
+ cwd,
866
+ stdout: "pipe",
867
+ stderr: "pipe"
868
+ });
869
+ const killTimer = setTimeout(() => {
870
+ try {
871
+ proc.kill();
872
+ } catch {}
873
+ }, timeoutMs);
874
+ try {
875
+ const [stdout, stderr, exitCode] = await Promise.all([
876
+ new Response(proc.stdout).text(),
877
+ new Response(proc.stderr).text(),
878
+ proc.exited
879
+ ]);
880
+ return {
881
+ ok: exitCode === 0,
882
+ stdout: stdout.trim(),
883
+ stderr: stderr.trim(),
884
+ exitCode
885
+ };
886
+ } finally {
887
+ clearTimeout(killTimer);
888
+ }
889
+ }
890
+ function resolveDefaultRuntimeRoot() {
891
+ const home = process.env.USERPROFILE || process.env.HOME || process.cwd();
892
+ return resolve2(home, ".pushpals", "runtime");
893
+ }
894
+ function copyFileIfMissing(fromPath, toPath) {
895
+ if (existsSync2(toPath) || !existsSync2(fromPath))
896
+ return;
897
+ mkdirSync(dirname(toPath), { recursive: true });
898
+ copyFileSync(fromPath, toPath);
899
+ }
900
+ async function ensureRuntimeCheckout(runtimeRoot, runtimeRepo) {
901
+ if (existsSync2(join2(runtimeRoot, ".git")))
902
+ return false;
903
+ mkdirSync(dirname(runtimeRoot), { recursive: true });
904
+ const clone = await runCommand(["git", "clone", "--depth", "1", runtimeRepo, runtimeRoot], dirname(runtimeRoot), 300000);
905
+ if (!clone.ok) {
906
+ throw new Error(`Failed to clone PushPals runtime from ${runtimeRepo} (exit ${clone.exitCode}): ${clone.stderr || clone.stdout || "unknown error"}`);
907
+ }
908
+ return true;
909
+ }
910
+ async function ensureRuntimePrepared(runtimeRoot) {
911
+ copyFileIfMissing(join2(runtimeRoot, ".env.example"), join2(runtimeRoot, ".env"));
912
+ copyFileIfMissing(join2(runtimeRoot, "configs", "local.example.toml"), join2(runtimeRoot, "configs", "local.toml"));
913
+ if (!existsSync2(join2(runtimeRoot, "node_modules"))) {
914
+ const install = await runCommand(["bun", "install"], runtimeRoot, 600000);
915
+ if (!install.ok) {
916
+ throw new Error(`Failed to install runtime dependencies (exit ${install.exitCode}): ${install.stderr || install.stdout || "unknown error"}`);
917
+ }
918
+ }
919
+ const protocolDist = join2(runtimeRoot, "packages", "protocol", "dist", "index.js");
920
+ if (!existsSync2(protocolDist)) {
921
+ const buildProtocol = await runCommand(["bun", "run", "--cwd", "packages/protocol", "build"], runtimeRoot, 300000);
922
+ if (!buildProtocol.ok) {
923
+ throw new Error(`Failed to build protocol package (exit ${buildProtocol.exitCode}): ${buildProtocol.stderr || buildProtocol.stdout || "unknown error"}`);
924
+ }
925
+ }
926
+ }
927
+ function spawnRuntimeService(name, command, cwd, env) {
928
+ const proc = Bun.spawn(command, {
929
+ cwd,
930
+ env,
931
+ stdout: "ignore",
932
+ stderr: "ignore"
933
+ });
934
+ const service = {
935
+ name,
936
+ proc,
937
+ exited: false,
938
+ exitCode: null
939
+ };
940
+ proc.exited.then((code) => {
941
+ service.exited = true;
942
+ service.exitCode = code;
943
+ });
944
+ return service;
945
+ }
946
+ function stopRuntimeServices(services) {
947
+ for (const service of services) {
948
+ try {
949
+ service.proc.kill();
950
+ } catch {}
951
+ }
952
+ }
953
+ async function probeServer(serverUrl) {
954
+ try {
955
+ const response = await fetchWithTimeout(`${serverUrl}/healthz`, {}, HTTP_TIMEOUT_MS);
956
+ return response.ok;
957
+ } catch {
958
+ return false;
959
+ }
960
+ }
961
+ async function probeSourceControlManager(port) {
962
+ if (!Number.isFinite(port) || port <= 0)
963
+ return false;
964
+ try {
965
+ const response = await fetchWithTimeout(`http://127.0.0.1:${Math.floor(port)}/health`, {}, HTTP_TIMEOUT_MS);
966
+ return response.ok;
967
+ } catch {
968
+ return false;
969
+ }
970
+ }
844
971
  async function fetchWithTimeout(url, init = {}, timeoutMs = HTTP_TIMEOUT_MS) {
845
972
  const controller = new AbortController;
846
973
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -868,6 +995,73 @@ function authHeaders(authToken) {
868
995
  async function probeLocalBuddy(localAgentUrl, authToken) {
869
996
  return await fetchJsonWithTimeout(`${localAgentUrl}/healthz`, { headers: authHeaders(authToken) }, LOCALBUDDY_TIMEOUT_MS);
870
997
  }
998
+ async function autoStartRuntimeServices(opts) {
999
+ const runtimeRoot = resolve2(opts.runtimeRoot || process.env.PUSHPALS_RUNTIME_ROOT || resolveDefaultRuntimeRoot());
1000
+ const runtimeRepo = String(opts.runtimeRepo || process.env.PUSHPALS_RUNTIME_REPO || DEFAULT_RUNTIME_REPO).trim();
1001
+ console.log(`[pushpals] LocalBuddy unavailable. Auto-starting runtime for repo: ${opts.repoRoot}`);
1002
+ console.log(`[pushpals] runtimeRoot=${runtimeRoot}`);
1003
+ const cloned = await ensureRuntimeCheckout(runtimeRoot, runtimeRepo);
1004
+ if (cloned) {
1005
+ console.log(`[pushpals] Cloned runtime from ${runtimeRepo}`);
1006
+ }
1007
+ await ensureRuntimePrepared(runtimeRoot);
1008
+ const runtimeEnv = {
1009
+ ...process.env,
1010
+ PUSHPALS_REPO_ROOT_OVERRIDE: opts.repoRoot,
1011
+ PUSHPALS_PROMPTS_ROOT_OVERRIDE: runtimeRoot
1012
+ };
1013
+ const services = [];
1014
+ const serverHealthy = await probeServer(opts.serverUrl);
1015
+ if (!serverHealthy) {
1016
+ console.log("[pushpals] Starting embedded server...");
1017
+ services.push(spawnRuntimeService("server", ["bun", "--cwd", "apps/server", "--env-file", "../../.env", "dev"], runtimeRoot, runtimeEnv));
1018
+ } else {
1019
+ console.log("[pushpals] Server already healthy; skipping embedded server start.");
1020
+ }
1021
+ console.log("[pushpals] Starting embedded LocalBuddy...");
1022
+ services.push(spawnRuntimeService("localbuddy", ["bun", "--cwd", "apps/localbuddy", "--env-file", "../../.env", "dev"], runtimeRoot, runtimeEnv));
1023
+ console.log("[pushpals] Starting embedded RemoteBuddy...");
1024
+ services.push(spawnRuntimeService("remotebuddy", ["bun", "--cwd", "apps/remotebuddy", "--env-file", "../../.env", "start"], runtimeRoot, runtimeEnv));
1025
+ const scmHealthy = await probeSourceControlManager(opts.sourceControlManagerPort);
1026
+ if (!scmHealthy) {
1027
+ console.log("[pushpals] Starting embedded SourceControlManager...");
1028
+ services.push(spawnRuntimeService("source_control_manager", [
1029
+ "bun",
1030
+ "--cwd",
1031
+ "apps/source_control_manager",
1032
+ "--env-file",
1033
+ "../../.env",
1034
+ "start",
1035
+ "--",
1036
+ "--skip-clean-check"
1037
+ ], runtimeRoot, runtimeEnv));
1038
+ } else {
1039
+ console.log("[pushpals] SourceControlManager already healthy; skipping embedded start.");
1040
+ }
1041
+ const deadline = Date.now() + DEFAULT_RUNTIME_BOOT_TIMEOUT_MS;
1042
+ while (Date.now() < deadline) {
1043
+ for (let i = services.length - 1;i >= 0; i--) {
1044
+ const service = services[i];
1045
+ if (service.exited) {
1046
+ if (service.name === "source_control_manager") {
1047
+ console.warn(`[pushpals] Embedded ${service.name} exited during startup (code=${service.exitCode ?? "unknown"}); continuing without SCM.`);
1048
+ services.splice(i, 1);
1049
+ continue;
1050
+ }
1051
+ stopRuntimeServices(services);
1052
+ throw new Error(`Embedded ${service.name} exited during startup (code=${service.exitCode ?? "unknown"})`);
1053
+ }
1054
+ }
1055
+ const health = await probeLocalBuddy(opts.localAgentUrl, opts.authToken);
1056
+ if (health?.ok) {
1057
+ console.log("[pushpals] Embedded runtime is ready.");
1058
+ return services;
1059
+ }
1060
+ await Bun.sleep(DEFAULT_RUNTIME_BOOT_POLL_MS);
1061
+ }
1062
+ stopRuntimeServices(services);
1063
+ throw new Error(`Timed out waiting for LocalBuddy at ${opts.localAgentUrl} after ${DEFAULT_RUNTIME_BOOT_TIMEOUT_MS}ms`);
1064
+ }
871
1065
  function readCliState(pathValue) {
872
1066
  if (!existsSync2(pathValue))
873
1067
  return {};
@@ -1129,18 +1323,48 @@ async function main() {
1129
1323
  const localAgentUrl = normalizeUrl(parsed.localAgentUrl ?? process.env.EXPO_PUBLIC_LOCAL_AGENT_URL, config.client.localAgentUrl);
1130
1324
  const sessionId = String(parsed.sessionId ?? process.env.PUSHPALS_SESSION_ID ?? config.sessionId).trim();
1131
1325
  const authToken = config.authToken;
1132
- const health = await probeLocalBuddy(localAgentUrl, authToken);
1326
+ let autoStartedServices = [];
1327
+ const stopAutoStartedServices = () => {
1328
+ if (autoStartedServices.length === 0)
1329
+ return;
1330
+ stopRuntimeServices(autoStartedServices);
1331
+ autoStartedServices = [];
1332
+ };
1333
+ let health = await probeLocalBuddy(localAgentUrl, authToken);
1334
+ if (!health?.ok && !parsed.noAutoStart) {
1335
+ try {
1336
+ autoStartedServices = await autoStartRuntimeServices({
1337
+ repoRoot,
1338
+ serverUrl,
1339
+ localAgentUrl,
1340
+ sourceControlManagerPort: config.sourceControlManager.port,
1341
+ authToken,
1342
+ runtimeRoot: parsed.runtimeRoot,
1343
+ runtimeRepo: parsed.runtimeRepo
1344
+ });
1345
+ health = await probeLocalBuddy(localAgentUrl, authToken);
1346
+ } catch (err) {
1347
+ console.error(`[pushpals] Auto-start failed: ${String(err)}`);
1348
+ stopAutoStartedServices();
1349
+ }
1350
+ }
1133
1351
  if (!health?.ok) {
1134
1352
  console.error(`[pushpals] LocalBuddy is unavailable at ${localAgentUrl}.`);
1135
- console.error("[pushpals] Start the stack first, then rerun `pushpals`.");
1353
+ if (parsed.noAutoStart) {
1354
+ console.error("[pushpals] Auto-start is disabled (--no-auto-start).");
1355
+ } else {
1356
+ console.error("[pushpals] Auto-start could not bring LocalBuddy online.");
1357
+ }
1136
1358
  process.exit(1);
1137
1359
  }
1138
1360
  const localBuddyRepo = health.repo ? resolve2(health.repo) : "";
1139
1361
  if (!localBuddyRepo) {
1362
+ stopAutoStartedServices();
1140
1363
  console.error("[pushpals] LocalBuddy health response did not include repo path.");
1141
1364
  process.exit(1);
1142
1365
  }
1143
1366
  if (normalizePath(localBuddyRepo) !== normalizePath(repoRoot)) {
1367
+ stopAutoStartedServices();
1144
1368
  console.error("[pushpals] Repo mismatch detected.");
1145
1369
  console.error(`[pushpals] currentRepo=${repoRoot}`);
1146
1370
  console.error(`[pushpals] localBuddyRepo=${localBuddyRepo}`);
@@ -1194,6 +1418,7 @@ ${line}
1194
1418
  streamAbort.abort();
1195
1419
  if (rl)
1196
1420
  rl.close();
1421
+ stopAutoStartedServices();
1197
1422
  };
1198
1423
  process.once("SIGINT", requestStop);
1199
1424
  process.once("SIGTERM", requestStop);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushpalsdev/cli",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "PushPals terminal CLI for LocalBuddy -> RemoteBuddy orchestration",
5
5
  "license": "MIT",
6
6
  "repository": {