@iicp/client 0.7.100 → 0.7.102

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.
Files changed (50) hide show
  1. package/LICENSE +83 -61
  2. package/README.md +2 -2
  3. package/dist/backends/openai_compat.d.ts +3 -0
  4. package/dist/backends/openai_compat.d.ts.map +1 -1
  5. package/dist/backends/openai_compat.js +119 -0
  6. package/dist/backends/openai_compat.js.map +1 -1
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +264 -39
  9. package/dist/cli.js.map +1 -1
  10. package/dist/iicp_tcp.d.ts +38 -0
  11. package/dist/iicp_tcp.d.ts.map +1 -1
  12. package/dist/iicp_tcp.js +164 -0
  13. package/dist/iicp_tcp.js.map +1 -1
  14. package/dist/index.d.ts +4 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +10 -3
  17. package/dist/index.js.map +1 -1
  18. package/dist/mcp_negotiation.d.ts +23 -0
  19. package/dist/mcp_negotiation.d.ts.map +1 -0
  20. package/dist/mcp_negotiation.js +94 -0
  21. package/dist/mcp_negotiation.js.map +1 -0
  22. package/dist/native_call_identity.d.ts +12 -0
  23. package/dist/native_call_identity.d.ts.map +1 -0
  24. package/dist/native_call_identity.js +44 -0
  25. package/dist/native_call_identity.js.map +1 -0
  26. package/dist/native_response_sequence.d.ts +32 -0
  27. package/dist/native_response_sequence.d.ts.map +1 -0
  28. package/dist/native_response_sequence.js +59 -0
  29. package/dist/native_response_sequence.js.map +1 -0
  30. package/dist/node.d.ts +5 -1
  31. package/dist/node.d.ts.map +1 -1
  32. package/dist/node.js +59 -7
  33. package/dist/node.js.map +1 -1
  34. package/dist/operator_profile.d.ts +18 -0
  35. package/dist/operator_profile.d.ts.map +1 -0
  36. package/dist/operator_profile.js +22 -0
  37. package/dist/operator_profile.js.map +1 -0
  38. package/dist/relay_session.d.ts +16 -4
  39. package/dist/relay_session.d.ts.map +1 -1
  40. package/dist/relay_session.js +116 -1
  41. package/dist/relay_session.js.map +1 -1
  42. package/dist/runtime_health.d.ts +77 -0
  43. package/dist/runtime_health.d.ts.map +1 -0
  44. package/dist/runtime_health.js +65 -0
  45. package/dist/runtime_health.js.map +1 -0
  46. package/dist/service.d.ts +7 -0
  47. package/dist/service.d.ts.map +1 -1
  48. package/dist/service.js +44 -0
  49. package/dist/service.js.map +1 -1
  50. package/package.json +6 -3
package/dist/cli.js CHANGED
@@ -44,6 +44,7 @@ const node_child_process_1 = require("node:child_process");
44
44
  const readline = require("node:readline/promises");
45
45
  const node_process_1 = require("node:process");
46
46
  const node_js_1 = require("./node.js");
47
+ const runtime_health_js_1 = require("./runtime_health.js");
47
48
  const client_js_1 = require("./client.js");
48
49
  const node_log_js_1 = require("./node_log.js");
49
50
  const cip_policy_js_1 = require("./cip_policy.js");
@@ -53,7 +54,10 @@ const index_js_2 = require("./backends/index.js");
53
54
  const recovery_js_1 = require("./recovery.js");
54
55
  const identity_js_1 = require("./identity.js");
55
56
  const delegation_js_1 = require("./delegation.js");
57
+ const operator_profile_js_1 = require("./operator_profile.js");
58
+ const updater_js_1 = require("./updater.js");
56
59
  const mcp_policy_js_1 = require("./mcp_policy.js");
