@hwip/cli 0.0.1 → 0.1.0
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/LICENSE +190 -0
- package/NOTICE +11 -0
- package/README.md +1979 -0
- package/dist/agent-list.d.ts +76 -0
- package/dist/agent-list.js +102 -0
- package/dist/agent-read.d.ts +105 -0
- package/dist/agent-read.js +309 -0
- package/dist/agent-rules-fetch.d.ts +51 -0
- package/dist/agent-rules-fetch.js +275 -0
- package/dist/agent-rules.default.d.ts +30 -0
- package/dist/agent-rules.default.js +292 -0
- package/dist/agent-session.d.ts +23 -0
- package/dist/agent-session.js +44 -0
- package/dist/agent-state.d.ts +84 -0
- package/dist/agent-state.js +188 -0
- package/dist/agent-target.d.ts +60 -0
- package/dist/agent-target.js +81 -0
- package/dist/agents.d.ts +31 -0
- package/dist/agents.js +75 -0
- package/dist/api.d.ts +28 -0
- package/dist/api.js +76 -0
- package/dist/args.d.ts +74 -0
- package/dist/args.js +254 -0
- package/dist/ask-context-attach.d.ts +73 -0
- package/dist/ask-context-attach.js +151 -0
- package/dist/ask-context-screen.d.ts +21 -0
- package/dist/ask-context-screen.js +49 -0
- package/dist/ask-context-switch.d.ts +31 -0
- package/dist/ask-context-switch.js +74 -0
- package/dist/ask-context.d.ts +84 -0
- package/dist/ask-context.js +172 -0
- package/dist/ask-pending.d.ts +47 -0
- package/dist/ask-pending.js +221 -0
- package/dist/ask-seal.d.ts +120 -0
- package/dist/ask-seal.js +179 -0
- package/dist/ask.d.ts +107 -0
- package/dist/ask.js +551 -0
- package/dist/blocked-prompt-seal.d.ts +35 -0
- package/dist/blocked-prompt-seal.js +89 -0
- package/dist/blocked-prompt.d.ts +57 -0
- package/dist/blocked-prompt.js +181 -0
- package/dist/channel.d.ts +67 -0
- package/dist/channel.js +136 -0
- package/dist/check-update.d.ts +28 -0
- package/dist/check-update.js +153 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +813 -0
- package/dist/codex-config.d.ts +24 -0
- package/dist/codex-config.js +169 -0
- package/dist/codex-transcript.d.ts +59 -0
- package/dist/codex-transcript.js +373 -0
- package/dist/command-refusal.d.ts +56 -0
- package/dist/command-refusal.js +127 -0
- package/dist/command-scan.d.ts +46 -0
- package/dist/command-scan.js +154 -0
- package/dist/command-seal.d.ts +57 -0
- package/dist/command-seal.js +88 -0
- package/dist/command-seq.d.ts +30 -0
- package/dist/command-seq.js +150 -0
- package/dist/config.d.ts +156 -0
- package/dist/config.js +392 -0
- package/dist/core-sections.d.ts +18 -0
- package/dist/core-sections.js +28 -0
- package/dist/crypto.d.ts +278 -0
- package/dist/crypto.js +698 -0
- package/dist/desktop-notify.d.ts +33 -0
- package/dist/desktop-notify.js +49 -0
- package/dist/device-directory.d.ts +36 -0
- package/dist/device-directory.js +97 -0
- package/dist/device-presence.d.ts +17 -0
- package/dist/device-presence.js +62 -0
- package/dist/downloads.d.ts +29 -0
- package/dist/downloads.js +106 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +28 -0
- package/dist/file-crypto.d.ts +94 -0
- package/dist/file-crypto.js +272 -0
- package/dist/file-view-budget.d.ts +15 -0
- package/dist/file-view-budget.js +34 -0
- package/dist/file-view-switch.d.ts +28 -0
- package/dist/file-view-switch.js +122 -0
- package/dist/file-view.d.ts +108 -0
- package/dist/file-view.js +327 -0
- package/dist/gate.d.ts +141 -0
- package/dist/gate.js +336 -0
- package/dist/gemini-transcript.d.ts +32 -0
- package/dist/gemini-transcript.js +189 -0
- package/dist/http-failure.d.ts +98 -0
- package/dist/http-failure.js +349 -0
- package/dist/hwip-core.generated.d.ts +7 -0
- package/dist/hwip-core.generated.js +14 -0
- package/dist/hwip-hook.d.ts +77 -0
- package/dist/hwip-hook.js +340 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +14 -0
- package/dist/input-sequencer.d.ts +65 -0
- package/dist/input-sequencer.js +142 -0
- package/dist/insecure-url.d.ts +16 -0
- package/dist/insecure-url.js +76 -0
- package/dist/installer.d.ts +96 -0
- package/dist/installer.js +850 -0
- package/dist/inventory-offload.d.ts +49 -0
- package/dist/inventory-offload.js +209 -0
- package/dist/inventory-worker.d.ts +2 -0
- package/dist/inventory-worker.js +27 -0
- package/dist/jsonc-doc.d.ts +106 -0
- package/dist/jsonc-doc.js +354 -0
- package/dist/keep-awake.d.ts +32 -0
- package/dist/keep-awake.js +46 -0
- package/dist/key-changes.d.ts +48 -0
- package/dist/key-changes.js +140 -0
- package/dist/lan-auth.d.ts +140 -0
- package/dist/lan-auth.js +217 -0
- package/dist/lan-endpoint.d.ts +41 -0
- package/dist/lan-endpoint.js +115 -0
- package/dist/lan-receiver.d.ts +122 -0
- package/dist/lan-receiver.js +510 -0
- package/dist/lan-sender.d.ts +76 -0
- package/dist/lan-sender.js +184 -0
- package/dist/lan-transfer.d.ts +36 -0
- package/dist/lan-transfer.js +54 -0
- package/dist/listener-device-id.d.ts +20 -0
- package/dist/listener-device-id.js +133 -0
- package/dist/listener-log.d.ts +4 -0
- package/dist/listener-log.js +23 -0
- package/dist/listener-process.d.ts +200 -0
- package/dist/listener-process.js +470 -0
- package/dist/listener-service.d.ts +225 -0
- package/dist/listener-service.js +682 -0
- package/dist/listener-worktree-cleanup.d.ts +27 -0
- package/dist/listener-worktree-cleanup.js +62 -0
- package/dist/listener.d.ts +1325 -0
- package/dist/listener.js +5680 -0
- package/dist/login-address.d.ts +25 -0
- package/dist/login-address.js +97 -0
- package/dist/login-page.d.ts +29 -0
- package/dist/login-page.js +93 -0
- package/dist/login.d.ts +98 -0
- package/dist/login.js +388 -0
- package/dist/mcp-command.d.ts +45 -0
- package/dist/mcp-command.js +50 -0
- package/dist/mcp.d.ts +19 -0
- package/dist/mcp.js +29 -0
- package/dist/node-support.d.ts +19 -0
- package/dist/node-support.js +38 -0
- package/dist/notify-failure.d.ts +57 -0
- package/dist/notify-failure.js +150 -0
- package/dist/opencode-transcript.d.ts +139 -0
- package/dist/opencode-transcript.js +390 -0
- package/dist/optional-features.d.ts +6 -0
- package/dist/optional-features.js +19 -0
- package/dist/pane-fit.d.ts +40 -0
- package/dist/pane-fit.js +119 -0
- package/dist/peer-key.d.ts +33 -0
- package/dist/peer-key.js +34 -0
- package/dist/pi-transcript.d.ts +26 -0
- package/dist/pi-transcript.js +255 -0
- package/dist/presence.d.ts +14 -0
- package/dist/presence.js +124 -0
- package/dist/private-files.d.ts +51 -0
- package/dist/private-files.js +218 -0
- package/dist/project-switch.d.ts +19 -0
- package/dist/project-switch.js +60 -0
- package/dist/redact.d.ts +10 -0
- package/dist/redact.js +21 -0
- package/dist/remote-agents.d.ts +410 -0
- package/dist/remote-agents.js +1218 -0
- package/dist/remote-hook.d.ts +12 -0
- package/dist/remote-hook.js +196 -0
- package/dist/replace-file.d.ts +50 -0
- package/dist/replace-file.js +328 -0
- package/dist/schedule-command.d.ts +35 -0
- package/dist/schedule-command.js +260 -0
- package/dist/schedule-launch.d.ts +15 -0
- package/dist/schedule-launch.js +24 -0
- package/dist/schedule-ready.d.ts +44 -0
- package/dist/schedule-ready.js +71 -0
- package/dist/schedule-remote.d.ts +77 -0
- package/dist/schedule-remote.js +167 -0
- package/dist/schedule-runner.d.ts +89 -0
- package/dist/schedule-runner.js +452 -0
- package/dist/schedule.d.ts +149 -0
- package/dist/schedule.js +428 -0
- package/dist/secret-mask.d.ts +29 -0
- package/dist/secret-mask.js +245 -0
- package/dist/self-register.d.ts +45 -0
- package/dist/self-register.js +67 -0
- package/dist/send.d.ts +56 -0
- package/dist/send.js +104 -0
- package/dist/session-diff.d.ts +161 -0
- package/dist/session-diff.js +445 -0
- package/dist/session-registry.d.ts +143 -0
- package/dist/session-registry.js +216 -0
- package/dist/sessions-report.d.ts +59 -0
- package/dist/sessions-report.js +99 -0
- package/dist/shell-audit.d.ts +34 -0
- package/dist/shell-audit.js +47 -0
- package/dist/subagent-transcripts.d.ts +167 -0
- package/dist/subagent-transcripts.js +437 -0
- package/dist/subscriber-trust.d.ts +128 -0
- package/dist/subscriber-trust.js +397 -0
- package/dist/templates.d.ts +105 -0
- package/dist/templates.js +871 -0
- package/dist/tmux-install.d.ts +44 -0
- package/dist/tmux-install.js +65 -0
- package/dist/tmux-liveness.d.ts +45 -0
- package/dist/tmux-liveness.js +71 -0
- package/dist/tmux-pane.d.ts +55 -0
- package/dist/tmux-pane.js +126 -0
- package/dist/tmux-socket.d.ts +20 -0
- package/dist/tmux-socket.js +292 -0
- package/dist/todo-fold.d.ts +31 -0
- package/dist/todo-fold.js +102 -0
- package/dist/transcript-tail.d.ts +312 -0
- package/dist/transcript-tail.js +718 -0
- package/dist/trust-command.d.ts +49 -0
- package/dist/trust-command.js +200 -0
- package/dist/trust-lock.d.ts +26 -0
- package/dist/trust-lock.js +94 -0
- package/dist/trust-pins.d.ts +37 -0
- package/dist/trust-pins.js +82 -0
- package/dist/turn-recorder-wire.d.ts +70 -0
- package/dist/turn-recorder-wire.js +165 -0
- package/dist/turn-recorder.d.ts +190 -0
- package/dist/turn-recorder.js +695 -0
- package/dist/turn-ring.d.ts +93 -0
- package/dist/turn-ring.js +418 -0
- package/dist/turn-watch.d.ts +177 -0
- package/dist/turn-watch.js +630 -0
- package/dist/types.d.ts +63 -0
- package/dist/types.js +2 -0
- package/dist/uninstall.d.ts +55 -0
- package/dist/uninstall.js +507 -0
- package/dist/usage.d.ts +17 -0
- package/dist/usage.js +421 -0
- package/dist/verify.d.ts +47 -0
- package/dist/verify.js +396 -0
- package/dist/worktree.d.ts +144 -0
- package/dist/worktree.js +310 -0
- package/dist/wrapper.d.ts +254 -0
- package/dist/wrapper.js +704 -0
- package/dist/ws-url.d.ts +109 -0
- package/dist/ws-url.js +202 -0
- package/package.json +78 -10
- package/bin/hwip.js +0 -3
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.serviceHealthChecks = exports.systemdServiceProbe = exports.defaultServiceProbe = exports.restartService = exports.stopService = exports.uninstallService = exports.installService = exports.defaultSystemdOpDeps = exports.defaultServiceOpDeps = exports.renderSystemdUnit = exports.serviceStatus = exports.renderLaunchAgentPlist = exports.resolveServiceSpec = exports.defaultServiceEnv = exports.serviceInstalled = exports.serviceUnitPath = exports.servicePlistPath = exports.serviceSupported = exports.serviceManager = exports.SERVICE_UNIT = exports.SERVICE_LABEL = void 0;
|
|
4
|
+
const node_child_process_1 = require("node:child_process");
|
|
5
|
+
const node_fs_1 = require("node:fs");
|
|
6
|
+
const node_os_1 = require("node:os");
|
|
7
|
+
const node_path_1 = require("node:path");
|
|
8
|
+
const private_files_js_1 = require("./private-files.js");
|
|
9
|
+
const listener_process_js_1 = require("./listener-process.js");
|
|
10
|
+
const node_support_js_1 = require("./node-support.js");
|
|
11
|
+
/**
|
|
12
|
+
* The listener as an OS service: a launchd LaunchAgent on macOS, a systemd user
|
|
13
|
+
* unit on Linux, that starts it at user login so the phone's picker is populated
|
|
14
|
+
* without anyone opening a terminal.
|
|
15
|
+
*
|
|
16
|
+
* Until this existed, the only thing that ever started the daemon was
|
|
17
|
+
* `hwip cc` (wrapper.ts → startListenerDetached). After a reboot that means an
|
|
18
|
+
* empty picker until the user happens to launch an agent — the machine is
|
|
19
|
+
* online, the past sessions are on disk, and the app says "no agents".
|
|
20
|
+
*
|
|
21
|
+
* Two constraints shape this file:
|
|
22
|
+
*
|
|
23
|
+
* 1. **Node builtins only.** wrapper.ts reads `serviceInstalled()` on the
|
|
24
|
+
* `hwip cc` hot path, and the same rule that keeps listener.ts out of
|
|
25
|
+
* wrapper.ts (ws + the crypto stack on every agent launch) applies here.
|
|
26
|
+
* listener-process.ts is the one import, and it is builtins-only too.
|
|
27
|
+
*
|
|
28
|
+
* 2. **Everything the job needs is resolved at install time and baked into
|
|
29
|
+
* the plist.** launchd hands a job `/usr/bin:/bin:/usr/sbin:/sbin` and
|
|
30
|
+
* nothing else — no shell profile, no homebrew prefix. `verifyTmux()`
|
|
31
|
+
* (listener.ts) exits 127 when tmux is not on PATH, so a plist without a
|
|
32
|
+
* baked PATH produces a service that dies at every login and is restarted
|
|
33
|
+
* forever by KeepAlive. The interpreter is baked for the same reason.
|
|
34
|
+
*/
|
|
35
|
+
/** Reverse-DNS launchd label. Lives on the user's machine once installed —
|
|
36
|
+
* renaming it orphans every plist already out there. */
|
|
37
|
+
exports.SERVICE_LABEL = 'app.hwip.listener';
|
|
38
|
+
/** The systemd user unit: the same label, so every message that names
|
|
39
|
+
* SERVICE_LABEL names this unit too. */
|
|
40
|
+
exports.SERVICE_UNIT = `${exports.SERVICE_LABEL}.service`;
|
|
41
|
+
/** What launchd itself puts on PATH. Ours is prepended, never replaces this. */
|
|
42
|
+
const LAUNCHD_BASE_PATH = ['/usr/bin', '/bin', '/usr/sbin', '/sbin'];
|
|
43
|
+
/** Interpreters to try when the CLI's own install prefix has none. */
|
|
44
|
+
const NODE_FALLBACKS = ['/opt/homebrew/bin/node', '/usr/local/bin/node'];
|
|
45
|
+
/**
|
|
46
|
+
* Locale to fall back on when the shell has none, or has a non-UTF-8 one.
|
|
47
|
+
*
|
|
48
|
+
* launchd hands a job no LANG at all. tmux then runs in the C locale and
|
|
49
|
+
* escapes every non-ASCII byte of its format output to `_` — including the
|
|
50
|
+
* U+241F separator the session inventory is split on. The daemon starts
|
|
51
|
+
* clean, tmux answers, and every session is discarded as unparseable: the
|
|
52
|
+
* phone shows nothing while `launchctl list` and the log both look healthy.
|
|
53
|
+
*/
|
|
54
|
+
const FALLBACK_LANG = 'en_US.UTF-8';
|
|
55
|
+
/** tmux locations to try when the shell can't answer `command -v`. */
|
|
56
|
+
const TMUX_FALLBACKS = ['/opt/homebrew/bin/tmux', '/usr/local/bin/tmux', '/usr/bin/tmux'];
|
|
57
|
+
/** Version-manager roots. A node under one of these is gone after the next
|
|
58
|
+
* upgrade, which silently breaks the service months later. */
|
|
59
|
+
const VERSION_MANAGER_DIRS = ['/.nvm/', '/.fnm/', '/.volta/', '/.nodenv/', '/.asdf/', '/.n/'];
|
|
60
|
+
/**
|
|
61
|
+
* Which service manager can run the listener here. macOS always has launchd.
|
|
62
|
+
* Linux has a systemd user manager only when one answers: a container, or a
|
|
63
|
+
* shell without a login session, may have none.
|
|
64
|
+
*/
|
|
65
|
+
const serviceManager = (platform = process.platform,
|
|
66
|
+
// Tests set HWIP_TEST_NO_SERVICE_MANAGER so a detection never reaches the
|
|
67
|
+
// real host — which may itself run a systemd user manager.
|
|
68
|
+
userManagerAnswers = () => process.env.HWIP_TEST_NO_SERVICE_MANAGER !== '1' && runSystemctl(['show-environment']).ok) => {
|
|
69
|
+
if (platform === 'darwin')
|
|
70
|
+
return 'launchd';
|
|
71
|
+
return platform === 'linux' && userManagerAnswers() ? 'systemd' : null;
|
|
72
|
+
};
|
|
73
|
+
exports.serviceManager = serviceManager;
|
|
74
|
+
/** launchd on macOS, a systemd user manager on Linux. Every other platform is
|
|
75
|
+
* an explicit refusal, never a quiet success. */
|
|
76
|
+
const serviceSupported = () => (0, exports.serviceManager)() !== null;
|
|
77
|
+
exports.serviceSupported = serviceSupported;
|
|
78
|
+
const servicePlistPath = () => (0, node_path_1.join)((0, node_os_1.homedir)(), 'Library', 'LaunchAgents', `${exports.SERVICE_LABEL}.plist`);
|
|
79
|
+
exports.servicePlistPath = servicePlistPath;
|
|
80
|
+
/** Where systemd's user manager reads a user's own units: `$XDG_CONFIG_HOME/systemd/user`,
|
|
81
|
+
* else `~/.config/systemd/user` (systemd.unit(5), user unit search path). */
|
|
82
|
+
const serviceUnitPath = () => {
|
|
83
|
+
const config = process.env.XDG_CONFIG_HOME;
|
|
84
|
+
return (0, node_path_1.join)(config && (0, node_path_1.isAbsolute)(config) ? config : (0, node_path_1.join)((0, node_os_1.homedir)(), '.config'), 'systemd', 'user', exports.SERVICE_UNIT);
|
|
85
|
+
};
|
|
86
|
+
exports.serviceUnitPath = serviceUnitPath;
|
|
87
|
+
/** Read on the `hwip cc` hot path, so it goes by platform and checks a file —
|
|
88
|
+
* it never runs a program to ask whether systemd is there. */
|
|
89
|
+
const serviceInstalled = () => (0, node_fs_1.existsSync)(process.platform === 'linux' ? (0, exports.serviceUnitPath)() : (0, exports.servicePlistPath)());
|
|
90
|
+
exports.serviceInstalled = serviceInstalled;
|
|
91
|
+
/** This user's launchd GUI domain, and our job within it. Not exported: every
|
|
92
|
+
* launchctl call lives in this file, and the label is an implementation
|
|
93
|
+
* detail of that. */
|
|
94
|
+
const serviceDomain = () => `gui/${process.getuid?.() ?? 0}`;
|
|
95
|
+
const serviceTarget = () => `${serviceDomain()}/${exports.SERVICE_LABEL}`;
|
|
96
|
+
const nodeVersionOf = (path) => {
|
|
97
|
+
try {
|
|
98
|
+
return (0, node_child_process_1.execFileSync)(path, ['-v'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const whichTmuxFromShell = () => {
|
|
105
|
+
try {
|
|
106
|
+
// `/bin/sh -c` on purpose: `which` in an interactive zsh resolves the
|
|
107
|
+
// user's tmux *alias*, which is not a path we can hand to launchd.
|
|
108
|
+
const out = (0, node_child_process_1.execFileSync)('/bin/sh', ['-c', 'command -v tmux'], {
|
|
109
|
+
encoding: 'utf-8',
|
|
110
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
111
|
+
}).trim();
|
|
112
|
+
return out.startsWith('/') ? out : null;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
exports.defaultServiceEnv = {
|
|
119
|
+
exists: node_fs_1.existsSync,
|
|
120
|
+
nodeVersion: nodeVersionOf,
|
|
121
|
+
whichTmux: whichTmuxFromShell,
|
|
122
|
+
cliPath: listener_process_js_1.resolveCliPath,
|
|
123
|
+
lang: () => process.env.LANG,
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* The interpreter that owns the CLI's install prefix.
|
|
127
|
+
*
|
|
128
|
+
* A global install lives at `<prefix>/lib/node_modules/@hwip/cli/dist/cli.js`
|
|
129
|
+
* and its node is `<prefix>/bin/node`. Preferring it keeps the plist internally
|
|
130
|
+
* consistent; picking whichever node happens to exist can pair a homebrew
|
|
131
|
+
* interpreter with a `~/.local` install — a combination that works on the
|
|
132
|
+
* machine that wrote it and breaks on the next one.
|
|
133
|
+
*/
|
|
134
|
+
const prefixNodeFor = (cliPath) => {
|
|
135
|
+
const marker = '/lib/node_modules/';
|
|
136
|
+
const at = cliPath.indexOf(marker);
|
|
137
|
+
if (at < 0)
|
|
138
|
+
return null;
|
|
139
|
+
return (0, node_path_1.join)(cliPath.slice(0, at), 'bin', 'node');
|
|
140
|
+
};
|
|
141
|
+
const isVersionManaged = (nodePath) => VERSION_MANAGER_DIRS.some((dir) => nodePath.includes(dir));
|
|
142
|
+
const dedupe = (values) => [...new Set(values)];
|
|
143
|
+
/**
|
|
144
|
+
* Decide what to bake into the plist, or say why we can't. Nothing is written
|
|
145
|
+
* and nothing is registered — a caller that gets `ok: false` has touched
|
|
146
|
+
* nothing on the machine.
|
|
147
|
+
*/
|
|
148
|
+
const resolveServiceSpec = (env = exports.defaultServiceEnv) => {
|
|
149
|
+
const cliPath = env.cliPath();
|
|
150
|
+
if (!cliPath) {
|
|
151
|
+
return { ok: false, reason: 'could not resolve the hwip CLI entry (dist/cli.js) to run' };
|
|
152
|
+
}
|
|
153
|
+
const tmuxPath = env.whichTmux() ?? TMUX_FALLBACKS.find(env.exists) ?? null;
|
|
154
|
+
if (!tmuxPath) {
|
|
155
|
+
return { ok: false, reason: 'tmux not found — install tmux first (the listener drives tmux panes)' };
|
|
156
|
+
}
|
|
157
|
+
const prefixNode = prefixNodeFor(cliPath);
|
|
158
|
+
const candidates = dedupe([...(prefixNode ? [prefixNode] : []), ...NODE_FALLBACKS, process.execPath]);
|
|
159
|
+
// A node outside `engines` starts and dies, so it is not a candidate.
|
|
160
|
+
const nodePath = candidates.find((c) => env.exists(c) && (0, node_support_js_1.isSupportedNode)(env.nodeVersion(c) ?? ''));
|
|
161
|
+
if (!nodePath) {
|
|
162
|
+
return { ok: false, reason: `no node found to run the listener with (needs ${node_support_js_1.SUPPORTED_NODE})` };
|
|
163
|
+
}
|
|
164
|
+
const pathEnv = dedupe([(0, node_path_1.dirname)(tmuxPath), (0, node_path_1.dirname)(nodePath), ...LAUNCHD_BASE_PATH]).join(':');
|
|
165
|
+
const shellLang = env.lang();
|
|
166
|
+
const lang = shellLang && /utf-?8$/i.test(shellLang) ? shellLang : FALLBACK_LANG;
|
|
167
|
+
const spec = { nodePath, cliPath, tmuxPath, logPath: listener_process_js_1.LISTENER_LOG_FILE, pathEnv, lang };
|
|
168
|
+
if (isVersionManaged(nodePath)) {
|
|
169
|
+
return {
|
|
170
|
+
ok: true,
|
|
171
|
+
value: spec,
|
|
172
|
+
warning: `${nodePath} belongs to a node version manager — the service will stop working ` +
|
|
173
|
+
`after a node upgrade. Re-run \`hwip listener --install-service\` if that happens ` +
|
|
174
|
+
`(\`hwip verify\` reports it).`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
return { ok: true, value: spec };
|
|
178
|
+
};
|
|
179
|
+
exports.resolveServiceSpec = resolveServiceSpec;
|
|
180
|
+
const escapeXml = (value) => value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
181
|
+
const renderLaunchAgentPlist = (spec) => {
|
|
182
|
+
const s = (value) => `<string>${escapeXml(value)}</string>`;
|
|
183
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
184
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
185
|
+
<plist version="1.0">
|
|
186
|
+
<dict>
|
|
187
|
+
<key>Label</key>
|
|
188
|
+
${s(exports.SERVICE_LABEL)}
|
|
189
|
+
<key>ProgramArguments</key>
|
|
190
|
+
<array>
|
|
191
|
+
${s(spec.nodePath)}
|
|
192
|
+
${s(spec.cliPath)}
|
|
193
|
+
<string>listener</string>
|
|
194
|
+
</array>
|
|
195
|
+
<key>RunAtLoad</key>
|
|
196
|
+
<true/>
|
|
197
|
+
<key>KeepAlive</key>
|
|
198
|
+
<dict>
|
|
199
|
+
<key>SuccessfulExit</key>
|
|
200
|
+
<false/>
|
|
201
|
+
</dict>
|
|
202
|
+
<key>ThrottleInterval</key>
|
|
203
|
+
<integer>30</integer>
|
|
204
|
+
<key>StandardOutPath</key>
|
|
205
|
+
${s(spec.logPath)}
|
|
206
|
+
<key>StandardErrorPath</key>
|
|
207
|
+
${s(spec.logPath)}
|
|
208
|
+
<key>EnvironmentVariables</key>
|
|
209
|
+
<dict>
|
|
210
|
+
<key>PATH</key>
|
|
211
|
+
${s(spec.pathEnv)}
|
|
212
|
+
<key>LANG</key>
|
|
213
|
+
${s(spec.lang)}
|
|
214
|
+
<key>HWIP_LISTENER_SERVICE</key>
|
|
215
|
+
<string>1</string>
|
|
216
|
+
</dict>
|
|
217
|
+
</dict>
|
|
218
|
+
</plist>
|
|
219
|
+
`;
|
|
220
|
+
};
|
|
221
|
+
exports.renderLaunchAgentPlist = renderLaunchAgentPlist;
|
|
222
|
+
const unescapeXml = (value) => value.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
|
223
|
+
/** ProgramArguments strings, in order, from an installed plist. */
|
|
224
|
+
const readProgramArguments = (xml) => {
|
|
225
|
+
const block = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(xml);
|
|
226
|
+
if (!block)
|
|
227
|
+
return [];
|
|
228
|
+
return [...block[1].matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => unescapeXml(m[1]));
|
|
229
|
+
};
|
|
230
|
+
const readEnvValue = (xml, key) => {
|
|
231
|
+
const found = new RegExp(`<key>${key}</key>\\s*<string>([\\s\\S]*?)</string>`).exec(xml);
|
|
232
|
+
return found ? unescapeXml(found[1]) : null;
|
|
233
|
+
};
|
|
234
|
+
const NOT_INSTALLED = {
|
|
235
|
+
installed: false,
|
|
236
|
+
nodePath: null,
|
|
237
|
+
cliPath: null,
|
|
238
|
+
pathEnv: null,
|
|
239
|
+
langEnv: null,
|
|
240
|
+
missing: [],
|
|
241
|
+
};
|
|
242
|
+
/**
|
|
243
|
+
* What is registered on this machine and whether it can still run. The single
|
|
244
|
+
* source `verify`, `--service-status` and the `hwip cc` branch all read, so
|
|
245
|
+
* they can never disagree about whether a service is installed.
|
|
246
|
+
*/
|
|
247
|
+
const serviceStatus = (manager = (0, exports.serviceManager)()) => {
|
|
248
|
+
// A unit file on disk outvotes a user bus that would not answer (a shell
|
|
249
|
+
// with no login session): installed, only unmanageable from here — never
|
|
250
|
+
// "macOS-only".
|
|
251
|
+
if (manager === null && (0, node_fs_1.existsSync)((0, exports.serviceUnitPath)()))
|
|
252
|
+
manager = 'systemd';
|
|
253
|
+
if (manager === 'systemd')
|
|
254
|
+
return unitStatus();
|
|
255
|
+
const plistPath = (0, exports.servicePlistPath)();
|
|
256
|
+
const base = { manager: 'launchd', supported: manager === 'launchd', label: exports.SERVICE_LABEL, plistPath };
|
|
257
|
+
if (!(0, node_fs_1.existsSync)(plistPath))
|
|
258
|
+
return { ...base, ...NOT_INSTALLED };
|
|
259
|
+
let xml = '';
|
|
260
|
+
try {
|
|
261
|
+
xml = (0, node_fs_1.readFileSync)(plistPath, 'utf-8');
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// The plist is there but unreadable — installed, nothing knowable.
|
|
265
|
+
return { ...base, ...NOT_INSTALLED, installed: true };
|
|
266
|
+
}
|
|
267
|
+
const [nodePath = null, cliPath = null] = readProgramArguments(xml);
|
|
268
|
+
const missing = [nodePath, cliPath].filter((p) => !!p && !(0, node_fs_1.existsSync)(p));
|
|
269
|
+
return {
|
|
270
|
+
...base,
|
|
271
|
+
installed: true,
|
|
272
|
+
nodePath,
|
|
273
|
+
cliPath,
|
|
274
|
+
pathEnv: readEnvValue(xml, 'PATH'),
|
|
275
|
+
langEnv: readEnvValue(xml, 'LANG'),
|
|
276
|
+
missing,
|
|
277
|
+
};
|
|
278
|
+
};
|
|
279
|
+
exports.serviceStatus = serviceStatus;
|
|
280
|
+
// ─── systemd unit ────────────────────────────────────────────────────
|
|
281
|
+
/** A quoted unit value: systemd’s C-style escapes (systemd.syntax(7), Quoting),
|
|
282
|
+
* and `%` doubled so no specifier expands (systemd.unit(5), Specifiers).
|
|
283
|
+
* Control characters are stripped first — a newline would start another unit
|
|
284
|
+
* line, and nothing holding one belongs in a value. */
|
|
285
|
+
const quoteUnit = (value) => `"${value.replace(/[\u0000-\u001f\u007f]/g, '').replace(/[\\"]/g, '\\$&').replace(/%/g, '%%')}"`;
|
|
286
|
+
/** A bare unit word (a log path after `append:`): specifiers doubled and
|
|
287
|
+
* control characters stripped, like quoteUnit. */
|
|
288
|
+
const plainUnitValue = (value) => value.replace(/[\u0000-\u001f\u007f]/g, '').replace(/%/g, '%%');
|
|
289
|
+
/** An ExecStart= word: `$` doubled too, which ExecStart= would otherwise expand
|
|
290
|
+
* as a variable (systemd.service(5)). */
|
|
291
|
+
const execWord = (value) => quoteUnit(value.replace(/\$/g, () => '$$'));
|
|
292
|
+
const unquoteUnit = (quoted) => quoted.replace(/%%/g, '%').replace(/\\(.)/g, '$1');
|
|
293
|
+
/**
|
|
294
|
+
* The unit holds what the plist holds, for the same reasons (see the top of
|
|
295
|
+
* this file): absolute node and CLI paths, a PATH that reaches tmux — the user
|
|
296
|
+
* manager's own PATH is whatever the distribution configured (systemd.exec(5),
|
|
297
|
+
* $PATH) — and a UTF-8 LANG.
|
|
298
|
+
*
|
|
299
|
+
* - `Restart=on-failure` + `RestartSec=30` is `KeepAlive:{SuccessfulExit:false}`
|
|
300
|
+
* + `ThrottleInterval 30`: a crash comes back, a clean exit stays down
|
|
301
|
+
* (systemd.service(5), Restart=).
|
|
302
|
+
* - `KillMode=process`: stop and restart kill the main process only. The
|
|
303
|
+
* default (`control-group`) would kill everything else left in the unit's
|
|
304
|
+
* cgroup — including the tmux server the listener started under this unit,
|
|
305
|
+
* and with it every agent session (launchd's detach spares it).
|
|
306
|
+
* systemd.kill(5) marks process "not recommended!", since it "allows
|
|
307
|
+
* processes to escape the service manager's lifecycle and resource
|
|
308
|
+
* management" — that escape is the point here: the tmux server is shared
|
|
309
|
+
* state, not a child of this service.
|
|
310
|
+
* - `append:` keeps the daemon writing ~/.hwip/listener.log, which `hwip verify`
|
|
311
|
+
* and the log rotation know, instead of the journal (systemd.exec(5)).
|
|
312
|
+
* - `WantedBy=default.target`: the user manager's main target, started with it
|
|
313
|
+
* at login (systemd.special(7), Special User Units).
|
|
314
|
+
*/
|
|
315
|
+
const renderSystemdUnit = (spec) => `[Unit]
|
|
316
|
+
Description=hwip listener (phone input to agent sessions)
|
|
317
|
+
|
|
318
|
+
[Service]
|
|
319
|
+
ExecStart=${execWord(spec.nodePath)} ${execWord(spec.cliPath)} listener
|
|
320
|
+
Restart=on-failure
|
|
321
|
+
RestartSec=30
|
|
322
|
+
KillMode=process
|
|
323
|
+
Environment=${quoteUnit(`PATH=${spec.pathEnv}`)}
|
|
324
|
+
Environment=${quoteUnit(`LANG=${spec.lang}`)}
|
|
325
|
+
Environment=HWIP_LISTENER_SERVICE=1
|
|
326
|
+
StandardOutput=append:${plainUnitValue(spec.logPath)}
|
|
327
|
+
StandardError=append:${plainUnitValue(spec.logPath)}
|
|
328
|
+
|
|
329
|
+
[Install]
|
|
330
|
+
WantedBy=default.target
|
|
331
|
+
`;
|
|
332
|
+
exports.renderSystemdUnit = renderSystemdUnit;
|
|
333
|
+
/** ExecStart= words, in order, from an installed unit. */
|
|
334
|
+
const readExecStart = (unit) => {
|
|
335
|
+
const line = /^ExecStart=(.*)$/m.exec(unit);
|
|
336
|
+
if (!line)
|
|
337
|
+
return [];
|
|
338
|
+
return [...line[1].matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => unquoteUnit(m[1]).replace(/\$\$/g, () => '$'));
|
|
339
|
+
};
|
|
340
|
+
const readUnitEnv = (unit, name) => {
|
|
341
|
+
const found = new RegExp(`^Environment="${name}=((?:[^"\\\\]|\\\\.)*)"$`, 'm').exec(unit);
|
|
342
|
+
return found ? unquoteUnit(found[1]) : null;
|
|
343
|
+
};
|
|
344
|
+
const unitStatus = () => {
|
|
345
|
+
const unitPath = (0, exports.serviceUnitPath)();
|
|
346
|
+
const base = { manager: 'systemd', supported: true, label: exports.SERVICE_UNIT, plistPath: unitPath };
|
|
347
|
+
if (!(0, node_fs_1.existsSync)(unitPath))
|
|
348
|
+
return { ...base, ...NOT_INSTALLED };
|
|
349
|
+
let unit = '';
|
|
350
|
+
try {
|
|
351
|
+
unit = (0, node_fs_1.readFileSync)(unitPath, 'utf-8');
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
return { ...base, ...NOT_INSTALLED, installed: true };
|
|
355
|
+
}
|
|
356
|
+
const [nodePath = null, cliPath = null] = readExecStart(unit);
|
|
357
|
+
const missing = [nodePath, cliPath].filter((p) => !!p && !(0, node_fs_1.existsSync)(p));
|
|
358
|
+
return {
|
|
359
|
+
...base,
|
|
360
|
+
installed: true,
|
|
361
|
+
nodePath,
|
|
362
|
+
cliPath,
|
|
363
|
+
pathEnv: readUnitEnv(unit, 'PATH'),
|
|
364
|
+
langEnv: readUnitEnv(unit, 'LANG'),
|
|
365
|
+
missing,
|
|
366
|
+
};
|
|
367
|
+
};
|
|
368
|
+
/** How long to give launchd to get the daemon up before calling the install
|
|
369
|
+
* a failure. Generous: the daemon writes its pid file as its first act. */
|
|
370
|
+
const POST_INSTALL_SETTLE_MS = 3_000;
|
|
371
|
+
/** How often to look within that window. A fixed 3 s wait made every
|
|
372
|
+
* `hwip install` 3 s slower; the daemon is usually up in well under one. */
|
|
373
|
+
const POST_INSTALL_POLL_MS = 250;
|
|
374
|
+
/** How long one pid must stay up to count as started. */
|
|
375
|
+
const POST_INSTALL_STABLE_MS = 1_000;
|
|
376
|
+
/** A wedged user manager must not hang the CLI: every systemctl call — the
|
|
377
|
+
* systemd detection included — gets five seconds. launchctl stays unbounded,
|
|
378
|
+
* exactly as on main: execFileSync kills a timed-out child with SIGTERM and
|
|
379
|
+
* empty stderr, so capping launchctl would turn a macOS install into a
|
|
380
|
+
* silent "failed: no detail" where it used to wait (and usually succeed). */
|
|
381
|
+
const SYSTEMCTL_TIMEOUT_MS = 5_000;
|
|
382
|
+
const runQuiet = (program, args, timeoutMs) => {
|
|
383
|
+
try {
|
|
384
|
+
(0, node_child_process_1.execFileSync)(program, args, { stdio: ['ignore', 'ignore', 'pipe'], ...(timeoutMs === undefined ? {} : { timeout: timeoutMs }) });
|
|
385
|
+
return { ok: true, stderr: '' };
|
|
386
|
+
}
|
|
387
|
+
catch (err) {
|
|
388
|
+
const stderr = err.stderr?.toString().trim() ?? '';
|
|
389
|
+
if (timeoutMs !== undefined && err.signal === 'SIGTERM') {
|
|
390
|
+
const why = 'timed out after ' + timeoutMs / 1000 + 's';
|
|
391
|
+
return { ok: false, stderr: stderr ? stderr + ' (' + why + ')' : why };
|
|
392
|
+
}
|
|
393
|
+
return { ok: false, stderr };
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
const runLaunchctl = (args) => runQuiet('launchctl', args);
|
|
397
|
+
const runSystemctl = (args) => runQuiet('systemctl', ['--user', ...args], SYSTEMCTL_TIMEOUT_MS);
|
|
398
|
+
exports.defaultServiceOpDeps = {
|
|
399
|
+
supported: exports.serviceSupported,
|
|
400
|
+
resolveSpec: () => (0, exports.resolveServiceSpec)(),
|
|
401
|
+
runningPid: listener_process_js_1.runningListenerPid,
|
|
402
|
+
stopListener: (pid) => (0, listener_process_js_1.stopListener)(pid),
|
|
403
|
+
launchctl: runLaunchctl,
|
|
404
|
+
settle: listener_process_js_1.sleep,
|
|
405
|
+
};
|
|
406
|
+
exports.defaultSystemdOpDeps = {
|
|
407
|
+
supported: exports.serviceSupported,
|
|
408
|
+
resolveSpec: () => (0, exports.resolveServiceSpec)(),
|
|
409
|
+
runningPid: listener_process_js_1.runningListenerPid,
|
|
410
|
+
stopListener: (pid) => (0, listener_process_js_1.stopListener)(pid),
|
|
411
|
+
systemctl: runSystemctl,
|
|
412
|
+
settle: listener_process_js_1.sleep,
|
|
413
|
+
};
|
|
414
|
+
const isSystemd = (deps) => 'systemctl' in deps;
|
|
415
|
+
/** Linux asks systemd, everything else launchd (whose `supported` refuses where there is none).
|
|
416
|
+
* Goes by platform alone: `hwip cc` reaches restartService through this. */
|
|
417
|
+
const defaultOpDeps = () => (process.platform === 'linux' ? exports.defaultSystemdOpDeps : exports.defaultServiceOpDeps);
|
|
418
|
+
const UNSUPPORTED = 'the login-time service needs launchd (macOS) or a systemd user manager (Linux), and this machine has neither ' +
|
|
419
|
+
'(run `hwip listener` yourself, or keep using `hwip cc` to autostart it)';
|
|
420
|
+
/** Lingering is a system setting, so this only says how (loginctl(1), enable-linger: a lingering user's
|
|
421
|
+
* manager is spawned at boot and kept around after logouts). */
|
|
422
|
+
const LINGER_NOTE = 'systemd runs it while you are logged in: without lingering your user manager starts at login and stops after '
|
|
423
|
+
+ 'your last logout. On a machine nobody logs in to, keep it running with: sudo loginctl enable-linger $USER';
|
|
424
|
+
/** What the install says about a listener it found already running. */
|
|
425
|
+
const stopRunning = async (deps, notes, manager) => {
|
|
426
|
+
const running = deps.runningPid();
|
|
427
|
+
if (running === null)
|
|
428
|
+
return;
|
|
429
|
+
const stopped = await deps.stopListener(running);
|
|
430
|
+
if (stopped.kind === 'stopped' || stopped.kind === 'killed') {
|
|
431
|
+
notes.push(`stopped the listener already running (pid ${running}) so ${manager} can own it`);
|
|
432
|
+
}
|
|
433
|
+
else if (stopped.kind === 'other-listener') {
|
|
434
|
+
notes.push((0, listener_process_js_1.otherListenerNote)(running, stopped.script));
|
|
435
|
+
}
|
|
436
|
+
else if (stopped.kind === 'foreign') {
|
|
437
|
+
notes.push(`left pid ${running} alone: the listener's pid file points at another process (${stopped.program})`);
|
|
438
|
+
}
|
|
439
|
+
else if (stopped.kind === 'unchecked') {
|
|
440
|
+
notes.push(`could not check pid ${running} (${stopped.reason}) — if it is the listener, kill ${running} and run: hwip listener --restart`);
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
/** launchd creates the log it is pointed at under the umask, and systemd's `append:` opens it the same way:
|
|
444
|
+
* make it before, and private. */
|
|
445
|
+
const prepareLog = () => {
|
|
446
|
+
(0, private_files_js_1.ensurePrivateDir)((0, node_path_1.dirname)(listener_process_js_1.LISTENER_LOG_FILE));
|
|
447
|
+
(0, private_files_js_1.ensurePrivateFile)(listener_process_js_1.LISTENER_LOG_FILE);
|
|
448
|
+
};
|
|
449
|
+
const writeServiceFile = (path, content, notes) => {
|
|
450
|
+
(0, private_files_js_1.mkdirHandedBack)((0, node_path_1.dirname)(path));
|
|
451
|
+
(0, node_fs_1.writeFileSync)(path, content);
|
|
452
|
+
(0, private_files_js_1.handBackToSudoUser)(path);
|
|
453
|
+
notes.push(`wrote ${path}`);
|
|
454
|
+
};
|
|
455
|
+
/** A failure naming the step the service manager refused, or null. */
|
|
456
|
+
const registerLaunchAgent = (deps, spec, notes) => {
|
|
457
|
+
// Nothing to unload before a first install — that failure is the normal path.
|
|
458
|
+
deps.launchctl(['bootout', serviceTarget()]);
|
|
459
|
+
prepareLog();
|
|
460
|
+
const plistPath = (0, exports.servicePlistPath)();
|
|
461
|
+
writeServiceFile(plistPath, (0, exports.renderLaunchAgentPlist)(spec), notes);
|
|
462
|
+
const bootstrap = deps.launchctl(['bootstrap', serviceDomain(), plistPath]);
|
|
463
|
+
if (!bootstrap.ok) {
|
|
464
|
+
return { ok: false, reason: `launchctl bootstrap failed: ${bootstrap.stderr || 'no detail'}`, notes };
|
|
465
|
+
}
|
|
466
|
+
// Best-effort: a job disabled by an earlier `launchctl disable` (ours never
|
|
467
|
+
// calls it, but a user's hand might have) would otherwise refuse to start.
|
|
468
|
+
deps.launchctl(['enable', serviceTarget()]);
|
|
469
|
+
const kickstart = deps.launchctl(['kickstart', '-k', serviceTarget()]);
|
|
470
|
+
if (!kickstart.ok) {
|
|
471
|
+
return { ok: false, reason: `launchctl kickstart failed: ${kickstart.stderr || 'no detail'}`, notes };
|
|
472
|
+
}
|
|
473
|
+
return null;
|
|
474
|
+
};
|
|
475
|
+
/**
|
|
476
|
+
* The systemd steps, each of which must succeed:
|
|
477
|
+
* - `daemon-reload`: the manager reads unit files on a reload, so a reinstall over
|
|
478
|
+
* a loaded unit would otherwise run the old one (systemd.unit(5)).
|
|
479
|
+
* - `enable`: links the unit into `default.target.wants/` per its [Install]
|
|
480
|
+
* section — what starts it at login (systemctl(1), enable).
|
|
481
|
+
* - `restart`: stops it if running, then starts it — `kickstart -k` (systemctl(1)).
|
|
482
|
+
*/
|
|
483
|
+
const registerUnit = (deps, spec, notes) => {
|
|
484
|
+
prepareLog();
|
|
485
|
+
writeServiceFile((0, exports.serviceUnitPath)(), (0, exports.renderSystemdUnit)(spec), notes);
|
|
486
|
+
for (const args of [['daemon-reload'], ['enable', exports.SERVICE_UNIT], ['restart', exports.SERVICE_UNIT]]) {
|
|
487
|
+
const result = deps.systemctl(args);
|
|
488
|
+
if (!result.ok) {
|
|
489
|
+
return { ok: false, reason: `systemctl --user ${args[0]} failed: ${result.stderr || 'no detail'}`, notes };
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
notes.push(LINGER_NOTE);
|
|
493
|
+
return null;
|
|
494
|
+
};
|
|
495
|
+
/**
|
|
496
|
+
* Register the service and get the daemon running under it.
|
|
497
|
+
*
|
|
498
|
+
* The order is load-bearing, not incidental:
|
|
499
|
+
*
|
|
500
|
+
* - **Any listener already running is stopped first.** `handleListener` exits
|
|
501
|
+
* **0** when it finds another listener alive, and it only does so quietly
|
|
502
|
+
* under HWIP_LISTENER_AUTOSTART, which a service does not have. So a
|
|
503
|
+
* kickstart into a live `hwip cc` daemon produces a clean exit, and
|
|
504
|
+
* `KeepAlive:{SuccessfulExit:false}` (systemd: `Restart=on-failure`) reads a
|
|
505
|
+
* clean exit as "stay down" — for the whole login session. The manager lists
|
|
506
|
+
* the job; nothing runs.
|
|
507
|
+
* - **Nothing is written until the spec resolves.** A refusal leaves the
|
|
508
|
+
* machine exactly as it was.
|
|
509
|
+
* - **The daemon is confirmed alive afterwards.** Without that check the
|
|
510
|
+
* failure above ships silently.
|
|
511
|
+
*
|
|
512
|
+
* The deps say which manager: launchctl for launchd, systemctl for systemd.
|
|
513
|
+
*/
|
|
514
|
+
const installService = async (deps = defaultOpDeps()) => {
|
|
515
|
+
const notes = [];
|
|
516
|
+
if (!deps.supported())
|
|
517
|
+
return { ok: false, reason: UNSUPPORTED, notes };
|
|
518
|
+
const spec = deps.resolveSpec();
|
|
519
|
+
if (!spec.ok)
|
|
520
|
+
return { ok: false, reason: spec.reason, notes };
|
|
521
|
+
if (spec.warning)
|
|
522
|
+
notes.push(spec.warning);
|
|
523
|
+
await stopRunning(deps, notes, isSystemd(deps) ? 'systemd' : 'launchd');
|
|
524
|
+
const refused = isSystemd(deps) ? registerUnit(deps, spec.value, notes) : registerLaunchAgent(deps, spec.value, notes);
|
|
525
|
+
if (refused)
|
|
526
|
+
return refused;
|
|
527
|
+
// Up = the same pid alive for a full second: a daemon that writes its pid
|
|
528
|
+
// file and then dies on a bad key or a bind failure does so within that.
|
|
529
|
+
let last = null;
|
|
530
|
+
let since = 0;
|
|
531
|
+
for (let waited = POST_INSTALL_POLL_MS; waited <= POST_INSTALL_SETTLE_MS; waited += POST_INSTALL_POLL_MS) {
|
|
532
|
+
await deps.settle(POST_INSTALL_POLL_MS);
|
|
533
|
+
const pid = deps.runningPid();
|
|
534
|
+
if (pid === null || pid !== last)
|
|
535
|
+
since = waited;
|
|
536
|
+
else if (waited - since >= POST_INSTALL_STABLE_MS)
|
|
537
|
+
return { ok: true, notes };
|
|
538
|
+
last = pid;
|
|
539
|
+
}
|
|
540
|
+
return {
|
|
541
|
+
ok: false,
|
|
542
|
+
reason: `the service was registered but the listener did not stay up — see ${listener_process_js_1.LISTENER_LOG_FILE}`,
|
|
543
|
+
notes,
|
|
544
|
+
};
|
|
545
|
+
};
|
|
546
|
+
exports.installService = installService;
|
|
547
|
+
/** Unregister the job and remove its file. Doing only one of the two leaves
|
|
548
|
+
* either a running service with no file or a file the manager ignores. */
|
|
549
|
+
const uninstallService = async (deps = defaultOpDeps()) => {
|
|
550
|
+
const notes = [];
|
|
551
|
+
const path = isSystemd(deps) ? (0, exports.serviceUnitPath)() : (0, exports.servicePlistPath)();
|
|
552
|
+
if (!(0, node_fs_1.existsSync)(path))
|
|
553
|
+
return { ok: true, notes: ['service not installed — nothing to remove'] };
|
|
554
|
+
if (isSystemd(deps)) {
|
|
555
|
+
// `--now` stops it as well as removing the `default.target.wants/` link (systemctl(1), disable).
|
|
556
|
+
const disable = deps.systemctl(['disable', '--now', exports.SERVICE_UNIT]);
|
|
557
|
+
if (disable.ok) {
|
|
558
|
+
notes.push(`stopped and disabled ${exports.SERVICE_UNIT}`);
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
// The manager refused; the unit file is gone right after this, so
|
|
562
|
+
// a left-behind wants/ link would dangle into every login.
|
|
563
|
+
const wants = (0, node_path_1.join)((0, node_path_1.dirname)(path), 'default.target.wants', exports.SERVICE_UNIT);
|
|
564
|
+
const hadLink = (0, node_fs_1.existsSync)(wants);
|
|
565
|
+
if (hadLink)
|
|
566
|
+
(0, node_fs_1.rmSync)(wants, { force: true });
|
|
567
|
+
notes.push(`systemctl --user disable failed (${disable.stderr || 'no detail'})`
|
|
568
|
+
+ (hadLink ? ' — removed the default.target.wants/ link directly' : '')
|
|
569
|
+
+ `; whether the unit stopped is unknown — if the listener is still up,`
|
|
570
|
+
+ ` check with: systemctl --user status ${exports.SERVICE_UNIT}`);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
else {
|
|
574
|
+
deps.launchctl(['bootout', serviceTarget()]);
|
|
575
|
+
notes.push(`unloaded ${exports.SERVICE_LABEL}`);
|
|
576
|
+
}
|
|
577
|
+
(0, node_fs_1.rmSync)(path, { force: true });
|
|
578
|
+
notes.push(`removed ${path}`);
|
|
579
|
+
if (isSystemd(deps))
|
|
580
|
+
deps.systemctl(['daemon-reload']);
|
|
581
|
+
return { ok: true, notes };
|
|
582
|
+
};
|
|
583
|
+
exports.uninstallService = uninstallService;
|
|
584
|
+
/** One service-manager call reported as a ServiceOpResult, naming the
|
|
585
|
+
* subcommand in the failure so the user sees which step was refused. */
|
|
586
|
+
const ask = (deps, args, note) => {
|
|
587
|
+
const result = isSystemd(deps) ? deps.systemctl(args) : deps.launchctl(args);
|
|
588
|
+
if (result.ok)
|
|
589
|
+
return { ok: true, notes: [note] };
|
|
590
|
+
const program = isSystemd(deps) ? 'systemctl --user' : 'launchctl';
|
|
591
|
+
return { ok: false, reason: `${program} ${args[0]} failed: ${result.stderr || 'no detail'}`, notes: [] };
|
|
592
|
+
};
|
|
593
|
+
/**
|
|
594
|
+
* Stop the service until the next login.
|
|
595
|
+
*
|
|
596
|
+
* launchd: `bootout` and not `disable` — disable writes to launchd's persistent
|
|
597
|
+
* disabled database, survives logins, and leaves no obvious way back: a
|
|
598
|
+
* `--stop` that quietly becomes permanent. Session-scoped is what stopping
|
|
599
|
+
* should mean. systemd: `stop` leaves the unit enabled, so the user manager
|
|
600
|
+
* starts it again the next time it starts itself.
|
|
601
|
+
*/
|
|
602
|
+
const stopService = (deps = defaultOpDeps()) => isSystemd(deps)
|
|
603
|
+
? ask(deps, ['stop', exports.SERVICE_UNIT], `stopped ${exports.SERVICE_UNIT} — it starts again with hwip listener --restart, or at your next login after a full logout`)
|
|
604
|
+
: ask(deps, ['bootout', serviceTarget()], `unloaded ${exports.SERVICE_LABEL} — it comes back at the next login`);
|
|
605
|
+
exports.stopService = stopService;
|
|
606
|
+
/** Restart the service in place. Both kill the running instance first, so this
|
|
607
|
+
* is also how a version-drifted daemon is replaced. */
|
|
608
|
+
const restartService = (deps = defaultOpDeps()) => isSystemd(deps)
|
|
609
|
+
? ask(deps, ['restart', exports.SERVICE_UNIT], `restarted ${exports.SERVICE_UNIT}`)
|
|
610
|
+
: ask(deps, ['kickstart', '-k', serviceTarget()], `restarted ${exports.SERVICE_LABEL}`);
|
|
611
|
+
exports.restartService = restartService;
|
|
612
|
+
const pathHasTmux = (pathEnv) => pathEnv.split(':').filter(Boolean).some((dir) => (0, node_fs_1.existsSync)((0, node_path_1.join)(dir, 'tmux')));
|
|
613
|
+
const jobIsLoaded = () => runLaunchctl(['print', serviceTarget()]).ok;
|
|
614
|
+
exports.defaultServiceProbe = { tmuxOnPath: pathHasTmux, loaded: jobIsLoaded };
|
|
615
|
+
/** `is-active` exits 0 when the unit is running (systemctl(1)). */
|
|
616
|
+
exports.systemdServiceProbe = {
|
|
617
|
+
tmuxOnPath: pathHasTmux,
|
|
618
|
+
loaded: () => runSystemctl(['is-active', '--quiet', exports.SERVICE_UNIT]).ok,
|
|
619
|
+
};
|
|
620
|
+
/**
|
|
621
|
+
* Why this exists: every way the service breaks leaves it looking installed.
|
|
622
|
+
* `launchctl list` shows the job whether or not its interpreter still exists,
|
|
623
|
+
* and a PATH without tmux makes the daemon exit 127 at every login while the
|
|
624
|
+
* registration sits there unchanged. None of that is visible without asking.
|
|
625
|
+
*/
|
|
626
|
+
const serviceHealthChecks = (status, probe = status.manager === 'systemd' ? exports.systemdServiceProbe : exports.defaultServiceProbe) => {
|
|
627
|
+
if (!status.supported)
|
|
628
|
+
return [];
|
|
629
|
+
if (!status.installed) {
|
|
630
|
+
return [
|
|
631
|
+
{
|
|
632
|
+
label: 'no login-time service — after a reboot the phone sees nothing until you run `hwip cc` '
|
|
633
|
+
+ '(add it: hwip listener --install-service)',
|
|
634
|
+
state: 'warn',
|
|
635
|
+
},
|
|
636
|
+
];
|
|
637
|
+
}
|
|
638
|
+
const rows = [{ label: `${status.label} registered`, state: 'pass' }];
|
|
639
|
+
if (status.missing.length > 0) {
|
|
640
|
+
rows.push({
|
|
641
|
+
label: `service points at ${status.missing.join(', ')} — gone. Re-run: hwip listener --install-service`,
|
|
642
|
+
state: 'fail',
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
else {
|
|
646
|
+
rows.push({ label: 'service programs still exist', state: 'pass' });
|
|
647
|
+
}
|
|
648
|
+
// launchd hands the job its own bare PATH, so tmux has to come from what
|
|
649
|
+
// the plist baked in. Without it verifyTmux() exits 127 at every login.
|
|
650
|
+
if (status.pathEnv && probe.tmuxOnPath(status.pathEnv)) {
|
|
651
|
+
rows.push({ label: 'tmux reachable from the service PATH', state: 'pass' });
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
rows.push({
|
|
655
|
+
label: 'tmux NOT on the service PATH — the daemon exits 127 at login. '
|
|
656
|
+
+ 'Re-run: hwip listener --install-service',
|
|
657
|
+
state: 'fail',
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
// Without a UTF-8 LANG, tmux escapes the U+241F field separator to `_` and
|
|
661
|
+
// every session is dropped as unparseable — with nothing in the log that
|
|
662
|
+
// says so except a raw line the user has no reason to read as fatal.
|
|
663
|
+
if (status.langEnv && /utf-?8$/i.test(status.langEnv)) {
|
|
664
|
+
rows.push({ label: 'service locale is UTF-8', state: 'pass' });
|
|
665
|
+
}
|
|
666
|
+
else {
|
|
667
|
+
rows.push({
|
|
668
|
+
label: 'service LANG is not UTF-8 — tmux mangles the session separator and every session is dropped. '
|
|
669
|
+
+ 'Re-run: hwip listener --install-service',
|
|
670
|
+
state: 'fail',
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
rows.push(status.manager === 'systemd'
|
|
674
|
+
? probe.loaded()
|
|
675
|
+
? { label: 'systemd is running the unit', state: 'pass' }
|
|
676
|
+
: { label: 'registered but systemd is not running it — start it: hwip listener --restart', state: 'warn' }
|
|
677
|
+
: probe.loaded()
|
|
678
|
+
? { label: 'launchd is running the job', state: 'pass' }
|
|
679
|
+
: { label: 'registered but launchd is not running it — it returns at the next login', state: 'warn' });
|
|
680
|
+
return rows;
|
|
681
|
+
};
|
|
682
|
+
exports.serviceHealthChecks = serviceHealthChecks;
|