@cydm/happy-elves 0.1.0-beta.1 → 0.1.0-beta.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/apps/cli/dist/index.js +437 -3
- package/apps/cli/dist/index.js.map +1 -1
- package/apps/daemon/dist/cli.js +62 -0
- package/apps/daemon/dist/cli.js.map +1 -1
- package/apps/relay/dist/index.js +52 -0
- package/apps/relay/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/packages/client/dist/index.d.ts +6 -1
- package/packages/client/dist/index.d.ts.map +1 -1
- package/packages/client/dist/index.js +50 -0
- package/packages/client/dist/index.js.map +1 -1
- package/packages/shared/dist/protocol.d.ts +30 -2
- package/packages/shared/dist/protocol.d.ts.map +1 -1
- package/packages/shared/dist/protocol.js +31 -0
- package/packages/shared/dist/protocol.js.map +1 -1
package/apps/cli/dist/index.js
CHANGED
|
@@ -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
|
|
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
|
|
672
|
+
cwd,
|
|
640
673
|
env: { ...process.env, HAPPY_ELVES_HOME: configDir },
|
|
641
674
|
detached: true,
|
|
642
675
|
stdio: "ignore",
|
|
@@ -728,10 +761,373 @@ 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 backupTimestamp() {
|
|
791
|
+
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
792
|
+
}
|
|
793
|
+
async function backupConfigFile(filePath, reason) {
|
|
794
|
+
const backupPath = `${filePath}.bak.${backupTimestamp()}`;
|
|
795
|
+
try {
|
|
796
|
+
await fs.rename(filePath, backupPath);
|
|
797
|
+
return { path: filePath, backupPath, reason };
|
|
798
|
+
}
|
|
799
|
+
catch (error) {
|
|
800
|
+
if (error.code === "ENOENT")
|
|
801
|
+
return undefined;
|
|
802
|
+
throw new CliError(`Could not back up ${filePath}: ${error instanceof Error ? error.message : String(error)}`, "CONFIG_INVALID");
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
async function createControllerConfig(params) {
|
|
806
|
+
const accountSecret = generateAccountSecret();
|
|
807
|
+
let response;
|
|
808
|
+
try {
|
|
809
|
+
response = await fetch(`${params.relayUrl}/api/pairing/start`, {
|
|
810
|
+
method: "POST",
|
|
811
|
+
headers: { "content-type": "application/json" },
|
|
812
|
+
body: JSON.stringify({ deviceId: randomId("dev"), deviceName: params.deviceName }),
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
catch (error) {
|
|
816
|
+
throw cliErrorFromFetch(error, "RELAY_UNREACHABLE", `Could not reach ${params.relayUrl}`);
|
|
817
|
+
}
|
|
818
|
+
if (!response.ok) {
|
|
819
|
+
await throwRelayHttpError(response, "Account creation failed");
|
|
820
|
+
}
|
|
821
|
+
const paired = parsePairingStartResponse(await readRelayJson(response, "Account creation response is not valid JSON"));
|
|
822
|
+
const config = {
|
|
823
|
+
relayUrl: params.relayUrl,
|
|
824
|
+
controllerToken: paired.controllerToken,
|
|
825
|
+
accountSecret,
|
|
826
|
+
};
|
|
827
|
+
await writeConfig(config);
|
|
828
|
+
return { config, accountId: paired.accountId };
|
|
829
|
+
}
|
|
830
|
+
async function ensureControllerConfig(params) {
|
|
831
|
+
let existing;
|
|
832
|
+
let backup;
|
|
833
|
+
try {
|
|
834
|
+
existing = await readExistingControllerConfig();
|
|
835
|
+
}
|
|
836
|
+
catch (error) {
|
|
837
|
+
if (!(error instanceof CliError) || error.code !== "CONFIG_INVALID")
|
|
838
|
+
throw error;
|
|
839
|
+
backup = await backupConfigFile(configPath, "invalid-controller-config");
|
|
840
|
+
}
|
|
841
|
+
if (existing) {
|
|
842
|
+
if (params.explicitRelay && existing.relayUrl !== params.relayUrl) {
|
|
843
|
+
backup = await backupConfigFile(configPath, "controller-relay-changed");
|
|
844
|
+
}
|
|
845
|
+
else {
|
|
846
|
+
return { config: existing, created: false, reconfigured: false };
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
const created = await createControllerConfig({ relayUrl: params.relayUrl, deviceName: params.deviceName });
|
|
850
|
+
return { config: created.config, created: true, accountId: created.accountId, backup, reconfigured: Boolean(backup) };
|
|
851
|
+
}
|
|
852
|
+
async function claimMachinePairing(params) {
|
|
853
|
+
let response;
|
|
854
|
+
try {
|
|
855
|
+
response = await fetch(`${params.relayUrl}/api/pairing/claim`, {
|
|
856
|
+
method: "POST",
|
|
857
|
+
headers: { "content-type": "application/json" },
|
|
858
|
+
body: JSON.stringify({ code: params.pairingCode, machineId: params.machineId }),
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
catch (error) {
|
|
862
|
+
throw cliErrorFromFetch(error, "RELAY_UNREACHABLE", `Could not reach ${params.relayUrl}`);
|
|
863
|
+
}
|
|
864
|
+
if (!response.ok) {
|
|
865
|
+
await throwRelayHttpError(response, "Machine enrollment failed");
|
|
866
|
+
}
|
|
867
|
+
return parsePairingClaimResponse(await readRelayJson(response, "Machine enrollment response is not valid JSON"));
|
|
868
|
+
}
|
|
869
|
+
async function createDaemonConfig(config, machineName) {
|
|
870
|
+
const pairing = await new ControllerClient(config).createPairingCode();
|
|
871
|
+
const claimed = await claimMachinePairing({
|
|
872
|
+
relayUrl: config.relayUrl,
|
|
873
|
+
pairingCode: pairing.pairingCode,
|
|
874
|
+
machineId: randomId("mach"),
|
|
875
|
+
});
|
|
876
|
+
const daemonConfig = {
|
|
877
|
+
relayUrl: config.relayUrl,
|
|
878
|
+
accountId: claimed.accountId,
|
|
879
|
+
machineId: claimed.machineId,
|
|
880
|
+
machineName,
|
|
881
|
+
machineToken: claimed.machineToken,
|
|
882
|
+
accountSecret: config.accountSecret,
|
|
883
|
+
};
|
|
884
|
+
await writeDaemonConfig(daemonConfig);
|
|
885
|
+
return daemonConfig;
|
|
886
|
+
}
|
|
887
|
+
async function ensureDaemonPaired(config, machineName) {
|
|
888
|
+
const daemonConfigPath = path.join(configDir, "daemon.json");
|
|
889
|
+
let existing;
|
|
890
|
+
let backup;
|
|
891
|
+
try {
|
|
892
|
+
existing = await readExistingDaemonConfig();
|
|
893
|
+
}
|
|
894
|
+
catch (error) {
|
|
895
|
+
if (!(error instanceof CliError) || error.code !== "CONFIG_INVALID")
|
|
896
|
+
throw error;
|
|
897
|
+
backup = await backupConfigFile(daemonConfigPath, "invalid-daemon-config");
|
|
898
|
+
}
|
|
899
|
+
if (existing) {
|
|
900
|
+
if (existing.relayUrl !== config.relayUrl || existing.accountSecret !== config.accountSecret) {
|
|
901
|
+
backup = await backupConfigFile(daemonConfigPath, "daemon-account-changed");
|
|
902
|
+
}
|
|
903
|
+
else {
|
|
904
|
+
return { config: existing, created: false, reconfigured: false };
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
return { config: await createDaemonConfig(config, machineName), created: true, backup, reconfigured: Boolean(backup) };
|
|
908
|
+
}
|
|
909
|
+
async function ensureDaemonRunning(relayUrl, cwd = process.cwd()) {
|
|
910
|
+
const current = await localDaemonStatus();
|
|
911
|
+
if (current.running) {
|
|
912
|
+
if (await daemonBinaryUpdatedAfterStart(current)) {
|
|
913
|
+
return await restartDaemonForNewConfig(relayUrl, cwd);
|
|
914
|
+
}
|
|
915
|
+
return { local: current, started: false };
|
|
916
|
+
}
|
|
917
|
+
await readDaemonConfig();
|
|
918
|
+
const startedPid = spawnDaemonStart(["--relay", relayUrl], cwd);
|
|
919
|
+
if (!startedPid)
|
|
920
|
+
throw new CliError("Daemon failed to start.", "DAEMON_START_FAILED");
|
|
921
|
+
const deadline = Date.now() + startDaemonTimeoutMs;
|
|
922
|
+
while (Date.now() < deadline) {
|
|
923
|
+
const local = await localDaemonStatus();
|
|
924
|
+
if (local.running)
|
|
925
|
+
return { local, started: true, startedPid };
|
|
926
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
927
|
+
}
|
|
928
|
+
throw new CliError("Daemon did not become ready in time. Run happy-elves daemon doctor and happy-elves daemon logs.", "DAEMON_START_FAILED");
|
|
929
|
+
}
|
|
930
|
+
async function restartDaemonForNewConfig(relayUrl, cwd = process.cwd()) {
|
|
931
|
+
const stopped = await stopLocalDaemon();
|
|
932
|
+
if (stopped.running) {
|
|
933
|
+
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");
|
|
934
|
+
}
|
|
935
|
+
const started = await ensureDaemonRunning(relayUrl, cwd);
|
|
936
|
+
return { stopped, ...started };
|
|
937
|
+
}
|
|
938
|
+
async function waitForMachineProjection(client, machineId, timeoutMs = 5000) {
|
|
939
|
+
const deadline = Date.now() + timeoutMs;
|
|
940
|
+
let lastSeen;
|
|
941
|
+
while (Date.now() <= deadline) {
|
|
942
|
+
const snapshot = await client.snapshot();
|
|
943
|
+
lastSeen = snapshot.machines.find((machine) => machine.id === machineId);
|
|
944
|
+
if (lastSeen?.online)
|
|
945
|
+
return lastSeen;
|
|
946
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
947
|
+
}
|
|
948
|
+
return lastSeen;
|
|
949
|
+
}
|
|
950
|
+
function controllerBaseUrl(flags) {
|
|
951
|
+
const value = typeof flags.controller === "string" ? flags.controller : defaultHostedControllerUrl;
|
|
952
|
+
return value.replace(/\/+$/, "");
|
|
953
|
+
}
|
|
954
|
+
function startRelayUrl(flags, existing) {
|
|
955
|
+
if (typeof flags.relay === "string") {
|
|
956
|
+
return { relayUrl: requireRelayUrl(flags), explicit: true };
|
|
957
|
+
}
|
|
958
|
+
return { relayUrl: existing?.relayUrl ?? normalizeRelayUrl(defaultHostedRelayUrl), explicit: false };
|
|
959
|
+
}
|
|
960
|
+
function writeHumanStartOutput(data) {
|
|
961
|
+
console.log("Happy Elves is running.");
|
|
962
|
+
console.log("");
|
|
963
|
+
console.log("Workspace");
|
|
964
|
+
console.log(data.workspace);
|
|
965
|
+
console.log("");
|
|
966
|
+
console.log(data.opened ? "Opened controller" : "Controller");
|
|
967
|
+
console.log(data.controllerUrl);
|
|
968
|
+
if (data.openError) {
|
|
969
|
+
console.log(`Could not open automatically: ${data.openError}`);
|
|
970
|
+
}
|
|
971
|
+
console.log("");
|
|
972
|
+
console.log("Phone");
|
|
973
|
+
console.log("Open the same link on your phone.");
|
|
974
|
+
console.log("");
|
|
975
|
+
console.log("Machine");
|
|
976
|
+
console.log(`${data.machine.online ? "online" : "starting"}, ${data.machine.name}`);
|
|
977
|
+
console.log("");
|
|
978
|
+
console.log("Daemon");
|
|
979
|
+
console.log(`${data.daemon.pid ? `pid ${data.daemon.pid}` : "running"} via ${data.relayUrl}`);
|
|
980
|
+
}
|
|
981
|
+
function openUrl(url) {
|
|
982
|
+
try {
|
|
983
|
+
if (process.platform === "darwin") {
|
|
984
|
+
execFileSync("open", [url], { stdio: "ignore" });
|
|
985
|
+
}
|
|
986
|
+
else if (process.platform === "win32") {
|
|
987
|
+
execFileSync("cmd", ["/c", "start", "", url], { stdio: "ignore" });
|
|
988
|
+
}
|
|
989
|
+
else {
|
|
990
|
+
execFileSync("xdg-open", [url], { stdio: "ignore" });
|
|
991
|
+
}
|
|
992
|
+
return { opened: true };
|
|
993
|
+
}
|
|
994
|
+
catch (error) {
|
|
995
|
+
return { opened: false, error: error instanceof Error ? error.message : String(error) };
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
async function startBootstrap(flags) {
|
|
999
|
+
const existingController = await readExistingControllerConfig().catch((error) => {
|
|
1000
|
+
if (error instanceof CliError && error.code === "CONFIG_INVALID")
|
|
1001
|
+
return undefined;
|
|
1002
|
+
throw error;
|
|
1003
|
+
});
|
|
1004
|
+
const relay = startRelayUrl(flags, existingController);
|
|
1005
|
+
const cwd = typeof flags.cwd === "string" ? path.resolve(flags.cwd) : process.cwd();
|
|
1006
|
+
const machineName = typeof flags.name === "string" ? flags.name : os.hostname();
|
|
1007
|
+
const deviceName = typeof flags["device-name"] === "string" ? flags["device-name"] : os.hostname();
|
|
1008
|
+
const controllerState = await ensureControllerConfig({
|
|
1009
|
+
relayUrl: relay.relayUrl,
|
|
1010
|
+
explicitRelay: relay.explicit,
|
|
1011
|
+
deviceName,
|
|
1012
|
+
});
|
|
1013
|
+
const daemonBefore = await localDaemonStatus();
|
|
1014
|
+
const daemonState = await ensureDaemonPaired(controllerState.config, machineName);
|
|
1015
|
+
const daemon = daemonBefore.running && daemonState.reconfigured
|
|
1016
|
+
? await restartDaemonForNewConfig(controllerState.config.relayUrl, cwd)
|
|
1017
|
+
: await ensureDaemonRunning(controllerState.config.relayUrl, cwd);
|
|
1018
|
+
const client = new ControllerClient(controllerState.config);
|
|
1019
|
+
const machine = await waitForMachineProjection(client, daemonState.config.machineId);
|
|
1020
|
+
if (!machine?.online) {
|
|
1021
|
+
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");
|
|
1022
|
+
}
|
|
1023
|
+
const invite = await client.createControllerInvite({
|
|
1024
|
+
deviceName: "Web controller",
|
|
1025
|
+
ttlMs: 10 * 60_000,
|
|
1026
|
+
joinUrlBase: controllerBaseUrl(flags),
|
|
1027
|
+
relayUrl: controllerState.config.relayUrl,
|
|
1028
|
+
}).catch((error) => {
|
|
1029
|
+
throw cliErrorFromFetch(error, "INVITE_CREATE_FAILED", "Could not create controller invite");
|
|
1030
|
+
});
|
|
1031
|
+
const data = {
|
|
1032
|
+
relayUrl: controllerState.config.relayUrl,
|
|
1033
|
+
controllerUrl: invite.joinUrl,
|
|
1034
|
+
workspace: cwd,
|
|
1035
|
+
config: {
|
|
1036
|
+
controllerPath: configPath,
|
|
1037
|
+
daemonPath: path.join(configDir, "daemon.json"),
|
|
1038
|
+
controllerCreated: controllerState.created,
|
|
1039
|
+
controllerReconfigured: controllerState.reconfigured,
|
|
1040
|
+
controllerBackupPath: controllerState.backup?.backupPath,
|
|
1041
|
+
daemonPaired: daemonState.created,
|
|
1042
|
+
daemonReconfigured: daemonState.reconfigured,
|
|
1043
|
+
daemonBackupPath: daemonState.backup?.backupPath,
|
|
1044
|
+
},
|
|
1045
|
+
machine: {
|
|
1046
|
+
id: daemonState.config.machineId,
|
|
1047
|
+
name: machine.name ?? daemonState.config.machineName,
|
|
1048
|
+
online: machine.online,
|
|
1049
|
+
lastSeen: machine?.lastSeen,
|
|
1050
|
+
},
|
|
1051
|
+
daemon: {
|
|
1052
|
+
status: daemon.local.running ? "online" : "starting",
|
|
1053
|
+
pid: daemon.local.pid ?? daemon.startedPid,
|
|
1054
|
+
pidPath: daemon.local.pidPath,
|
|
1055
|
+
started: daemon.started,
|
|
1056
|
+
alreadyRunning: daemonBefore.running,
|
|
1057
|
+
restarted: daemonBefore.running && daemonState.reconfigured,
|
|
1058
|
+
},
|
|
1059
|
+
invite: {
|
|
1060
|
+
id: invite.inviteCode,
|
|
1061
|
+
expiresAt: invite.expiresAt,
|
|
1062
|
+
},
|
|
1063
|
+
};
|
|
1064
|
+
if (flags.json === true) {
|
|
1065
|
+
ok("start", data);
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
const openResult = flags["no-open"] === true ? { opened: false } : openUrl(invite.joinUrl);
|
|
1069
|
+
writeHumanStartOutput({
|
|
1070
|
+
workspace: cwd,
|
|
1071
|
+
controllerUrl: invite.joinUrl,
|
|
1072
|
+
relayUrl: controllerState.config.relayUrl,
|
|
1073
|
+
opened: openResult.opened,
|
|
1074
|
+
openError: openResult.error,
|
|
1075
|
+
machine: {
|
|
1076
|
+
name: data.machine.name,
|
|
1077
|
+
online: data.machine.online,
|
|
1078
|
+
},
|
|
1079
|
+
daemon: data.daemon,
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
async function topLevelDoctor(flags) {
|
|
1083
|
+
const checks = [];
|
|
1084
|
+
const existingController = await readExistingControllerConfig().catch((error) => {
|
|
1085
|
+
checks.push({ name: "controller-config", ok: false, message: error instanceof Error ? error.message : String(error) });
|
|
1086
|
+
return undefined;
|
|
1087
|
+
});
|
|
1088
|
+
if (existingController) {
|
|
1089
|
+
checks.push({ name: "controller-config", ok: true, message: configPath, details: redactedControllerConfig(existingController) });
|
|
1090
|
+
}
|
|
1091
|
+
const relay = startRelayUrl(flags, existingController);
|
|
1092
|
+
try {
|
|
1093
|
+
const health = await relayHealth(relay.relayUrl);
|
|
1094
|
+
checks.push({
|
|
1095
|
+
name: "relay",
|
|
1096
|
+
ok: health.ok,
|
|
1097
|
+
message: health.ok ? "reachable" : `HTTP ${health.status}`,
|
|
1098
|
+
details: { relayUrl: relay.relayUrl, body: health.body },
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
catch (error) {
|
|
1102
|
+
checks.push({ name: "relay", ok: false, message: error instanceof Error ? error.message : String(error), details: { relayUrl: relay.relayUrl } });
|
|
1103
|
+
}
|
|
1104
|
+
const existingDaemon = await readExistingDaemonConfig().catch((error) => {
|
|
1105
|
+
checks.push({ name: "daemon-config", ok: false, message: error instanceof Error ? error.message : String(error) });
|
|
1106
|
+
return undefined;
|
|
1107
|
+
});
|
|
1108
|
+
if (existingDaemon) {
|
|
1109
|
+
checks.push({ name: "daemon-config", ok: true, message: path.join(configDir, "daemon.json"), details: redactedDaemonConfig(existingDaemon) });
|
|
1110
|
+
}
|
|
1111
|
+
const local = await localDaemonStatus();
|
|
1112
|
+
checks.push({ name: "daemon-process", ok: local.running, message: local.running ? "running" : "not running", details: local });
|
|
1113
|
+
ok("doctor", { ok: checks.every((check) => check.ok), checks });
|
|
1114
|
+
}
|
|
731
1115
|
async function main() {
|
|
732
|
-
const
|
|
1116
|
+
const argv = process.argv.slice(2);
|
|
1117
|
+
const [domain] = argv;
|
|
733
1118
|
if (!domain)
|
|
734
1119
|
usage();
|
|
1120
|
+
if (domain === "start") {
|
|
1121
|
+
const { flags } = parseFlags(argv.slice(1));
|
|
1122
|
+
await startBootstrap(flags);
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
if (domain === "doctor") {
|
|
1126
|
+
const { flags } = parseFlags(argv.slice(1));
|
|
1127
|
+
await topLevelDoctor(flags);
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
const [, action, ...rest] = argv;
|
|
735
1131
|
const { positional, flags } = parseFlags(rest);
|
|
736
1132
|
if (domain === "account" && action === "create") {
|
|
737
1133
|
const relayUrl = requireRelayUrl(flags);
|
|
@@ -764,6 +1160,18 @@ async function main() {
|
|
|
764
1160
|
});
|
|
765
1161
|
return;
|
|
766
1162
|
}
|
|
1163
|
+
if (domain === "account" && action === "reset") {
|
|
1164
|
+
const stop = await stopLocalDaemon().catch((error) => ({
|
|
1165
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1166
|
+
}));
|
|
1167
|
+
await fs.rm(configPath, { force: true });
|
|
1168
|
+
await fs.rm(path.join(configDir, "daemon.json"), { force: true });
|
|
1169
|
+
ok("account.reset", {
|
|
1170
|
+
removed: [configPath, path.join(configDir, "daemon.json")],
|
|
1171
|
+
stop,
|
|
1172
|
+
});
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
767
1175
|
if (domain === "config" && action === "init") {
|
|
768
1176
|
const config = {
|
|
769
1177
|
relayUrl: requireRelayUrl(flags),
|
|
@@ -1435,6 +1843,32 @@ async function main() {
|
|
|
1435
1843
|
}
|
|
1436
1844
|
main().catch((error) => {
|
|
1437
1845
|
const enriched = error;
|
|
1846
|
+
if (process.argv[2] === "start" && !process.argv.includes("--json")) {
|
|
1847
|
+
const code = enriched.code ?? "COMMAND_FAILED";
|
|
1848
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1849
|
+
console.error(message);
|
|
1850
|
+
console.error("");
|
|
1851
|
+
if (code === "RELAY_UNREACHABLE") {
|
|
1852
|
+
console.error("Try:");
|
|
1853
|
+
console.error(" happy-elves start --relay <your-relay-url>");
|
|
1854
|
+
console.error(" happy-elves doctor");
|
|
1855
|
+
}
|
|
1856
|
+
else if (code === "DAEMON_START_FAILED" || code === "DAEMON_ONLINE_TIMEOUT") {
|
|
1857
|
+
console.error("Run:");
|
|
1858
|
+
console.error(" happy-elves doctor");
|
|
1859
|
+
console.error(" happy-elves daemon logs");
|
|
1860
|
+
}
|
|
1861
|
+
else if (code === "CONFIG_INVALID") {
|
|
1862
|
+
console.error("Run:");
|
|
1863
|
+
console.error(" happy-elves account reset");
|
|
1864
|
+
console.error(" happy-elves start");
|
|
1865
|
+
}
|
|
1866
|
+
else {
|
|
1867
|
+
console.error("Run:");
|
|
1868
|
+
console.error(" happy-elves doctor");
|
|
1869
|
+
}
|
|
1870
|
+
process.exit(1);
|
|
1871
|
+
}
|
|
1438
1872
|
writeError(enriched.code ?? "COMMAND_FAILED", error instanceof Error ? error.message : String(error), { requestId: enriched.requestId }, enriched.capability);
|
|
1439
1873
|
process.exit(1);
|
|
1440
1874
|
});
|