60
+ const mcp_negotiation_js_1 = require("./mcp_negotiation.js");
57
61
  /**
58
62
  * Start the default-on provider updater for long-running TS node processes.
59
63
  *
@@ -169,6 +173,7 @@ function printHelp() {
169
173
  ` list List node configs saved under ~/.iicp/nodes/\n` +
170
174
  ` serve Register and serve a node\n` +
171
175
  ` doctor Check local health, directory presence, and recovery action\n` +
176
+ ` healthcheck Check local runtime liveness/readiness snapshot\n` +
172
177
  ` query <prompt> Discover mesh nodes and submit a chat task\n` +
173
178
  ` credits Show your operator wallet plus this node's credit ledger\n` +
174
179
  ` proxy Run the local OpenAI/Ollama/Anthropic-compat gateway (loopback; no registration)\n` +
@@ -692,18 +697,6 @@ async function runServe(opts) {
692
697
  allowCoordinator: true,
693
698
  });
694
699
  }
695
- // 2-C: co-host the compat proxy on loopback alongside the node, supervised so a
696
- // proxy failure logs but never drops the network-facing node. Forced to 127.0.0.1.
697
- if (opts.withProxy) {
698
- const pport = envInt("IICP_PROXY_PORT", 9483);
699
- const pclient = new client_js_1.IicpClient({
700
- directory_url: opts.directoryUrl,
701
- region: opts.region,
702
- });
703
- const pserver = (0, index_js_1.createProxyServer)(pclient);
704
- pserver.on("error", (e) => process.stderr.write(`co-hosted proxy error (node continues): ${String(e)}\n`));
705
- pserver.listen(pport, "127.0.0.1", () => process.stdout.write(`co-hosted proxy → http://127.0.0.1:${pport} (OpenAI/Ollama/Anthropic compat)\n`));
706
- }
707
700
  // Phase 2 (#529/#55) — capture any cached node_token so the node can prove
708
701
  // ownership on re-registration (IICP-E050 token path).
709
702
  let savedNodeToken;
@@ -716,6 +709,31 @@ async function runServe(opts) {
716
709
  savedNodeToken = saved.node_token ?? undefined;
717
710
  opts = applySavedNode(opts, saved);
718
711
  }
712
+ const _op = (0, identity_js_1.loadOperator)();
713
+ const managedDecision = (0, operator_profile_js_1.evaluateManagedOperator)({
714
+ mode: (process.env.IICP_OPERATOR_PROFILE ?? "convenience").trim().toLowerCase(),
715
+ authentication_configured: !!_op && (0, identity_js_1.operatorIsKeyBacked)(_op),
716
+ identity_storage_protected: !!_op && (0, identity_js_1.operatorIsKeyBacked)(_op) && (0, identity_js_1.operatorIsEncrypted)(_op),
717
+ auto_update_requested: (0, updater_js_1.autoUpdateEnabled)(),
718
+ update_authenticated: envBool("IICP_MANAGED_UPDATE_AUTHENTICATED"),
719
+ rollback_verified: envBool("IICP_MANAGED_ROLLBACK_VERIFIED"),
720
+ upnp_requested: opts.autoDetectNat && !envBool("IICP_SKIP_UPNP"),
721
+ tunnel_requested: opts.tunnel !== false,
722
+ upnp_approved: envBool("IICP_MANAGED_UPNP_APPROVED"),
723
+ tunnel_approved: envBool("IICP_MANAGED_TUNNEL_APPROVED"),
724
+ });
725
+ if (!managedDecision.accepted) {
726
+ process.stderr.write(`ERROR: managed operator startup rejected: ${managedDecision.reason}\n`);
727
+ return 2;
728
+ }
729
+ // Start the optional co-hosted listener only after the operator profile passes.
730
+ if (opts.withProxy) {
731
+ const pport = envInt("IICP_PROXY_PORT", 9483);
732
+ const pclient = new client_js_1.IicpClient({ directory_url: opts.directoryUrl, region: opts.region });
733
+ const pserver = (0, index_js_1.createProxyServer)(pclient);
734
+ pserver.on("error", (e) => process.stderr.write(`co-hosted proxy error (node continues): ${String(e)}\n`));
735
+ pserver.listen(pport, "127.0.0.1", () => process.stdout.write(`co-hosted proxy → http://127.0.0.1:${pport} (OpenAI/Ollama/Anthropic compat)\n`));
736
+ }
719
737
  // #410/#414 — built-in backend-url fallback applied LAST (after flag/env/saved-config),
720
738
  // so a bare `serve --model x` works without --backend-url. An `anthropic` backend
721
739
  // defaults to the Anthropic API, not localhost Ollama. Mirrors Python cli.py ~704.
@@ -983,7 +1001,6 @@ async function runServe(opts) {
983
1001
  // #463/#464 — bind the operator identity: issue a delegation FROM the (key-backed) operator
984
1002
  // identity for this node and advertise the public display_name. The directory verifies the
985
1003
  // delegation (operator_pub == operator_id) and records the operator. Never sends the secret/contact.
986
- const _op = (0, identity_js_1.loadOperator)();
987
1004
  let _opDelegation;
988
1005
  let _opDisplayName;
989
1006
  let _opCreatedAt;
@@ -1958,6 +1975,50 @@ async function runDoctor(argv) {
1958
1975
  process.stdout.write(" Note restart is automatic only when supervised services set IICP_SUPERVISED=1\n");
1959
1976
  return 0;
1960
1977
  }
1978
+ function runHealthcheck(argv) {
1979
+ const { values } = safeParseArgs({ args: argv, options: { node: { type: "string" }, json: { type: "boolean" }, ready: { type: "boolean" }, help: { type: "boolean", short: "h" } }, allowPositionals: false });
1980
+ if (values.help) {
1981
+ process.stdout.write("usage: iicp-node healthcheck --node NAME [--json] [--ready]\n");
1982
+ return 0;
1983
+ }
1984
+ const node = values.node ?? process.env.IICP_NODE_NAME;
1985
+ if (!node) {
1986
+ process.stderr.write("ERROR: healthcheck requires --node NAME\n");
1987
+ return 2;
1988
+ }
1989
+ let file;
1990
+ try {
1991
+ file = (0, runtime_health_js_1.runtimeHealthPath)(node);
1992
+ }
1993
+ catch (error) {
1994
+ process.stderr.write(`ERROR: ${error instanceof Error ? error.message : String(error)}\n`);
1995
+ return 2;
1996
+ }
1997
+ let snapshot;
1998
+ try {
1999
+ snapshot = JSON.parse(fs.readFileSync(file, "utf8"));
2000
+ }
2001
+ catch (error) {
2002
+ process.stderr.write(`INDETERMINATE: runtime-health snapshot unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
2003
+ return 2;
2004
+ }
2005
+ const progress = snapshot.progress;
2006
+ const staleAfter = Number(progress?.runtime?.stale_after_ms ?? 0);
2007
+ const age = Date.now() - fs.statSync(file).mtimeMs;
2008
+ if (age < 0 || !Number.isFinite(staleAfter) || staleAfter <= 0) {
2009
+ process.stderr.write("INDETERMINATE: snapshot freshness could not be established\n");
2010
+ return 2;
2011
+ }
2012
+ if (values.json)
2013
+ process.stdout.write(`${JSON.stringify(snapshot, null, 2)}\n`);
2014
+ else
2015
+ process.stdout.write(`IICP node health — ${node}\n liveness ${String(snapshot.liveness ?? "indeterminate")}\n readiness ${String(snapshot.readiness ?? "not_ready")}\n reasons ${Array.isArray(snapshot.reason_codes) ? snapshot.reason_codes.join(", ") : ""}\n`);
2016
+ if (age > staleAfter)
2017
+ return 1;
2018
+ if (values.ready)
2019
+ return snapshot.readiness === "ready" ? 0 : 1;
2020
+ return snapshot.liveness === "live" ? 0 : snapshot.liveness === "not_live" ? 1 : 2;
2021
+ }
1961
2022
  /**
1962
2023
  * Resolve a passphrase: $IICP_OPERATOR_PASSPHRASE if set (headless/CI), else an interactive
1963
2024
  * readline prompt (this command is operator-run, so a prompt is fine here — only `serve` must
@@ -2576,7 +2637,10 @@ async function runMcpGateway(argv) {
2576
2637
  ` --allow-dangerous-tools IICP_MCP_ALLOW_DANGEROUS_TOOLS (requires all controls below)\n` +
2577
2638
  ` --authz-policy ID IICP_MCP_AUTHZ_POLICY\n` +
2578
2639
  ` --sandbox PROFILE IICP_MCP_SANDBOX (strict/container/sandbox)\n` +
2579
- ` --audit-redaction IICP_MCP_AUDIT_REDACTION\n`);
2640
+ ` --audit-redaction IICP_MCP_AUDIT_REDACTION\n` +
2641
+ ` --mcp-revision REV IICP_MCP_REVISION (legacy default; 2026-07-28 is opt-in)\n` +
2642
+ ` --mcp-server-name N IICP_MCP_SERVER_NAME (required for modern MCP)\n` +
2643
+ ` --mcp-extensions X IICP_MCP_EXTENSIONS (tasks,skills,apps)\n`);
2580
2644
  return 0;
2581
2645
  }
2582
2646
  const { values } = safeParseArgs({
@@ -2594,10 +2658,25 @@ async function runMcpGateway(argv) {
2594
2658
  "authz-policy": { type: "string" },
2595
2659
  sandbox: { type: "string" },
2596
2660
  "audit-redaction": { type: "boolean" },
2661
+ "mcp-revision": { type: "string" },
2662
+ "mcp-server-name": { type: "string" },
2663
+ "mcp-extensions": { type: "string" },
2597
2664
  },
2598
2665
  allowPositionals: false,
2599
2666
  });
2600
2667
  const mcpUrl = (values["mcp-url"] ?? envOr("IICP_MCP_URL") ?? "http://localhost:8001").replace(/\/$/, "");
2668
+ const mcpRevision = values["mcp-revision"] ?? envOr("IICP_MCP_REVISION") ?? mcp_negotiation_js_1.LEGACY_MCP_REVISION;
2669
+ const mcpServerName = (values["mcp-server-name"] ?? envOr("IICP_MCP_SERVER_NAME") ?? "").trim();
2670
+ const mcpExtensions = (values["mcp-extensions"] ?? envOr("IICP_MCP_EXTENSIONS") ?? "")
2671
+ .split(",").map((extension) => extension.trim().toLowerCase()).filter(Boolean);
2672
+ if (![...mcp_negotiation_js_1.SUPPORTED_MCP_REVISIONS].includes(mcpRevision)) {
2673
+ process.stderr.write(`ERROR: unsupported MCP revision ${mcpRevision}.\n`);
2674
+ return 2;
2675
+ }
2676
+ if (mcpRevision === mcp_negotiation_js_1.MODERN_MCP_REVISION && !mcpServerName) {
2677
+ process.stderr.write("ERROR: --mcp-server-name is required with MCP 2026-07-28.\n");
2678
+ return 2;
2679
+ }
2601
2680
  const rawTools = (values["tools"] ?? envOr("IICP_MCP_TOOLS") ?? "")
2602
2681
  .split(",").map((t) => t.trim()).filter(Boolean);
2603
2682
  const toolPolicy = new mcp_policy_js_1.McpToolPolicy({
@@ -2627,9 +2706,7 @@ async function runMcpGateway(argv) {
2627
2706
  const publicEndpoint = values["public-endpoint"] ?? envOr("IICP_PUBLIC_ENDPOINT") ?? `http://localhost:${port}`;
2628
2707
  const intents = activeTools.map(_toolToIntent);
2629
2708
  const capabilities = activeTools.map((tool, index) => ({
2630
- intent: intents[index],
2631
- models: [`mcp:${tool}`],
2632
- max_tokens: 65536,
2709
+ intent: intents[index], models: [`mcp:${tool}`], max_tokens: 65536,
2633
2710
  }));
2634
2711
  let nodeToken = envOr("IICP_NODE_TOKEN") ?? "";
2635
2712
  async function doRegister() {
@@ -2670,18 +2747,128 @@ async function runMcpGateway(argv) {
2670
2747
  }
2671
2748
  }
2672
2749
  let mcpRpcId = 0;
2673
- async function callMcp(toolName, args) {
2750
+ let legacySessionId;
2751
+ let legacyInitialization;
2752
+ class McpLegacySessionExpired extends Error {
2753
+ }
2754
+ async function mcpResponseJson(response) {
2755
+ const raw = await response.text();
2756
+ if ((response.headers.get("content-type") ?? "").includes("text/event-stream")) {
2757
+ const events = raw.split(/\r?\n/).filter((line) => line.startsWith("data: ")).map((line) => line.slice(6));
2758
+ if (!events.length)
2759
+ throw new Error("MCP server returned an empty event stream");
2760
+ return JSON.parse(events.at(-1) ?? "");
2761
+ }
2762
+ return JSON.parse(raw);
2763
+ }
2764
+ async function initializeLegacySession() {
2765
+ if (legacyInitialization)
2766
+ return legacyInitialization;
2767
+ legacyInitialization = (async () => {
2768
+ const id = ++mcpRpcId;
2769
+ const headers = {
2770
+ "Content-Type": "application/json",
2771
+ "MCP-Protocol-Version": mcp_negotiation_js_1.LEGACY_MCP_REVISION,
2772
+ "Accept": "application/json, text/event-stream",
2773
+ };
2774
+ const init = await fetch(`${mcpUrl}/mcp`, {
2775
+ method: "POST",
2776
+ headers,
2777
+ body: JSON.stringify({
2778
+ jsonrpc: "2.0", id, method: "initialize",
2779
+ params: { protocolVersion: mcp_negotiation_js_1.LEGACY_MCP_REVISION, capabilities: {}, clientInfo: { name: "iicp-mcp-gateway", version: "0.7" } },
2780
+ }),
2781
+ signal: AbortSignal.timeout(30_000),
2782
+ });
2783
+ if (!init.ok)
2784
+ throw new Error(`MCP legacy initialization failed: ${init.status}`);
2785
+ const initData = await mcpResponseJson(init);
2786
+ if (initData["error"])
2787
+ throw new Error("MCP server rejected legacy initialization");
2788
+ const sessionId = init.headers.get("mcp-session-id");
2789
+ if (!sessionId)
2790
+ throw new Error("MCP server did not return a legacy session identifier");
2791
+ const initialized = await fetch(`${mcpUrl}/mcp`, {
2792
+ method: "POST",
2793
+ headers: { ...headers, "Mcp-Session-Id": sessionId },
2794
+ body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }),
2795
+ signal: AbortSignal.timeout(30_000),
2796
+ });
2797
+ if (!initialized.ok)
2798
+ throw new Error(`MCP legacy initialized notification failed: ${initialized.status}`);
2799
+ legacySessionId = sessionId;
2800
+ })();
2801
+ try {
2802
+ await legacyInitialization;
2803
+ }
2804
+ finally {
2805
+ legacyInitialization = undefined;
2806
+ }
2807
+ }
2808
+ async function legacyToolCall(rpc, replaySafe) {
2809
+ if (!legacySessionId)
2810
+ await initializeLegacySession();
2811
+ const send = async () => {
2812
+ if (!legacySessionId)
2813
+ throw new Error("legacy MCP session identifier is unavailable");
2814
+ const response = await fetch(`${mcpUrl}/mcp`, {
2815
+ method: "POST",
2816
+ headers: {
2817
+ "Content-Type": "application/json",
2818
+ "MCP-Protocol-Version": mcp_negotiation_js_1.LEGACY_MCP_REVISION,
2819
+ "Mcp-Session-Id": legacySessionId,
2820
+ "Accept": "application/json, text/event-stream",
2821
+ },
2822
+ body: JSON.stringify(rpc),
2823
+ signal: AbortSignal.timeout(30_000),
2824
+ });
2825
+ if (response.status === 401 || response.status === 404) {
2826
+ legacySessionId = undefined;
2827
+ throw new McpLegacySessionExpired("MCP legacy session expired");
2828
+ }
2829
+ if (!response.ok)
2830
+ throw new Error(`MCP server unreachable: ${response.status}`);
2831
+ return mcpResponseJson(response);
2832
+ };
2833
+ try {
2834
+ return await send();
2835
+ }
2836
+ catch (error) {
2837
+ if (!(error instanceof McpLegacySessionExpired))
2838
+ throw error;
2839
+ if (!replaySafe)
2840
+ throw new McpLegacySessionExpired("MCP legacy session expired; caller retry required");
2841
+ await initializeLegacySession();
2842
+ try {
2843
+ return await send();
2844
+ }
2845
+ catch (retryError) {
2846
+ if (retryError instanceof McpLegacySessionExpired)
2847
+ throw new Error("MCP legacy session expired after one reinitialization");
2848
+ throw retryError;
2849
+ }
2850
+ }
2851
+ }
2852
+ async function callMcp(toolName, args, replaySafe = false) {
2674
2853
  mcpRpcId += 1;
2675
- const rpc = { jsonrpc: "2.0", id: mcpRpcId, method: "tools/call", params: { name: toolName, arguments: args } };
2676
- const resp = await fetch(`${mcpUrl}/mcp`, {
2677
- method: "POST",
2678
- headers: { "Content-Type": "application/json" },
2679
- body: JSON.stringify(rpc),
2680
- signal: AbortSignal.timeout(30_000),
2681
- });
2682
- if (!resp.ok)
2683
- throw new Error(`MCP server unreachable: ${resp.status}`);
2684
- const data = await resp.json();
2854
+ const params = { name: toolName, arguments: args };
2855
+ const modern = mcpRevision === mcp_negotiation_js_1.MODERN_MCP_REVISION
2856
+ ? (0, mcp_negotiation_js_1.buildModernMcpRequest)({ requestId: mcpRpcId, method: "tools/call", name: toolName, params, extensions: mcpExtensions })
2857
+ : null;
2858
+ const rpc = modern?.body ?? { jsonrpc: "2.0", id: mcpRpcId, method: "tools/call", params };
2859
+ const data = modern
2860
+ ? await (async () => {
2861
+ const resp = await fetch(`${mcpUrl}/mcp`, {
2862
+ method: "POST", headers: { "Content-Type": "application/json", ...modern.headers },
2863
+ body: JSON.stringify(rpc), signal: AbortSignal.timeout(30_000),
2864
+ });
2865
+ if (!resp.ok)
2866
+ throw new Error(`MCP server unreachable: ${resp.status}`);
2867
+ return resp.json();
2868
+ })()
2869
+ : await legacyToolCall(rpc, replaySafe);
2870
+ if (modern)
2871
+ (0, mcp_negotiation_js_1.validateModernMcpResponse)(data, mcpServerName);
2685
2872
  if (data["error"])
2686
2873
  throw new Error("MCP tool returned an error");
2687
2874
  return data["result"];
@@ -2757,15 +2944,15 @@ async function runMcpGateway(argv) {
2757
2944
  }
2758
2945
  const taskId = body["task_id"] ?? (0, node_crypto_1.randomUUID)();
2759
2946
  try {
2760
- const result = await callMcp(toolName, args);
2947
+ const result = await callMcp(toolName, args, payload["mcp_replay_safe"] === true);
2761
2948
  res.writeHead(200, { "Content-Type": "application/json" });
2762
2949
  res.end(JSON.stringify({ task_id: taskId, status: "completed", result, policy_receipt: toolPolicy.receipt(toolName, "allowed", argumentCount) }));
2763
2950
  }
2764
2951
  catch (err) {
2765
2952
  const msg = err.message ?? "error";
2766
- const code = msg.includes("unreachable") ? 502 : 422;
2953
+ const code = err instanceof McpLegacySessionExpired ? 409 : msg.includes("unreachable") ? 502 : 422;
2767
2954
  res.writeHead(code, { "Content-Type": "application/json" });
2768
- res.end(JSON.stringify({ error: msg }));
2955
+ res.end(JSON.stringify(err instanceof McpLegacySessionExpired ? { error: "mcp_session_expired_retry_required", retryable: true } : { error: msg }));
2769
2956
  }
2770
2957
  return;
2771
2958
  }
@@ -2823,7 +3010,8 @@ async function runService(argv) {
2823
3010
  ` --node NAME Saved node name to serve (required)\n` +
2824
3011
  ` --name NAME Override service label/unit name\n` +
2825
3012
  ` --platform KIND auto | launchd | systemd (default auto)\n` +
2826
- ` --dry-run For install: print the generated unit without writing files\n`);
3013
+ ` --no-start Install and enable without starting the service\n` +
3014
+ ` --dry-run Print unit/actions without changing files or service state\n`);
2827
3015
  return subcmd ? 0 : 2;
2828
3016
  }
2829
3017
  if (!["install", "status", "restart", "uninstall"].includes(subcmd)) {
@@ -2836,6 +3024,7 @@ async function runService(argv) {
2836
3024
  name: { type: "string" },
2837
3025
  platform: { type: "string" },
2838
3026
  "dry-run": { type: "boolean" },
3027
+ "no-start": { type: "boolean" },
2839
3028
  help: { type: "boolean", short: "h" },
2840
3029
  },
2841
3030
  allowPositionals: false,
@@ -2845,17 +3034,37 @@ async function runService(argv) {
2845
3034
  const node = values.node;
2846
3035
  if (!node)
2847
3036
  throw new CliError("service requires --node NAME");
2848
- const { renderServiceUnit } = await import("./service.js");
3037
+ const { managerActions, renderServiceUnit } = await import("./service.js");
2849
3038
  const unit = renderServiceUnit(node, values.name, values.platform ?? "auto");
3039
+ const dryRun = values["dry-run"] === true;
3040
+ const noStart = values["no-start"] === true;
3041
+ const printAction = (command, args) => process.stdout.write(`$ ${[command, ...args].map((part) => JSON.stringify(part)).join(" ")}\n`);
3042
+ const execute = (operation) => {
3043
+ for (const action of managerActions(unit, operation, noStart)) {
3044
+ if (dryRun) {
3045
+ printAction(action.command, action.args);
3046
+ continue;
3047
+ }
3048
+ const result = (0, node_child_process_1.spawnSync)(action.command, action.args, { stdio: "inherit" });
3049
+ if (result.error && !action.tolerateFailure)
3050
+ throw new CliError(`${action.command} failed: ${result.error.message}`);
3051
+ if ((result.status ?? (result.error ? 1 : 0)) !== 0 && !action.tolerateFailure) {
3052
+ throw new CliError(`${action.command} exited with status ${result.status ?? "unknown"}`);
3053
+ }
3054
+ }
3055
+ };
2850
3056
  if (subcmd === "install") {
2851
- if (values["dry-run"]) {
3057
+ if (dryRun) {
2852
3058
  process.stdout.write(`# ${unit.platform} service: ${unit.name}\n# path: ${unit.path}\n${unit.content}`);
2853
3059
  }
2854
3060
  else {
2855
3061
  fs.mkdirSync(path.dirname(unit.path), { recursive: true });
2856
- fs.writeFileSync(unit.path, unit.content);
3062
+ const temporary = `${unit.path}.tmp-${process.pid}`;
3063
+ fs.writeFileSync(temporary, unit.content, { mode: 0o600 });
3064
+ fs.renameSync(temporary, unit.path);
2857
3065
  process.stdout.write(`Installed ${unit.platform} service unit: ${unit.path}\n`);
2858
3066
  }
3067
+ execute("install");
2859
3068
  process.stdout.write(`status: ${unit.statusHint}\n`);
2860
3069
  process.stdout.write(`restart: ${unit.restartHint}\n`);
2861
3070
  process.stdout.write(`logs: ${unit.logHint}\n`);
@@ -2863,11 +3072,25 @@ async function runService(argv) {
2863
3072
  return 0;
2864
3073
  }
2865
3074
  if (subcmd === "status")
2866
- process.stdout.write(`${unit.statusHint}\n`);
3075
+ execute("status");
2867
3076
  if (subcmd === "restart")
2868
- process.stdout.write(`${unit.restartHint}\n`);
2869
- if (subcmd === "uninstall")
2870
- process.stdout.write(`${unit.uninstallHint}\n`);
3077
+ execute("restart");
3078
+ if (subcmd === "uninstall") {
3079
+ execute("uninstall");
3080
+ if (dryRun)
3081
+ process.stdout.write(`$ ${JSON.stringify("rm")} ${JSON.stringify("-f")} ${JSON.stringify(unit.path)}\n`);
3082
+ else
3083
+ fs.rmSync(unit.path, { force: true });
3084
+ if (unit.platform === "systemd") {
3085
+ if (dryRun)
3086
+ printAction("systemctl", ["--user", "daemon-reload"]);
3087
+ else {
3088
+ const result = (0, node_child_process_1.spawnSync)("systemctl", ["--user", "daemon-reload"], { stdio: "inherit" });
3089
+ if (result.error || result.status !== 0)
3090
+ throw new CliError("systemctl --user daemon-reload failed after uninstall");
3091
+ }
3092
+ }
3093
+ }
2871
3094
  return 0;
2872
3095
  }
2873
3096
  async function dispatch(argv) {
@@ -2882,6 +3105,8 @@ async function dispatch(argv) {
2882
3105
  return runCredits(argv.slice(1));
2883
3106
  if (cmd === "doctor")
2884
3107
  return runDoctor(argv.slice(1));
3108
+ if (cmd === "healthcheck")
3109
+ return runHealthcheck(argv.slice(1));
2885
3110
  if (cmd === "operator")
2886
3111
  return runOperator(argv.slice(1));
2887
3112
  if (cmd === "proxy")