@cydm/happy-elves 0.1.0-beta.1 → 0.1.0-beta.3

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.
@@ -8,6 +8,7 @@ import { generateAccountSecret, randomId, } from "../../../packages/shared/dist/
8
8
  const valueFlags = new Set([
9
9
  "agent",
10
10
  "code",
11
+ "controller",
11
12
  "cwd",
12
13
  "device-id",
13
14
  "device-name",
@@ -49,11 +50,19 @@ const configDir = process.env.HAPPY_ELVES_HOME
49
50
  ? path.resolve(process.env.HAPPY_ELVES_HOME)
50
51
  : path.join(os.homedir(), ".happy-elves");
51
52
  const configPath = path.join(configDir, "controller.json");
53
+ const defaultHostedRelayUrl = process.env.HAPPY_ELVES_DEFAULT_RELAY_URL ?? "https://relay.happyelves.ai";
54
+ const defaultHostedControllerUrl = process.env.HAPPY_ELVES_DEFAULT_CONTROLLER_URL ?? "https://happyelves.ai";
55
+ const startDaemonTimeoutMs = 10_000;
52
56
  function usage() {
53
57
  writeError("USAGE", `happy-elves
54
58
 
59
+ Start:
60
+ start [--relay <url>] [--controller <url>] [--cwd <path>] [--no-open] [--json]
61
+ doctor [--relay <url>] [--json]
62
+
55
63
  Config:
56
64
  account create --relay <url> [--device-id <id>] [--device-name <name>] --json
65
+ account reset --json
57
66
  config init --relay <url> --token <controller-token> --secret <account-secret> --json
58
67
  config show --json
59
68
  remote pairing new --json
@@ -609,6 +618,16 @@ function readProcessCommand(pid) {
609
618
  return undefined;
610
619
  }
611
620
  }
621
+ function readProcessStartedAtMs(pid) {
622
+ try {
623
+ const text = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf8" }).trim();
624
+ const startedAt = Date.parse(text);
625
+ return Number.isFinite(startedAt) ? startedAt : undefined;
626
+ }
627
+ catch {
628
+ return undefined;
629
+ }
630
+ }
612
631
  function isManagedDaemonPid(pid) {
613
632
  const command = readProcessCommand(pid);
614
633
  const scriptPath = daemonCliPath();
@@ -634,9 +653,23 @@ async function localDaemonStatus() {
634
653
  const pid = await readDaemonPid();
635
654
  return { pidPath: daemonPidPath, pid, running: pid !== undefined && isPidAlive(pid) && isManagedDaemonPid(pid) };
636
655
  }
637
- function spawnDaemonStart(args) {
656
+ async function daemonBinaryUpdatedAfterStart(status) {
657
+ if (!status.running || !status.pid)
658
+ return false;
659
+ const startedAt = readProcessStartedAtMs(status.pid);
660
+ if (!startedAt)
661
+ return false;
662
+ try {
663
+ const script = await fs.stat(daemonCliPath());
664
+ return script.mtimeMs > startedAt + 1000;
665
+ }
666
+ catch {
667
+ return false;
668
+ }
669
+ }
670
+ function spawnDaemonStart(args, cwd = process.cwd()) {
638
671
  const child = spawn(process.execPath, [daemonCliPath(), "start", ...args], {
639
- cwd: path.resolve(import.meta.dirname, "../..", ".."),
672
+ cwd,
640
673
  env: { ...process.env, HAPPY_ELVES_HOME: configDir },
641
674
  detached: true,
642
675
  stdio: "ignore",
@@ -728,10 +761,387 @@ function parsePairingStartResponse(value) {
728
761
  }
729
762
  return { accountId, controllerToken, pairingCode, expiresAt };
730
763
  }
764
+ async function readExistingControllerConfig() {
765
+ try {
766
+ return await readConfig({});
767
+ }
768
+ catch (error) {
769
+ if (error instanceof CliError && error.code === "CONFIG_MISSING")
770
+ return undefined;
771
+ throw error;
772
+ }
773
+ }
774
+ async function readExistingDaemonConfig() {
775
+ try {
776
+ return await readDaemonConfig();
777
+ }
778
+ catch (error) {
779
+ if (error instanceof CliError && error.code === "CONFIG_MISSING")
780
+ return undefined;
781
+ throw error;
782
+ }
783
+ }
784
+ function cliErrorFromFetch(error, code, message) {
785
+ if (error instanceof CliError)
786
+ return error;
787
+ const suffix = error instanceof Error ? `: ${error.message}` : "";
788
+ return new CliError(`${message}${suffix}`, code);
789
+ }
790
+ function isAuthTokenInvalid(error) {
791
+ if (!error || typeof error !== "object")
792
+ return false;
793
+ const record = error;
794
+ return record.code === "AUTH_TOKEN_INVALID" || record.message === "Invalid token";
795
+ }
796
+ function backupTimestamp() {
797
+ return new Date().toISOString().replace(/[:.]/g, "-");
798
+ }
799
+ async function backupConfigFile(filePath, reason) {
800
+ const backupPath = `${filePath}.bak.${backupTimestamp()}`;
801
+ try {
802
+ await fs.rename(filePath, backupPath);
803
+ return { path: filePath, backupPath, reason };
804
+ }
805
+ catch (error) {
806
+ if (error.code === "ENOENT")
807
+ return undefined;
808
+ throw new CliError(`Could not back up ${filePath}: ${error instanceof Error ? error.message : String(error)}`, "CONFIG_INVALID");
809
+ }
810
+ }
811
+ async function createControllerConfig(params) {
812
+ const accountSecret = generateAccountSecret();
813
+ let response;
814
+ try {
815
+ response = await fetch(`${params.relayUrl}/api/pairing/start`, {
816
+ method: "POST",
817
+ headers: { "content-type": "application/json" },
818
+ body: JSON.stringify({ deviceId: randomId("dev"), deviceName: params.deviceName }),
819
+ });
820
+ }
821
+ catch (error) {
822
+ throw cliErrorFromFetch(error, "RELAY_UNREACHABLE", `Could not reach ${params.relayUrl}`);
823
+ }
824
+ if (!response.ok) {
825
+ await throwRelayHttpError(response, "Account creation failed");
826
+ }
827
+ const paired = parsePairingStartResponse(await readRelayJson(response, "Account creation response is not valid JSON"));
828
+ const config = {
829
+ relayUrl: params.relayUrl,
830
+ controllerToken: paired.controllerToken,
831
+ accountSecret,
832
+ };
833
+ await writeConfig(config);
834
+ return { config, accountId: paired.accountId };
835
+ }
836
+ async function ensureControllerConfig(params) {
837
+ let existing;
838
+ let backup;
839
+ try {
840
+ existing = await readExistingControllerConfig();
841
+ }
842
+ catch (error) {
843
+ if (!(error instanceof CliError) || error.code !== "CONFIG_INVALID")
844
+ throw error;
845
+ backup = await backupConfigFile(configPath, "invalid-controller-config");
846
+ }
847
+ if (existing) {
848
+ if (params.explicitRelay && existing.relayUrl !== params.relayUrl) {
849
+ backup = await backupConfigFile(configPath, "controller-relay-changed");
850
+ }
851
+ else {
852
+ try {
853
+ await new ControllerClient(existing).snapshot();
854
+ return { config: existing, created: false, reconfigured: false };
855
+ }
856
+ catch (error) {
857
+ if (!isAuthTokenInvalid(error))
858
+ throw error;
859
+ backup = await backupConfigFile(configPath, "controller-token-invalid");
860
+ }
861
+ }
862
+ }
863
+ const created = await createControllerConfig({ relayUrl: params.relayUrl, deviceName: params.deviceName });
864
+ return { config: created.config, created: true, accountId: created.accountId, backup, reconfigured: Boolean(backup) };
865
+ }
866
+ async function claimMachinePairing(params) {
867
+ let response;
868
+ try {
869
+ response = await fetch(`${params.relayUrl}/api/pairing/claim`, {
870
+ method: "POST",
871
+ headers: { "content-type": "application/json" },
872
+ body: JSON.stringify({ code: params.pairingCode, machineId: params.machineId }),
873
+ });
874
+ }
875
+ catch (error) {
876
+ throw cliErrorFromFetch(error, "RELAY_UNREACHABLE", `Could not reach ${params.relayUrl}`);
877
+ }
878
+ if (!response.ok) {
879
+ await throwRelayHttpError(response, "Machine enrollment failed");
880
+ }
881
+ return parsePairingClaimResponse(await readRelayJson(response, "Machine enrollment response is not valid JSON"));
882
+ }
883
+ async function createDaemonConfig(config, machineName) {
884
+ const pairing = await new ControllerClient(config).createPairingCode();
885
+ const claimed = await claimMachinePairing({
886
+ relayUrl: config.relayUrl,
887
+ pairingCode: pairing.pairingCode,
888
+ machineId: randomId("mach"),
889
+ });
890
+ const daemonConfig = {
891
+ relayUrl: config.relayUrl,
892
+ accountId: claimed.accountId,
893
+ machineId: claimed.machineId,
894
+ machineName,
895
+ machineToken: claimed.machineToken,
896
+ accountSecret: config.accountSecret,
897
+ };
898
+ await writeDaemonConfig(daemonConfig);
899
+ return daemonConfig;
900
+ }
901
+ async function ensureDaemonPaired(config, machineName) {
902
+ const daemonConfigPath = path.join(configDir, "daemon.json");
903
+ let existing;
904
+ let backup;
905
+ try {
906
+ existing = await readExistingDaemonConfig();
907
+ }
908
+ catch (error) {
909
+ if (!(error instanceof CliError) || error.code !== "CONFIG_INVALID")
910
+ throw error;
911
+ backup = await backupConfigFile(daemonConfigPath, "invalid-daemon-config");
912
+ }
913
+ if (existing) {
914
+ if (existing.relayUrl !== config.relayUrl || existing.accountSecret !== config.accountSecret) {
915
+ backup = await backupConfigFile(daemonConfigPath, "daemon-account-changed");
916
+ }
917
+ else {
918
+ return { config: existing, created: false, reconfigured: false };
919
+ }
920
+ }
921
+ return { config: await createDaemonConfig(config, machineName), created: true, backup, reconfigured: Boolean(backup) };
922
+ }
923
+ async function ensureDaemonRunning(relayUrl, cwd = process.cwd()) {
924
+ const current = await localDaemonStatus();
925
+ if (current.running) {
926
+ if (await daemonBinaryUpdatedAfterStart(current)) {
927
+ return await restartDaemonForNewConfig(relayUrl, cwd);
928
+ }
929
+ return { local: current, started: false };
930
+ }
931
+ await readDaemonConfig();
932
+ const startedPid = spawnDaemonStart(["--relay", relayUrl], cwd);
933
+ if (!startedPid)
934
+ throw new CliError("Daemon failed to start.", "DAEMON_START_FAILED");
935
+ const deadline = Date.now() + startDaemonTimeoutMs;
936
+ while (Date.now() < deadline) {
937
+ const local = await localDaemonStatus();
938
+ if (local.running)
939
+ return { local, started: true, startedPid };
940
+ await new Promise((resolve) => setTimeout(resolve, 250));
941
+ }
942
+ throw new CliError("Daemon did not become ready in time. Run happy-elves daemon doctor and happy-elves daemon logs.", "DAEMON_START_FAILED");
943
+ }
944
+ async function restartDaemonForNewConfig(relayUrl, cwd = process.cwd()) {
945
+ const stopped = await stopLocalDaemon();
946
+ if (stopped.running) {
947
+ throw new CliError("Daemon did not stop in time after local config changed. Run happy-elves daemon stop and retry happy-elves start.", "DAEMON_START_FAILED");
948
+ }
949
+ const started = await ensureDaemonRunning(relayUrl, cwd);
950
+ return { stopped, ...started };
951
+ }
952
+ async function waitForMachineProjection(client, machineId, timeoutMs = 5000) {
953
+ const deadline = Date.now() + timeoutMs;
954
+ let lastSeen;
955
+ while (Date.now() <= deadline) {
956
+ const snapshot = await client.snapshot();
957
+ lastSeen = snapshot.machines.find((machine) => machine.id === machineId);
958
+ if (lastSeen?.online)
959
+ return lastSeen;
960
+ await new Promise((resolve) => setTimeout(resolve, 300));
961
+ }
962
+ return lastSeen;
963
+ }
964
+ function controllerBaseUrl(flags) {
965
+ const value = typeof flags.controller === "string" ? flags.controller : defaultHostedControllerUrl;
966
+ return value.replace(/\/+$/, "");
967
+ }
968
+ function startRelayUrl(flags, existing) {
969
+ if (typeof flags.relay === "string") {
970
+ return { relayUrl: requireRelayUrl(flags), explicit: true };
971
+ }
972
+ return { relayUrl: existing?.relayUrl ?? normalizeRelayUrl(defaultHostedRelayUrl), explicit: false };
973
+ }
974
+ function writeHumanStartOutput(data) {
975
+ console.log("Happy Elves is running.");
976
+ console.log("");
977
+ console.log("Workspace");
978
+ console.log(data.workspace);
979
+ console.log("");
980
+ console.log(data.opened ? "Opened controller" : "Controller");
981
+ console.log(data.controllerUrl);
982
+ if (data.openError) {
983
+ console.log(`Could not open automatically: ${data.openError}`);
984
+ }
985
+ console.log("");
986
+ console.log("Phone");
987
+ console.log("Open the same link on your phone.");
988
+ console.log("");
989
+ console.log("Machine");
990
+ console.log(`${data.machine.online ? "online" : "starting"}, ${data.machine.name}`);
991
+ console.log("");
992
+ console.log("Daemon");
993
+ console.log(`${data.daemon.pid ? `pid ${data.daemon.pid}` : "running"} via ${data.relayUrl}`);
994
+ }
995
+ function openUrl(url) {
996
+ try {
997
+ if (process.platform === "darwin") {
998
+ execFileSync("open", [url], { stdio: "ignore" });
999
+ }
1000
+ else if (process.platform === "win32") {
1001
+ execFileSync("cmd", ["/c", "start", "", url], { stdio: "ignore" });
1002
+ }
1003
+ else {
1004
+ execFileSync("xdg-open", [url], { stdio: "ignore" });
1005
+ }
1006
+ return { opened: true };
1007
+ }
1008
+ catch (error) {
1009
+ return { opened: false, error: error instanceof Error ? error.message : String(error) };
1010
+ }
1011
+ }
1012
+ async function startBootstrap(flags) {
1013
+ const existingController = await readExistingControllerConfig().catch((error) => {
1014
+ if (error instanceof CliError && error.code === "CONFIG_INVALID")
1015
+ return undefined;
1016
+ throw error;
1017
+ });
1018
+ const relay = startRelayUrl(flags, existingController);
1019
+ const cwd = typeof flags.cwd === "string" ? path.resolve(flags.cwd) : process.cwd();
1020
+ const machineName = typeof flags.name === "string" ? flags.name : os.hostname();
1021
+ const deviceName = typeof flags["device-name"] === "string" ? flags["device-name"] : os.hostname();
1022
+ const controllerState = await ensureControllerConfig({
1023
+ relayUrl: relay.relayUrl,
1024
+ explicitRelay: relay.explicit,
1025
+ deviceName,
1026
+ });
1027
+ const daemonBefore = await localDaemonStatus();
1028
+ const daemonState = await ensureDaemonPaired(controllerState.config, machineName);
1029
+ const daemon = daemonBefore.running && daemonState.reconfigured
1030
+ ? await restartDaemonForNewConfig(controllerState.config.relayUrl, cwd)
1031
+ : await ensureDaemonRunning(controllerState.config.relayUrl, cwd);
1032
+ const client = new ControllerClient(controllerState.config);
1033
+ const machine = await waitForMachineProjection(client, daemonState.config.machineId);
1034
+ if (!machine?.online) {
1035
+ throw new CliError("Daemon started, but this machine did not appear online in the relay snapshot. Run happy-elves doctor and happy-elves daemon logs.", "DAEMON_ONLINE_TIMEOUT");
1036
+ }
1037
+ const invite = await client.createControllerInvite({
1038
+ deviceName: "Web controller",
1039
+ ttlMs: 10 * 60_000,
1040
+ joinUrlBase: controllerBaseUrl(flags),
1041
+ relayUrl: controllerState.config.relayUrl,
1042
+ }).catch((error) => {
1043
+ throw cliErrorFromFetch(error, "INVITE_CREATE_FAILED", "Could not create controller invite");
1044
+ });
1045
+ const data = {
1046
+ relayUrl: controllerState.config.relayUrl,
1047
+ controllerUrl: invite.joinUrl,
1048
+ workspace: cwd,
1049
+ config: {
1050
+ controllerPath: configPath,
1051
+ daemonPath: path.join(configDir, "daemon.json"),
1052
+ controllerCreated: controllerState.created,
1053
+ controllerReconfigured: controllerState.reconfigured,
1054
+ controllerBackupPath: controllerState.backup?.backupPath,
1055
+ daemonPaired: daemonState.created,
1056
+ daemonReconfigured: daemonState.reconfigured,
1057
+ daemonBackupPath: daemonState.backup?.backupPath,
1058
+ },
1059
+ machine: {
1060
+ id: daemonState.config.machineId,
1061
+ name: machine.name ?? daemonState.config.machineName,
1062
+ online: machine.online,
1063
+ lastSeen: machine?.lastSeen,
1064
+ },
1065
+ daemon: {
1066
+ status: daemon.local.running ? "online" : "starting",
1067
+ pid: daemon.local.pid ?? daemon.startedPid,
1068
+ pidPath: daemon.local.pidPath,
1069
+ started: daemon.started,
1070
+ alreadyRunning: daemonBefore.running,
1071
+ restarted: daemonBefore.running && daemonState.reconfigured,
1072
+ },
1073
+ invite: {
1074
+ id: invite.inviteCode,
1075
+ expiresAt: invite.expiresAt,
1076
+ },
1077
+ };
1078
+ if (flags.json === true) {
1079
+ ok("start", data);
1080
+ return;
1081
+ }
1082
+ const openResult = flags["no-open"] === true ? { opened: false } : openUrl(invite.joinUrl);
1083
+ writeHumanStartOutput({
1084
+ workspace: cwd,
1085
+ controllerUrl: invite.joinUrl,
1086
+ relayUrl: controllerState.config.relayUrl,
1087
+ opened: openResult.opened,
1088
+ openError: openResult.error,
1089
+ machine: {
1090
+ name: data.machine.name,
1091
+ online: data.machine.online,
1092
+ },
1093
+ daemon: data.daemon,
1094
+ });
1095
+ }
1096
+ async function topLevelDoctor(flags) {
1097
+ const checks = [];
1098
+ const existingController = await readExistingControllerConfig().catch((error) => {
1099
+ checks.push({ name: "controller-config", ok: false, message: error instanceof Error ? error.message : String(error) });
1100
+ return undefined;
1101
+ });
1102
+ if (existingController) {
1103
+ checks.push({ name: "controller-config", ok: true, message: configPath, details: redactedControllerConfig(existingController) });
1104
+ }
1105
+ const relay = startRelayUrl(flags, existingController);
1106
+ try {
1107
+ const health = await relayHealth(relay.relayUrl);
1108
+ checks.push({
1109
+ name: "relay",
1110
+ ok: health.ok,
1111
+ message: health.ok ? "reachable" : `HTTP ${health.status}`,
1112
+ details: { relayUrl: relay.relayUrl, body: health.body },
1113
+ });
1114
+ }
1115
+ catch (error) {
1116
+ checks.push({ name: "relay", ok: false, message: error instanceof Error ? error.message : String(error), details: { relayUrl: relay.relayUrl } });
1117
+ }
1118
+ const existingDaemon = await readExistingDaemonConfig().catch((error) => {
1119
+ checks.push({ name: "daemon-config", ok: false, message: error instanceof Error ? error.message : String(error) });
1120
+ return undefined;
1121
+ });
1122
+ if (existingDaemon) {
1123
+ checks.push({ name: "daemon-config", ok: true, message: path.join(configDir, "daemon.json"), details: redactedDaemonConfig(existingDaemon) });
1124
+ }
1125
+ const local = await localDaemonStatus();
1126
+ checks.push({ name: "daemon-process", ok: local.running, message: local.running ? "running" : "not running", details: local });
1127
+ ok("doctor", { ok: checks.every((check) => check.ok), checks });
1128
+ }
731
1129
  async function main() {
732
- const [domain, action, ...rest] = process.argv.slice(2);
1130
+ const argv = process.argv.slice(2);
1131
+ const [domain] = argv;
733
1132
  if (!domain)
734
1133
  usage();
1134
+ if (domain === "start") {
1135
+ const { flags } = parseFlags(argv.slice(1));
1136
+ await startBootstrap(flags);
1137
+ return;
1138
+ }
1139
+ if (domain === "doctor") {
1140
+ const { flags } = parseFlags(argv.slice(1));
1141
+ await topLevelDoctor(flags);
1142
+ return;
1143
+ }
1144
+ const [, action, ...rest] = argv;
735
1145
  const { positional, flags } = parseFlags(rest);
736
1146
  if (domain === "account" && action === "create") {
737
1147
  const relayUrl = requireRelayUrl(flags);
@@ -764,6 +1174,18 @@ async function main() {
764
1174
  });
765
1175
  return;
766
1176
  }
1177
+ if (domain === "account" && action === "reset") {
1178
+ const stop = await stopLocalDaemon().catch((error) => ({
1179
+ error: error instanceof Error ? error.message : String(error),
1180
+ }));
1181
+ await fs.rm(configPath, { force: true });
1182
+ await fs.rm(path.join(configDir, "daemon.json"), { force: true });
1183
+ ok("account.reset", {
1184
+ removed: [configPath, path.join(configDir, "daemon.json")],
1185
+ stop,
1186
+ });
1187
+ return;
1188
+ }
767
1189
  if (domain === "config" && action === "init") {
768
1190
  const config = {
769
1191
  relayUrl: requireRelayUrl(flags),
@@ -1435,6 +1857,32 @@ async function main() {
1435
1857
  }
1436
1858
  main().catch((error) => {
1437
1859
  const enriched = error;
1860
+ if (process.argv[2] === "start" && !process.argv.includes("--json")) {
1861
+ const code = enriched.code ?? "COMMAND_FAILED";
1862
+ const message = error instanceof Error ? error.message : String(error);
1863
+ console.error(message);
1864
+ console.error("");
1865
+ if (code === "RELAY_UNREACHABLE") {
1866
+ console.error("Try:");
1867
+ console.error(" happy-elves start --relay <your-relay-url>");
1868
+ console.error(" happy-elves doctor");
1869
+ }
1870
+ else if (code === "DAEMON_START_FAILED" || code === "DAEMON_ONLINE_TIMEOUT") {
1871
+ console.error("Run:");
1872
+ console.error(" happy-elves doctor");
1873
+ console.error(" happy-elves daemon logs");
1874
+ }
1875
+ else if (code === "CONFIG_INVALID") {
1876
+ console.error("Run:");
1877
+ console.error(" happy-elves account reset");
1878
+ console.error(" happy-elves start");
1879
+ }
1880
+ else {
1881
+ console.error("Run:");
1882
+ console.error(" happy-elves doctor");
1883
+ }
1884
+ process.exit(1);
1885
+ }
1438
1886
  writeError(enriched.code ?? "COMMAND_FAILED", error instanceof Error ? error.message : String(error), { requestId: enriched.requestId }, enriched.capability);
1439
1887
  process.exit(1);
1440
1888
  });