@phnx-labs/agents-cli 1.20.21 → 1.20.23
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/CHANGELOG.md +14 -0
- package/dist/commands/cloud.js +142 -13
- package/dist/commands/exec.js +13 -1
- package/dist/commands/menubar.d.ts +10 -0
- package/dist/commands/menubar.js +83 -0
- package/dist/commands/routines.js +34 -1
- package/dist/commands/secrets.d.ts +1 -1
- package/dist/commands/secrets.js +95 -38
- package/dist/index.js +292 -225
- package/dist/lib/agents.js +8 -0
- package/dist/lib/cloud/antigravity.d.ts +70 -0
- package/dist/lib/cloud/antigravity.js +196 -0
- package/dist/lib/cloud/codex.d.ts +1 -0
- package/dist/lib/cloud/codex.js +8 -2
- package/dist/lib/cloud/factory.d.ts +79 -18
- package/dist/lib/cloud/factory.js +324 -26
- package/dist/lib/cloud/registry.d.ts +18 -2
- package/dist/lib/cloud/registry.js +28 -4
- package/dist/lib/cloud/types.d.ts +73 -2
- package/dist/lib/cloud/types.js +17 -0
- package/dist/lib/exec.d.ts +2 -0
- package/dist/lib/exec.js +5 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +20 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +115 -0
- package/dist/lib/menubar/install-menubar.d.ts +57 -0
- package/dist/lib/menubar/install-menubar.js +291 -0
- package/dist/lib/secrets/agent.d.ts +9 -1
- package/dist/lib/secrets/agent.js +91 -10
- package/dist/lib/secrets/bundles.d.ts +19 -12
- package/dist/lib/secrets/bundles.js +22 -14
- package/dist/lib/self-update.d.ts +34 -0
- package/dist/lib/self-update.js +63 -2
- package/dist/lib/startup/command-registry.d.ts +99 -0
- package/dist/lib/startup/command-registry.js +136 -0
- package/dist/lib/types.d.ts +8 -0
- package/dist/lib/version.d.ts +11 -0
- package/dist/lib/version.js +20 -0
- package/package.json +5 -3
- package/scripts/postinstall.js +35 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>files</key>
|
|
6
|
+
<dict/>
|
|
7
|
+
<key>files2</key>
|
|
8
|
+
<dict/>
|
|
9
|
+
<key>rules</key>
|
|
10
|
+
<dict>
|
|
11
|
+
<key>^Resources/</key>
|
|
12
|
+
<true/>
|
|
13
|
+
<key>^Resources/.*\.lproj/</key>
|
|
14
|
+
<dict>
|
|
15
|
+
<key>optional</key>
|
|
16
|
+
<true/>
|
|
17
|
+
<key>weight</key>
|
|
18
|
+
<real>1000</real>
|
|
19
|
+
</dict>
|
|
20
|
+
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
|
21
|
+
<dict>
|
|
22
|
+
<key>omit</key>
|
|
23
|
+
<true/>
|
|
24
|
+
<key>weight</key>
|
|
25
|
+
<real>1100</real>
|
|
26
|
+
</dict>
|
|
27
|
+
<key>^Resources/Base\.lproj/</key>
|
|
28
|
+
<dict>
|
|
29
|
+
<key>weight</key>
|
|
30
|
+
<real>1010</real>
|
|
31
|
+
</dict>
|
|
32
|
+
<key>^version.plist$</key>
|
|
33
|
+
<true/>
|
|
34
|
+
</dict>
|
|
35
|
+
<key>rules2</key>
|
|
36
|
+
<dict>
|
|
37
|
+
<key>.*\.dSYM($|/)</key>
|
|
38
|
+
<dict>
|
|
39
|
+
<key>weight</key>
|
|
40
|
+
<real>11</real>
|
|
41
|
+
</dict>
|
|
42
|
+
<key>^(.*/)?\.DS_Store$</key>
|
|
43
|
+
<dict>
|
|
44
|
+
<key>omit</key>
|
|
45
|
+
<true/>
|
|
46
|
+
<key>weight</key>
|
|
47
|
+
<real>2000</real>
|
|
48
|
+
</dict>
|
|
49
|
+
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
|
|
50
|
+
<dict>
|
|
51
|
+
<key>nested</key>
|
|
52
|
+
<true/>
|
|
53
|
+
<key>weight</key>
|
|
54
|
+
<real>10</real>
|
|
55
|
+
</dict>
|
|
56
|
+
<key>^.*</key>
|
|
57
|
+
<true/>
|
|
58
|
+
<key>^Info\.plist$</key>
|
|
59
|
+
<dict>
|
|
60
|
+
<key>omit</key>
|
|
61
|
+
<true/>
|
|
62
|
+
<key>weight</key>
|
|
63
|
+
<real>20</real>
|
|
64
|
+
</dict>
|
|
65
|
+
<key>^PkgInfo$</key>
|
|
66
|
+
<dict>
|
|
67
|
+
<key>omit</key>
|
|
68
|
+
<true/>
|
|
69
|
+
<key>weight</key>
|
|
70
|
+
<real>20</real>
|
|
71
|
+
</dict>
|
|
72
|
+
<key>^Resources/</key>
|
|
73
|
+
<dict>
|
|
74
|
+
<key>weight</key>
|
|
75
|
+
<real>20</real>
|
|
76
|
+
</dict>
|
|
77
|
+
<key>^Resources/.*\.lproj/</key>
|
|
78
|
+
<dict>
|
|
79
|
+
<key>optional</key>
|
|
80
|
+
<true/>
|
|
81
|
+
<key>weight</key>
|
|
82
|
+
<real>1000</real>
|
|
83
|
+
</dict>
|
|
84
|
+
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
|
85
|
+
<dict>
|
|
86
|
+
<key>omit</key>
|
|
87
|
+
<true/>
|
|
88
|
+
<key>weight</key>
|
|
89
|
+
<real>1100</real>
|
|
90
|
+
</dict>
|
|
91
|
+
<key>^Resources/Base\.lproj/</key>
|
|
92
|
+
<dict>
|
|
93
|
+
<key>weight</key>
|
|
94
|
+
<real>1010</real>
|
|
95
|
+
</dict>
|
|
96
|
+
<key>^[^/]+$</key>
|
|
97
|
+
<dict>
|
|
98
|
+
<key>nested</key>
|
|
99
|
+
<true/>
|
|
100
|
+
<key>weight</key>
|
|
101
|
+
<real>10</real>
|
|
102
|
+
</dict>
|
|
103
|
+
<key>^embedded\.provisionprofile$</key>
|
|
104
|
+
<dict>
|
|
105
|
+
<key>weight</key>
|
|
106
|
+
<real>20</real>
|
|
107
|
+
</dict>
|
|
108
|
+
<key>^version\.plist$</key>
|
|
109
|
+
<dict>
|
|
110
|
+
<key>weight</key>
|
|
111
|
+
<real>20</real>
|
|
112
|
+
</dict>
|
|
113
|
+
</dict>
|
|
114
|
+
</dict>
|
|
115
|
+
</plist>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install + lifecycle for the macOS menu-bar helper (`MenubarHelper.app`).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `src/lib/secrets/install-helper.ts` (stable Application Support path,
|
|
5
|
+
* survives npm re-sign) and the secrets-agent launchd pattern in
|
|
6
|
+
* `src/lib/secrets/agent.ts` (RunAtLoad + KeepAlive user service).
|
|
7
|
+
*
|
|
8
|
+
* The helper is a no-Dock `.accessory` status-bar app. It reads live agent
|
|
9
|
+
* state directly from disk and shells `agents` only for actions, so the plist
|
|
10
|
+
* bakes in the node interpreter + entry point + bin path so the GUI process can
|
|
11
|
+
* find the CLI without a login PATH.
|
|
12
|
+
*
|
|
13
|
+
* Opt-out is sticky: `agents menubar disable` drops a sentinel that the upgrade
|
|
14
|
+
* migration (`installMenubarLaunchAgent` in migrate.ts) honors, so a disabled
|
|
15
|
+
* menu bar never silently comes back on the next release.
|
|
16
|
+
*/
|
|
17
|
+
/** True if the user explicitly disabled the menu bar (don't auto-enable on upgrade). */
|
|
18
|
+
export declare function menubarDisabledByUser(): boolean;
|
|
19
|
+
/** True if the launchd plist for the menu-bar service is installed. */
|
|
20
|
+
export declare function menubarServiceInstalled(): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Copy the bundled `.app` to the stable user path (idempotent unless forced).
|
|
23
|
+
* Returns the installed executable path, or null if no source bundle ships
|
|
24
|
+
* with this install (e.g. Linux package, or a build without the helper).
|
|
25
|
+
*/
|
|
26
|
+
export declare function ensureMenubarAppInstalled(opts?: {
|
|
27
|
+
forceReinstall?: boolean;
|
|
28
|
+
}): string | null;
|
|
29
|
+
/**
|
|
30
|
+
* Install + start the menu-bar helper as a launchd user service (idempotent).
|
|
31
|
+
* Clears the sticky opt-out, installs the .app, writes the plist, and
|
|
32
|
+
* bootstraps it into the GUI domain. Returns false on non-darwin or when no
|
|
33
|
+
* helper bundle ships with this install.
|
|
34
|
+
*/
|
|
35
|
+
export declare function enableMenubarService(opts?: {
|
|
36
|
+
clearOptOut?: boolean;
|
|
37
|
+
}): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Stop + remove the menu-bar service and write the sticky opt-out so the
|
|
40
|
+
* upgrade migration won't re-enable it.
|
|
41
|
+
*/
|
|
42
|
+
export declare function disableMenubarService(): void;
|
|
43
|
+
/**
|
|
44
|
+
* Upgrade-time auto-enable. Runs from runMigration() once per sentinel bump.
|
|
45
|
+
* No-ops if: not darwin, the user opted out, no helper bundle ships, or the
|
|
46
|
+
* service is already installed. Best-effort — never throws into migration.
|
|
47
|
+
*/
|
|
48
|
+
export declare function installMenubarLaunchAgentOnUpgrade(): void;
|
|
49
|
+
export interface MenubarStatus {
|
|
50
|
+
platform: string;
|
|
51
|
+
source: string | null;
|
|
52
|
+
installedApp: string | null;
|
|
53
|
+
serviceInstalled: boolean;
|
|
54
|
+
running: boolean;
|
|
55
|
+
disabledByUser: boolean;
|
|
56
|
+
}
|
|
57
|
+
export declare function getMenubarStatus(): MenubarStatus;
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install + lifecycle for the macOS menu-bar helper (`MenubarHelper.app`).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `src/lib/secrets/install-helper.ts` (stable Application Support path,
|
|
5
|
+
* survives npm re-sign) and the secrets-agent launchd pattern in
|
|
6
|
+
* `src/lib/secrets/agent.ts` (RunAtLoad + KeepAlive user service).
|
|
7
|
+
*
|
|
8
|
+
* The helper is a no-Dock `.accessory` status-bar app. It reads live agent
|
|
9
|
+
* state directly from disk and shells `agents` only for actions, so the plist
|
|
10
|
+
* bakes in the node interpreter + entry point + bin path so the GUI process can
|
|
11
|
+
* find the CLI without a login PATH.
|
|
12
|
+
*
|
|
13
|
+
* Opt-out is sticky: `agents menubar disable` drops a sentinel that the upgrade
|
|
14
|
+
* migration (`installMenubarLaunchAgent` in migrate.ts) honors, so a disabled
|
|
15
|
+
* menu bar never silently comes back on the next release.
|
|
16
|
+
*/
|
|
17
|
+
import { fileURLToPath } from 'url';
|
|
18
|
+
import { execFileSync, spawnSync } from 'child_process';
|
|
19
|
+
import * as fs from 'fs';
|
|
20
|
+
import * as os from 'os';
|
|
21
|
+
import * as path from 'path';
|
|
22
|
+
import { getRuntimeStateDir, getHelpersDir } from '../state.js';
|
|
23
|
+
const APP_BUNDLE_NAME = 'MenubarHelper.app';
|
|
24
|
+
const INSTALL_DIR_NAME = 'agents-cli';
|
|
25
|
+
const SERVICE_LABEL = 'com.phnx-labs.agents-menubar';
|
|
26
|
+
function onDarwin() {
|
|
27
|
+
return process.platform === 'darwin';
|
|
28
|
+
}
|
|
29
|
+
/** ~/Library/Application Support/agents-cli/MenubarHelper.app */
|
|
30
|
+
function installedAppPath() {
|
|
31
|
+
return path.join(os.homedir(), 'Library', 'Application Support', INSTALL_DIR_NAME, APP_BUNDLE_NAME);
|
|
32
|
+
}
|
|
33
|
+
/** Executable inside the installed bundle. */
|
|
34
|
+
function installedExecutablePath() {
|
|
35
|
+
return path.join(installedAppPath(), 'Contents', 'MacOS', 'MenubarHelper');
|
|
36
|
+
}
|
|
37
|
+
/** ~/Library/LaunchAgents/com.phnx-labs.agents-menubar.plist */
|
|
38
|
+
function servicePlistPath() {
|
|
39
|
+
return path.join(os.homedir(), 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`);
|
|
40
|
+
}
|
|
41
|
+
/** Sticky opt-out marker written by `agents menubar disable`. */
|
|
42
|
+
function disabledSentinelPath() {
|
|
43
|
+
return path.join(getRuntimeStateDir(), 'menubar.disabled');
|
|
44
|
+
}
|
|
45
|
+
/** True if the user explicitly disabled the menu bar (don't auto-enable on upgrade). */
|
|
46
|
+
export function menubarDisabledByUser() {
|
|
47
|
+
return fs.existsSync(disabledSentinelPath());
|
|
48
|
+
}
|
|
49
|
+
/** True if the launchd plist for the menu-bar service is installed. */
|
|
50
|
+
export function menubarServiceInstalled() {
|
|
51
|
+
return onDarwin() && fs.existsSync(servicePlistPath());
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Locate the source `.app` shipped alongside the compiled JS.
|
|
55
|
+
* 1. dist/lib/menubar/MenubarHelper.app — npm install layout (sibling of this file)
|
|
56
|
+
* 2. <repo>/bin/MenubarHelper.app — raw working tree (tsx/dev)
|
|
57
|
+
* 3. <repo>/packages/menubar-helper/dist/MenubarHelper.app — fresh local build
|
|
58
|
+
*/
|
|
59
|
+
function sourceAppPath() {
|
|
60
|
+
const candidates = [];
|
|
61
|
+
try {
|
|
62
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
63
|
+
candidates.push(path.join(here, APP_BUNDLE_NAME));
|
|
64
|
+
candidates.push(path.resolve(here, '..', '..', '..', 'bin', APP_BUNDLE_NAME));
|
|
65
|
+
candidates.push(path.resolve(here, '..', '..', '..', 'packages', 'menubar-helper', 'dist', APP_BUNDLE_NAME));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
/* import.meta.url unavailable */
|
|
69
|
+
}
|
|
70
|
+
for (const c of candidates) {
|
|
71
|
+
if (fs.existsSync(c))
|
|
72
|
+
return c;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
/** Resolve the `agents` launcher binary on PATH-less GUI processes. */
|
|
77
|
+
function resolveAgentsBin() {
|
|
78
|
+
const home = os.homedir();
|
|
79
|
+
const candidates = [
|
|
80
|
+
path.join(home, '.local', 'bin', 'agents'),
|
|
81
|
+
'/opt/homebrew/bin/agents',
|
|
82
|
+
'/usr/local/bin/agents',
|
|
83
|
+
path.join(home, '.npm-global', 'bin', 'agents'),
|
|
84
|
+
];
|
|
85
|
+
for (const c of candidates) {
|
|
86
|
+
try {
|
|
87
|
+
fs.accessSync(c, fs.constants.X_OK);
|
|
88
|
+
return c;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
/* try next */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
/** Resolve the compiled CLI entry (dist/index.js) so the helper can exec node directly. */
|
|
97
|
+
function resolveCliEntry() {
|
|
98
|
+
try {
|
|
99
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
100
|
+
// dist/lib/menubar/install-menubar.js -> dist/index.js
|
|
101
|
+
const entry = path.resolve(here, '..', '..', 'index.js');
|
|
102
|
+
if (fs.existsSync(entry))
|
|
103
|
+
return entry;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
/* ignore */
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
function copyAppBundle(src, dest) {
|
|
111
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
112
|
+
if (fs.existsSync(dest))
|
|
113
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
114
|
+
// `cp -R` preserves the bundle's signature and resource forks (see install-helper.ts).
|
|
115
|
+
const r = spawnSync('cp', ['-R', src, dest], { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' });
|
|
116
|
+
if (r.status !== 0) {
|
|
117
|
+
const msg = (r.stderr || r.stdout || '').toString().trim();
|
|
118
|
+
throw new Error(`Failed to copy ${src} -> ${dest}: ${msg || 'unknown error'}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Copy the bundled `.app` to the stable user path (idempotent unless forced).
|
|
123
|
+
* Returns the installed executable path, or null if no source bundle ships
|
|
124
|
+
* with this install (e.g. Linux package, or a build without the helper).
|
|
125
|
+
*/
|
|
126
|
+
export function ensureMenubarAppInstalled(opts = {}) {
|
|
127
|
+
if (!onDarwin())
|
|
128
|
+
return null;
|
|
129
|
+
const src = sourceAppPath();
|
|
130
|
+
if (!src)
|
|
131
|
+
return null;
|
|
132
|
+
const dest = installedAppPath();
|
|
133
|
+
if (!opts.forceReinstall && fs.existsSync(dest))
|
|
134
|
+
return installedExecutablePath();
|
|
135
|
+
copyAppBundle(src, dest);
|
|
136
|
+
return installedExecutablePath();
|
|
137
|
+
}
|
|
138
|
+
function xmlEscape(s) {
|
|
139
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
140
|
+
}
|
|
141
|
+
function generateServicePlist(execPath) {
|
|
142
|
+
const home = os.homedir();
|
|
143
|
+
const logPath = path.join(getHelpersDir(), 'menubar', 'menubar.log');
|
|
144
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
145
|
+
// Bake interpreter + entry + bin so the GUI helper can reach the CLI with no
|
|
146
|
+
// login PATH. AgentsCLI.swift prefers [AGENTS_NODE, AGENTS_ENTRY] when both
|
|
147
|
+
// exist, else falls back to AGENTS_BIN, else probes well-known paths.
|
|
148
|
+
const env = {
|
|
149
|
+
PATH: `/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${path.dirname(process.execPath)}:${home}/.local/bin`,
|
|
150
|
+
};
|
|
151
|
+
const node = process.execPath;
|
|
152
|
+
const entry = resolveCliEntry();
|
|
153
|
+
const bin = resolveAgentsBin();
|
|
154
|
+
if (node && entry) {
|
|
155
|
+
env.AGENTS_NODE = node;
|
|
156
|
+
env.AGENTS_ENTRY = entry;
|
|
157
|
+
}
|
|
158
|
+
if (bin)
|
|
159
|
+
env.AGENTS_BIN = bin;
|
|
160
|
+
const envXml = Object.entries(env)
|
|
161
|
+
.map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v)}</string>`)
|
|
162
|
+
.join('\n');
|
|
163
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
164
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
165
|
+
<plist version="1.0">
|
|
166
|
+
<dict>
|
|
167
|
+
<key>Label</key>
|
|
168
|
+
<string>${SERVICE_LABEL}</string>
|
|
169
|
+
<key>ProgramArguments</key>
|
|
170
|
+
<array>
|
|
171
|
+
<string>${xmlEscape(execPath)}</string>
|
|
172
|
+
</array>
|
|
173
|
+
<key>RunAtLoad</key>
|
|
174
|
+
<true/>
|
|
175
|
+
<key>KeepAlive</key>
|
|
176
|
+
<true/>
|
|
177
|
+
<key>ProcessType</key>
|
|
178
|
+
<string>Interactive</string>
|
|
179
|
+
<key>StandardOutPath</key>
|
|
180
|
+
<string>${xmlEscape(logPath)}</string>
|
|
181
|
+
<key>StandardErrorPath</key>
|
|
182
|
+
<string>${xmlEscape(logPath)}</string>
|
|
183
|
+
<key>EnvironmentVariables</key>
|
|
184
|
+
<dict>
|
|
185
|
+
${envXml}
|
|
186
|
+
</dict>
|
|
187
|
+
</dict>
|
|
188
|
+
</plist>`;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Install + start the menu-bar helper as a launchd user service (idempotent).
|
|
192
|
+
* Clears the sticky opt-out, installs the .app, writes the plist, and
|
|
193
|
+
* bootstraps it into the GUI domain. Returns false on non-darwin or when no
|
|
194
|
+
* helper bundle ships with this install.
|
|
195
|
+
*/
|
|
196
|
+
export function enableMenubarService(opts = { clearOptOut: true }) {
|
|
197
|
+
if (!onDarwin())
|
|
198
|
+
return false;
|
|
199
|
+
const exec = ensureMenubarAppInstalled({ forceReinstall: true });
|
|
200
|
+
if (!exec)
|
|
201
|
+
return false;
|
|
202
|
+
if (opts.clearOptOut) {
|
|
203
|
+
try {
|
|
204
|
+
fs.rmSync(disabledSentinelPath(), { force: true });
|
|
205
|
+
}
|
|
206
|
+
catch { /* already gone */ }
|
|
207
|
+
}
|
|
208
|
+
const plist = servicePlistPath();
|
|
209
|
+
fs.mkdirSync(path.dirname(plist), { recursive: true });
|
|
210
|
+
fs.writeFileSync(plist, generateServicePlist(exec));
|
|
211
|
+
const uid = process.getuid?.() ?? 0;
|
|
212
|
+
try {
|
|
213
|
+
execFileSync('launchctl', ['bootstrap', `gui/${uid}`, plist], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
try {
|
|
217
|
+
execFileSync('launchctl', ['load', '-w', plist], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
218
|
+
}
|
|
219
|
+
catch { /* may already be loaded */ }
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
execFileSync('launchctl', ['kickstart', '-k', `gui/${uid}/${SERVICE_LABEL}`], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
223
|
+
}
|
|
224
|
+
catch { /* best effort */ }
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Stop + remove the menu-bar service and write the sticky opt-out so the
|
|
229
|
+
* upgrade migration won't re-enable it.
|
|
230
|
+
*/
|
|
231
|
+
export function disableMenubarService() {
|
|
232
|
+
if (!onDarwin())
|
|
233
|
+
return;
|
|
234
|
+
const plist = servicePlistPath();
|
|
235
|
+
const uid = process.getuid?.() ?? 0;
|
|
236
|
+
try {
|
|
237
|
+
execFileSync('launchctl', ['bootout', `gui/${uid}/${SERVICE_LABEL}`], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
try {
|
|
241
|
+
execFileSync('launchctl', ['unload', '-w', plist], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
242
|
+
}
|
|
243
|
+
catch { /* not loaded */ }
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
fs.unlinkSync(plist);
|
|
247
|
+
}
|
|
248
|
+
catch { /* already gone */ }
|
|
249
|
+
try {
|
|
250
|
+
fs.mkdirSync(path.dirname(disabledSentinelPath()), { recursive: true });
|
|
251
|
+
fs.writeFileSync(disabledSentinelPath(), `disabled ${new Date().toISOString()}\n`);
|
|
252
|
+
}
|
|
253
|
+
catch { /* best effort */ }
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Upgrade-time auto-enable. Runs from runMigration() once per sentinel bump.
|
|
257
|
+
* No-ops if: not darwin, the user opted out, no helper bundle ships, or the
|
|
258
|
+
* service is already installed. Best-effort — never throws into migration.
|
|
259
|
+
*/
|
|
260
|
+
export function installMenubarLaunchAgentOnUpgrade() {
|
|
261
|
+
try {
|
|
262
|
+
if (!onDarwin())
|
|
263
|
+
return;
|
|
264
|
+
if (menubarDisabledByUser())
|
|
265
|
+
return;
|
|
266
|
+
if (menubarServiceInstalled())
|
|
267
|
+
return;
|
|
268
|
+
if (!sourceAppPath())
|
|
269
|
+
return;
|
|
270
|
+
enableMenubarService({ clearOptOut: false });
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
/* never block migration on the menu bar */
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
export function getMenubarStatus() {
|
|
277
|
+
const dest = installedAppPath();
|
|
278
|
+
let running = false;
|
|
279
|
+
if (onDarwin()) {
|
|
280
|
+
const r = spawnSync('pgrep', ['-f', 'MenubarHelper'], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' });
|
|
281
|
+
running = r.status === 0 && (r.stdout || '').trim().length > 0;
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
platform: process.platform,
|
|
285
|
+
source: sourceAppPath(),
|
|
286
|
+
installedApp: fs.existsSync(dest) ? dest : null,
|
|
287
|
+
serviceInstalled: menubarServiceInstalled(),
|
|
288
|
+
running,
|
|
289
|
+
disabledByUser: menubarDisabledByUser(),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
@@ -46,6 +46,13 @@ export declare function secretsAgentServiceInstalled(): boolean;
|
|
|
46
46
|
* so it can boot even when the machine is loaded. Returns true once reachable.
|
|
47
47
|
*/
|
|
48
48
|
export declare function installSecretsAgentService(timeoutMs?: number): Promise<boolean>;
|
|
49
|
+
/**
|
|
50
|
+
* Kickstart the already-installed persistent broker so launchd relaunches it
|
|
51
|
+
* onto the current on-disk code. Used by postinstall heal-on-upgrade. No-op if
|
|
52
|
+
* the service isn't installed; never rewrites the plist or waits, so it's safe
|
|
53
|
+
* and fast to call from an installer.
|
|
54
|
+
*/
|
|
55
|
+
export declare function kickstartSecretsAgentService(): void;
|
|
49
56
|
/** Stop + remove the persistent broker service, and wipe whatever it held. */
|
|
50
57
|
export declare function uninstallSecretsAgentService(): Promise<void>;
|
|
51
58
|
export type Request = {
|
|
@@ -69,6 +76,7 @@ export type Response = {
|
|
|
69
76
|
ok: true;
|
|
70
77
|
cmd: 'ping';
|
|
71
78
|
version: number;
|
|
79
|
+
cliVersion: string;
|
|
72
80
|
} | {
|
|
73
81
|
ok: true;
|
|
74
82
|
cmd: 'get';
|
|
@@ -127,7 +135,7 @@ export declare function secretsAgentAutoEnabled(): boolean;
|
|
|
127
135
|
/**
|
|
128
136
|
* Fire-and-forget: populate the broker with a freshly-resolved bundle so the
|
|
129
137
|
* NEXT process reads it without a prompt. Used by the auto-cache path after a
|
|
130
|
-
* real keychain read of a `
|
|
138
|
+
* real keychain read of a `daily`-policy bundle. Adds no latency to the caller
|
|
131
139
|
* — it spawns a detached `secrets _agent-load` worker (passing the resolved env
|
|
132
140
|
* over stdin, never argv) and returns immediately.
|
|
133
141
|
*
|
|
@@ -30,6 +30,7 @@ import { spawn, spawnSync, execFileSync } from 'child_process';
|
|
|
30
30
|
import { getHelpersDir, readMeta } from '../state.js';
|
|
31
31
|
import { isAlive } from '../platform/process.js';
|
|
32
32
|
import { getKeychainHelperPath } from './install-helper.js';
|
|
33
|
+
import { getCliVersion, getCliVersionFresh } from '../version.js';
|
|
33
34
|
/** Bumped when the wire protocol changes; a client that pings a mismatched
|
|
34
35
|
* server kills and respawns it rather than talking a stale dialect. */
|
|
35
36
|
const PROTOCOL_VERSION = 1;
|
|
@@ -164,12 +165,27 @@ export async function installSecretsAgentService(timeoutMs = 30000) {
|
|
|
164
165
|
catch { /* best effort */ }
|
|
165
166
|
const deadline = Date.now() + timeoutMs;
|
|
166
167
|
while (Date.now() < deadline) {
|
|
167
|
-
if (await agentPing())
|
|
168
|
+
if ((await agentPing()).reachable)
|
|
168
169
|
return true;
|
|
169
170
|
await new Promise((r) => setTimeout(r, 200));
|
|
170
171
|
}
|
|
171
172
|
return false;
|
|
172
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* Kickstart the already-installed persistent broker so launchd relaunches it
|
|
176
|
+
* onto the current on-disk code. Used by postinstall heal-on-upgrade. No-op if
|
|
177
|
+
* the service isn't installed; never rewrites the plist or waits, so it's safe
|
|
178
|
+
* and fast to call from an installer.
|
|
179
|
+
*/
|
|
180
|
+
export function kickstartSecretsAgentService() {
|
|
181
|
+
if (!onDarwin() || !secretsAgentServiceInstalled())
|
|
182
|
+
return;
|
|
183
|
+
const uid = process.getuid?.() ?? 0;
|
|
184
|
+
try {
|
|
185
|
+
execFileSync('launchctl', ['kickstart', '-k', `gui/${uid}/${SERVICE_LABEL}`], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
186
|
+
}
|
|
187
|
+
catch { /* best effort */ }
|
|
188
|
+
}
|
|
173
189
|
/** Stop + remove the persistent broker service, and wipe whatever it held. */
|
|
174
190
|
export async function uninstallSecretsAgentService() {
|
|
175
191
|
if (!onDarwin())
|
|
@@ -201,7 +217,11 @@ export async function uninstallSecretsAgentService() {
|
|
|
201
217
|
export function handleAgentRequest(store, req, now = Date.now()) {
|
|
202
218
|
switch (req.cmd) {
|
|
203
219
|
case 'ping':
|
|
204
|
-
|
|
220
|
+
// Report the version of the code this broker is RUNNING (getCliVersion
|
|
221
|
+
// caches the value from the broker's startup), not the on-disk version.
|
|
222
|
+
// A client compares this to its own fresh on-disk read; a mismatch means
|
|
223
|
+
// the broker is running pre-upgrade code and should be restarted.
|
|
224
|
+
return { ok: true, cmd: 'ping', version: PROTOCOL_VERSION, cliVersion: getCliVersion() };
|
|
205
225
|
case 'get': {
|
|
206
226
|
const e = store.get(req.name);
|
|
207
227
|
if (!e || now >= e.expiresAt) {
|
|
@@ -278,11 +298,25 @@ export async function runSecretsAgent(opts = {}) {
|
|
|
278
298
|
fs.unlinkSync(sock);
|
|
279
299
|
}
|
|
280
300
|
catch { /* no stale socket */ }
|
|
301
|
+
// Capture the version of the code we're running so the sweep can detect when
|
|
302
|
+
// an in-place upgrade has landed and self-heal onto it. getCliVersion caches
|
|
303
|
+
// this value for the process lifetime; getCliVersionFresh re-reads on disk.
|
|
304
|
+
const runningVersion = getCliVersion();
|
|
281
305
|
const sweep = () => {
|
|
282
306
|
const now = Date.now();
|
|
283
307
|
for (const [name, e] of store)
|
|
284
308
|
if (now >= e.expiresAt)
|
|
285
309
|
store.delete(name);
|
|
310
|
+
// Self-heal: a newer version was installed in place — exit so launchd
|
|
311
|
+
// relaunches us on the new code. Only meaningful when launchd will restart
|
|
312
|
+
// us (persistent); a one-off broker just keeps serving until idle.
|
|
313
|
+
if (persistent) {
|
|
314
|
+
const onDisk = getCliVersionFresh();
|
|
315
|
+
if (onDisk !== 'unknown' && runningVersion !== 'unknown' && onDisk !== runningVersion) {
|
|
316
|
+
shutdown(0); // KeepAlive relaunches on the new code
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
286
320
|
if (store.size === 0) {
|
|
287
321
|
if (!persistent && now - emptySince >= IDLE_EXIT_MS)
|
|
288
322
|
shutdown(0);
|
|
@@ -489,7 +523,7 @@ export function secretsAgentAutoEnabled() {
|
|
|
489
523
|
/**
|
|
490
524
|
* Fire-and-forget: populate the broker with a freshly-resolved bundle so the
|
|
491
525
|
* NEXT process reads it without a prompt. Used by the auto-cache path after a
|
|
492
|
-
* real keychain read of a `
|
|
526
|
+
* real keychain read of a `daily`-policy bundle. Adds no latency to the caller
|
|
493
527
|
* — it spawns a detached `secrets _agent-load` worker (passing the resolved env
|
|
494
528
|
* over stdin, never argv) and returns immediately.
|
|
495
529
|
*
|
|
@@ -556,12 +590,16 @@ export async function agentStatus() {
|
|
|
556
590
|
const r = await request({ cmd: 'status' });
|
|
557
591
|
return r?.ok === true && r.cmd === 'status' ? r.entries : [];
|
|
558
592
|
}
|
|
559
|
-
/**
|
|
593
|
+
/** Ping result: whether a broker is reachable + speaking our protocol, and the
|
|
594
|
+
* version of the code it's running (for staleness detection). */
|
|
560
595
|
async function agentPing() {
|
|
561
596
|
if (!agentSocketExists())
|
|
562
|
-
return false;
|
|
597
|
+
return { reachable: false };
|
|
563
598
|
const r = await request({ cmd: 'ping' });
|
|
564
|
-
|
|
599
|
+
if (r?.ok === true && r.cmd === 'ping' && r.version === PROTOCOL_VERSION) {
|
|
600
|
+
return { reachable: true, cliVersion: r.cliVersion };
|
|
601
|
+
}
|
|
602
|
+
return { reachable: false };
|
|
565
603
|
}
|
|
566
604
|
/**
|
|
567
605
|
* Ensure a broker is running and reachable. Returns true once the socket answers
|
|
@@ -576,8 +614,16 @@ async function agentPing() {
|
|
|
576
614
|
export async function ensureAgentRunning(timeoutMs = 5000) {
|
|
577
615
|
if (!onDarwin())
|
|
578
616
|
return false;
|
|
579
|
-
if
|
|
580
|
-
|
|
617
|
+
// Self-heal: if a broker is reachable but running pre-upgrade code (its
|
|
618
|
+
// reported version != the version on disk now), tear it down so the paths
|
|
619
|
+
// below bring up a fresh one on current code. A current, reachable broker is
|
|
620
|
+
// accepted immediately.
|
|
621
|
+
const ping = await agentPing();
|
|
622
|
+
if (ping.reachable) {
|
|
623
|
+
if (ping.cliVersion === undefined || ping.cliVersion === getCliVersionFresh())
|
|
624
|
+
return true;
|
|
625
|
+
await teardownStaleBroker();
|
|
626
|
+
}
|
|
581
627
|
// Path 1: the persistent service. installSecretsAgentService is idempotent and
|
|
582
628
|
// waits for the socket; for an already-installed service we kickstart and wait.
|
|
583
629
|
try {
|
|
@@ -593,7 +639,7 @@ export async function ensureAgentRunning(timeoutMs = 5000) {
|
|
|
593
639
|
catch { /* may already be running */ }
|
|
594
640
|
const d = Date.now() + timeoutMs;
|
|
595
641
|
while (Date.now() < d) {
|
|
596
|
-
if (await agentPing())
|
|
642
|
+
if ((await agentPing()).reachable)
|
|
597
643
|
return true;
|
|
598
644
|
await new Promise((r) => setTimeout(r, 150));
|
|
599
645
|
}
|
|
@@ -627,9 +673,44 @@ export async function ensureAgentRunning(timeoutMs = 5000) {
|
|
|
627
673
|
spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
|
|
628
674
|
const deadline = Date.now() + timeoutMs;
|
|
629
675
|
while (Date.now() < deadline) {
|
|
630
|
-
if (await agentPing())
|
|
676
|
+
if ((await agentPing()).reachable)
|
|
631
677
|
return true;
|
|
632
678
|
await new Promise((r) => setTimeout(r, 100));
|
|
633
679
|
}
|
|
634
680
|
return false;
|
|
635
681
|
}
|
|
682
|
+
/**
|
|
683
|
+
* Tear down a stale broker (running pre-upgrade code) so a fresh one can take
|
|
684
|
+
* over. If the persistent service is installed, bootout makes launchd relaunch
|
|
685
|
+
* it on the new code; otherwise kill the process and clear its socket/pid.
|
|
686
|
+
*/
|
|
687
|
+
async function teardownStaleBroker() {
|
|
688
|
+
if (secretsAgentServiceInstalled()) {
|
|
689
|
+
const uid = process.getuid?.() ?? 0;
|
|
690
|
+
try {
|
|
691
|
+
execFileSync('launchctl', ['kickstart', '-k', `gui/${uid}/${SERVICE_LABEL}`], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
692
|
+
}
|
|
693
|
+
catch { /* best effort */ }
|
|
694
|
+
return; // kickstart -k restarts the service onto current code; socket/pid managed by it
|
|
695
|
+
}
|
|
696
|
+
const pid = (() => { try {
|
|
697
|
+
return parseInt(fs.readFileSync(pidPath(), 'utf-8').trim(), 10);
|
|
698
|
+
}
|
|
699
|
+
catch {
|
|
700
|
+
return NaN;
|
|
701
|
+
} })();
|
|
702
|
+
if (!isNaN(pid) && isAlive(pid)) {
|
|
703
|
+
try {
|
|
704
|
+
process.kill(pid, 'SIGTERM');
|
|
705
|
+
}
|
|
706
|
+
catch { /* gone */ }
|
|
707
|
+
}
|
|
708
|
+
try {
|
|
709
|
+
fs.unlinkSync(socketPath());
|
|
710
|
+
}
|
|
711
|
+
catch { /* gone */ }
|
|
712
|
+
try {
|
|
713
|
+
fs.unlinkSync(pidPath());
|
|
714
|
+
}
|
|
715
|
+
catch { /* gone */ }
|
|
716
|
+
}
|