@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.
- package/apps/cli/dist/index.js +451 -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,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
|
|
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
|
});
|