@phnx-labs/agents-cli 1.20.87 → 1.20.89
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 +323 -0
- package/README.md +12 -4
- package/dist/bin/agents +0 -0
- package/dist/commands/commands.js +7 -7
- package/dist/commands/doctor.d.ts +0 -19
- package/dist/commands/doctor.js +219 -305
- package/dist/commands/exec.js +7 -19
- package/dist/commands/factory.js +26 -2
- package/dist/commands/funnel.js +16 -1
- package/dist/commands/inspect.js +3 -5
- package/dist/commands/menubar.js +117 -34
- package/dist/commands/routines.js +25 -3
- package/dist/commands/secrets-rotate-passphrase.d.ts +17 -0
- package/dist/commands/secrets-rotate-passphrase.js +96 -0
- package/dist/commands/secrets.js +2 -0
- package/dist/commands/sessions.d.ts +7 -1
- package/dist/commands/sessions.js +40 -12
- package/dist/commands/ssh.js +3 -3
- package/dist/commands/usage.d.ts +3 -2
- package/dist/commands/usage.js +2 -9
- package/dist/commands/webhook.js +7 -2
- package/dist/lib/agents.d.ts +31 -1
- package/dist/lib/agents.js +55 -0
- package/dist/lib/command-skills.d.ts +10 -0
- package/dist/lib/command-skills.js +14 -0
- package/dist/lib/commands.js +28 -2
- package/dist/lib/daemon.d.ts +29 -0
- package/dist/lib/daemon.js +75 -6
- package/dist/lib/devices/doctor-findings.d.ts +167 -0
- package/dist/lib/devices/doctor-findings.js +893 -0
- package/dist/lib/devices/fleet-divergence.d.ts +22 -0
- package/dist/lib/devices/fleet-divergence.js +34 -10
- package/dist/lib/devices/fleet-inventory.d.ts +17 -6
- package/dist/lib/devices/fleet-inventory.js +56 -8
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +14 -3
- package/dist/lib/exec.js +41 -8
- package/dist/lib/factory/snapshot.d.ts +78 -0
- package/dist/lib/factory/snapshot.js +209 -0
- package/dist/lib/fs-atomic.d.ts +14 -1
- package/dist/lib/fs-atomic.js +35 -3
- package/dist/lib/funnel.d.ts +1 -0
- package/dist/lib/funnel.js +8 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
- package/dist/lib/menubar/install-menubar.d.ts +53 -2
- package/dist/lib/menubar/install-menubar.js +183 -28
- package/dist/lib/platform/process.d.ts +2 -0
- package/dist/lib/platform/process.js +5 -3
- package/dist/lib/project-resources.js +34 -20
- package/dist/lib/resources.d.ts +8 -0
- package/dist/lib/resources.js +34 -1
- package/dist/lib/routines-placement.d.ts +2 -1
- package/dist/lib/routines-placement.js +8 -4
- package/dist/lib/routines.d.ts +57 -1
- package/dist/lib/routines.js +74 -1
- package/dist/lib/runner.d.ts +16 -1
- package/dist/lib/runner.js +58 -16
- package/dist/lib/sandbox.d.ts +2 -0
- package/dist/lib/sandbox.js +38 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.js +9 -34
- package/dist/lib/secrets/filestore.d.ts +152 -34
- package/dist/lib/secrets/filestore.js +676 -123
- package/dist/lib/secrets/rc-hygiene.d.ts +0 -6
- package/dist/lib/secrets/rc-hygiene.js +0 -24
- package/dist/lib/session/active.d.ts +6 -6
- package/dist/lib/session/active.js +6 -6
- package/dist/lib/session/discover.d.ts +5 -0
- package/dist/lib/session/discover.js +137 -1
- package/dist/lib/session/parse.d.ts +2 -0
- package/dist/lib/session/parse.js +76 -37
- package/dist/lib/session/remote-active.d.ts +4 -1
- package/dist/lib/session/remote-active.js +8 -2
- package/dist/lib/session/sync/agents.js +0 -0
- package/dist/lib/session/types.d.ts +1 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/session/viewing-in.d.ts +31 -0
- package/dist/lib/session/viewing-in.js +47 -0
- package/dist/lib/staleness/detectors/commands.js +14 -5
- package/dist/lib/staleness/types.d.ts +2 -0
- package/dist/lib/staleness/writers/commands.js +13 -7
- package/dist/lib/state.d.ts +17 -0
- package/dist/lib/state.js +30 -2
- package/dist/lib/triggers/handlers.d.ts +95 -0
- package/dist/lib/triggers/handlers.js +384 -0
- package/dist/lib/triggers/webhook.d.ts +10 -2
- package/dist/lib/triggers/webhook.js +65 -11
- package/dist/lib/usage.d.ts +72 -1
- package/dist/lib/usage.js +21 -27
- package/dist/lib/versions.js +30 -13
- package/package.json +1 -1
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only Software Factory state aggregation.
|
|
3
|
+
*
|
|
4
|
+
* ~/.agents/factory.yml example:
|
|
5
|
+
*
|
|
6
|
+
* ceiling: 4
|
|
7
|
+
* max_dispatch_per_tick: 2
|
|
8
|
+
* per_project:
|
|
9
|
+
* Agents CLI: { weight: 2, cap: 2 }
|
|
10
|
+
* idle_boxes: [yosemite-m1, yosemite-m2]
|
|
11
|
+
* digest: { times: ["09:00", "17:00"], tz: America/Los_Angeles }
|
|
12
|
+
*/
|
|
13
|
+
import * as fs from 'fs';
|
|
14
|
+
import * as os from 'os';
|
|
15
|
+
import * as path from 'path';
|
|
16
|
+
import { execFile as execFileCallback } from 'child_process';
|
|
17
|
+
import { promisify } from 'util';
|
|
18
|
+
import * as yaml from 'yaml';
|
|
19
|
+
import { getActiveSessions } from '../session/active.js';
|
|
20
|
+
import { serializeActiveSessionsForJson } from '../../commands/sessions.js';
|
|
21
|
+
import { readAuthHealthCache } from '../auth-health.js';
|
|
22
|
+
import { readStatsCache } from '../devices/stats-cache.js';
|
|
23
|
+
const execFile = promisify(execFileCallback);
|
|
24
|
+
export const FACTORY_PROJECTS = [
|
|
25
|
+
{ name: 'Prix', repo: 'phnx-labs/prix' },
|
|
26
|
+
{ name: 'Rush App', repo: 'phnx-labs/rush' },
|
|
27
|
+
{ name: 'Rush CLI', repo: 'phnx-labs/rush-cli' },
|
|
28
|
+
{ name: 'Agents CLI', repo: 'phnx-labs/agents-cli' },
|
|
29
|
+
{ name: 'Linear CLI', repo: 'phnx-labs/linear-cli' },
|
|
30
|
+
];
|
|
31
|
+
const defaults = () => ({
|
|
32
|
+
source: 'default',
|
|
33
|
+
ceiling: 4,
|
|
34
|
+
max_dispatch_per_tick: 2,
|
|
35
|
+
per_project: {},
|
|
36
|
+
idle_boxes: [],
|
|
37
|
+
digest: { times: [], tz: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' },
|
|
38
|
+
});
|
|
39
|
+
export function readFactoryConfig(home) {
|
|
40
|
+
const file = path.join(home, '.agents', 'factory.yml');
|
|
41
|
+
if (!fs.existsSync(file))
|
|
42
|
+
return defaults();
|
|
43
|
+
const raw = yaml.parse(fs.readFileSync(file, 'utf8'));
|
|
44
|
+
if (!raw || typeof raw !== 'object')
|
|
45
|
+
throw new Error(`${file} must contain a YAML mapping`);
|
|
46
|
+
const base = defaults();
|
|
47
|
+
return {
|
|
48
|
+
source: 'file',
|
|
49
|
+
ceiling: integer(raw.ceiling ?? base.ceiling, 'ceiling'),
|
|
50
|
+
max_dispatch_per_tick: integer(raw.max_dispatch_per_tick ?? base.max_dispatch_per_tick, 'max_dispatch_per_tick'),
|
|
51
|
+
per_project: parseProjectConfig(raw.per_project),
|
|
52
|
+
idle_boxes: stringArray(raw.idle_boxes, 'idle_boxes'),
|
|
53
|
+
digest: raw.digest ? {
|
|
54
|
+
times: stringArray(raw.digest.times, 'digest.times'),
|
|
55
|
+
tz: typeof raw.digest.tz === 'string' ? raw.digest.tz : base.digest.tz,
|
|
56
|
+
} : base.digest,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function integer(value, name) {
|
|
60
|
+
if (!Number.isInteger(value) || value < 0)
|
|
61
|
+
throw new Error(`factory.yml ${name} must be a non-negative integer`);
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
function stringArray(value, name) {
|
|
65
|
+
if (value === undefined)
|
|
66
|
+
return [];
|
|
67
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string'))
|
|
68
|
+
throw new Error(`factory.yml ${name} must be a string array`);
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
function parseProjectConfig(value) {
|
|
72
|
+
if (value === undefined)
|
|
73
|
+
return {};
|
|
74
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
75
|
+
throw new Error('factory.yml per_project must be a mapping');
|
|
76
|
+
return Object.fromEntries(Object.entries(value).map(([name, item]) => {
|
|
77
|
+
const record = item;
|
|
78
|
+
return [name, { weight: integer(record?.weight, `per_project.${name}.weight`), cap: integer(record?.cap, `per_project.${name}.cap`) }];
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
function json(text) {
|
|
82
|
+
return JSON.parse(text);
|
|
83
|
+
}
|
|
84
|
+
function issueCount(payload) {
|
|
85
|
+
if (Array.isArray(payload))
|
|
86
|
+
return payload.length;
|
|
87
|
+
if (payload && typeof payload === 'object') {
|
|
88
|
+
const record = payload;
|
|
89
|
+
if (typeof record.count === 'number')
|
|
90
|
+
return record.count;
|
|
91
|
+
if (Array.isArray(record.issues))
|
|
92
|
+
return record.issues.length;
|
|
93
|
+
}
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
export function queueCounts(todoPayload, openPayload) {
|
|
97
|
+
const issues = openPayload && typeof openPayload === 'object' && Array.isArray(openPayload.issues)
|
|
98
|
+
? openPayload.issues : [];
|
|
99
|
+
const blocked = issues.filter((issue) => {
|
|
100
|
+
const state = issue.state && typeof issue.state === 'object' ? issue.state : {};
|
|
101
|
+
const labels = issue.labels && typeof issue.labels === 'object' && Array.isArray(issue.labels.nodes)
|
|
102
|
+
? issue.labels.nodes : [];
|
|
103
|
+
return String(state.name ?? '').toLowerCase().includes('block') || labels.some((label) => String(label.name ?? '').toLowerCase() === 'blocked');
|
|
104
|
+
}).length;
|
|
105
|
+
const inProgress = issues.filter((issue) => {
|
|
106
|
+
const state = issue.state && typeof issue.state === 'object' ? issue.state : {};
|
|
107
|
+
return String(state.type ?? '').toLowerCase() === 'started' && !String(state.name ?? '').toLowerCase().includes('block');
|
|
108
|
+
}).length;
|
|
109
|
+
return { todo: issueCount(todoPayload), inProgress, blocked };
|
|
110
|
+
}
|
|
111
|
+
export function parsePullRequests(repo, payload) {
|
|
112
|
+
if (!Array.isArray(payload))
|
|
113
|
+
return [];
|
|
114
|
+
return payload.map((raw) => {
|
|
115
|
+
const pr = raw;
|
|
116
|
+
const checks = Array.isArray(pr.statusCheckRollup) ? pr.statusCheckRollup : [];
|
|
117
|
+
const states = checks.map((check) => String(check.conclusion ?? check.state ?? check.status ?? '').toUpperCase());
|
|
118
|
+
const ci = states.some((s) => ['FAILURE', 'ERROR', 'CANCELLED', 'TIMED_OUT'].includes(s)) ? 'failing'
|
|
119
|
+
: states.some((s) => ['', 'PENDING', 'QUEUED', 'IN_PROGRESS'].includes(s)) ? 'pending'
|
|
120
|
+
: states.length > 0 ? 'passing' : 'none';
|
|
121
|
+
return {
|
|
122
|
+
repo,
|
|
123
|
+
number: Number(pr.number),
|
|
124
|
+
ci,
|
|
125
|
+
review: String(pr.reviewDecision ?? 'none').toLowerCase(),
|
|
126
|
+
mergeable: String(pr.mergeable ?? 'unknown').toLowerCase(),
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
export function parseDevices(payload, cached = {}) {
|
|
131
|
+
const rows = Array.isArray(payload) ? payload : [];
|
|
132
|
+
return rows.map((raw) => {
|
|
133
|
+
const row = raw;
|
|
134
|
+
const name = String(row.name ?? row.host ?? '');
|
|
135
|
+
const stats = row.stats && typeof row.stats === 'object' ? row.stats : cached[name] ?? row;
|
|
136
|
+
const candidate = stats.loadPercent ?? stats.load ?? stats.loadPct;
|
|
137
|
+
const load = typeof candidate === 'number' && Number.isFinite(candidate) ? candidate : null;
|
|
138
|
+
return { name, load, idle: row.idle === true || (load !== null && load < 20) };
|
|
139
|
+
}).filter((row) => row.name.length > 0);
|
|
140
|
+
}
|
|
141
|
+
export function readRecentRuns(home, limit = 3) {
|
|
142
|
+
const root = path.join(home, '.agents', '.history', 'runs');
|
|
143
|
+
if (!fs.existsSync(root))
|
|
144
|
+
return [];
|
|
145
|
+
const found = [];
|
|
146
|
+
for (const routine of fs.readdirSync(root)) {
|
|
147
|
+
const routineDir = path.join(root, routine);
|
|
148
|
+
if (!fs.statSync(routineDir).isDirectory())
|
|
149
|
+
continue;
|
|
150
|
+
const routineRuns = [];
|
|
151
|
+
for (const run of fs.readdirSync(routineDir)) {
|
|
152
|
+
const file = path.join(routineDir, run, 'meta.json');
|
|
153
|
+
if (!fs.existsSync(file))
|
|
154
|
+
continue;
|
|
155
|
+
try {
|
|
156
|
+
const meta = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
157
|
+
const started = Date.parse(String(meta.startedAt ?? meta.createdAt ?? ''));
|
|
158
|
+
const ended = Date.parse(String(meta.finishedAt ?? meta.completedAt ?? meta.updatedAt ?? ''));
|
|
159
|
+
const duration = typeof meta.durationMs === 'number' ? meta.durationMs : Number.isFinite(started) && Number.isFinite(ended) ? ended - started : null;
|
|
160
|
+
routineRuns.push({ routine, status: String(meta.status ?? 'unknown'), durationMs: duration, mtime: fs.statSync(file).mtimeMs });
|
|
161
|
+
}
|
|
162
|
+
catch { /* A concurrently-written or malformed run is not a completed outcome. */ }
|
|
163
|
+
}
|
|
164
|
+
found.push(...routineRuns.sort((a, b) => b.mtime - a.mtime).slice(0, limit));
|
|
165
|
+
}
|
|
166
|
+
return found.sort((a, b) => b.mtime - a.mtime).map(({ mtime: _, ...run }) => run);
|
|
167
|
+
}
|
|
168
|
+
function latestClaudeVerdict(entries) {
|
|
169
|
+
return Object.entries(entries)
|
|
170
|
+
.filter(([key]) => key.split(':').includes('claude'))
|
|
171
|
+
.map(([, value]) => value)
|
|
172
|
+
.sort((a, b) => b.checkedAt - a.checkedAt)[0]?.verdict ?? null;
|
|
173
|
+
}
|
|
174
|
+
export async function buildFactorySnapshot(overrides = {}) {
|
|
175
|
+
const deps = {
|
|
176
|
+
home: os.homedir(),
|
|
177
|
+
now: () => new Date(),
|
|
178
|
+
activeSessions: () => getActiveSessions(),
|
|
179
|
+
run: async (file, args) => (await execFile(file, args, { maxBuffer: 10 * 1024 * 1024 })).stdout,
|
|
180
|
+
readAuth: readAuthHealthCache,
|
|
181
|
+
readDeviceStats: readStatsCache,
|
|
182
|
+
...overrides,
|
|
183
|
+
};
|
|
184
|
+
const linear = path.join(deps.home, '.agents', 'skills', 'linear', 'scripts', 'linear');
|
|
185
|
+
const safeRun = async (file, args) => {
|
|
186
|
+
try {
|
|
187
|
+
return json(await deps.run(file, args));
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
const sessionsPromise = deps.activeSessions().then(serializeActiveSessionsForJson);
|
|
194
|
+
const queuePromise = Promise.all(FACTORY_PROJECTS.map(async ({ name }) => {
|
|
195
|
+
const query = (status) => safeRun(linear, ['tasks', '--project', name, '--label', 'pilot', '--status', status, '--cycle', 'all', '--all', '--json']);
|
|
196
|
+
const [todo, open] = await Promise.all([query('todo'), query('open')]);
|
|
197
|
+
return [name, queueCounts(todo, open)];
|
|
198
|
+
})).then(Object.fromEntries);
|
|
199
|
+
const prsPromise = Promise.all(FACTORY_PROJECTS.map(async ({ repo }) => parsePullRequests(repo, await safeRun('gh', ['pr', 'list', '--repo', repo, '--state', 'open', '--json', 'number,title,statusCheckRollup,reviewDecision,mergeable'])))).then((rows) => rows.flat());
|
|
200
|
+
// `devices list --json` is the registry's read-only JSON surface. Load comes
|
|
201
|
+
// from the daemon-warmed cache so snapshot never probes or writes reachability.
|
|
202
|
+
const devicesPromise = safeRun('agents', ['devices', 'list', '--json']).then((payload) => parseDevices(payload, deps.readDeviceStats()));
|
|
203
|
+
const [sessions, queues, prs, devices] = await Promise.all([sessionsPromise, queuePromise, prsPromise, devicesPromise]);
|
|
204
|
+
return {
|
|
205
|
+
generatedAt: deps.now().toISOString(), sessions, queues, prs, devices,
|
|
206
|
+
recentRuns: readRecentRuns(deps.home), auth: { claude: latestClaudeVerdict(deps.readAuth()) },
|
|
207
|
+
config: readFactoryConfig(deps.home),
|
|
208
|
+
};
|
|
209
|
+
}
|
package/dist/lib/fs-atomic.d.ts
CHANGED
|
@@ -16,9 +16,22 @@ export declare function atomicWriteFileSync(filePath: string, content: string, o
|
|
|
16
16
|
* releases the lock. Retries with capped linear back-off until either the lock
|
|
17
17
|
* is acquired or LOCK_ACQUIRE_TIMEOUT_MS elapses. Breaks stale locks older than
|
|
18
18
|
* LOCK_STALE_MS, so a crashed holder never blocks past the stale window.
|
|
19
|
+
*
|
|
20
|
+
* `fn` is handed a `heartbeat()` it can call during a long, fully SYNCHRONOUS
|
|
21
|
+
* critical section. proper-lockfile keeps a held lock "alive" by refreshing its
|
|
22
|
+
* lockfile mtime on a `setTimeout` every `stale/2` — but that timer only fires
|
|
23
|
+
* when the event loop gets a turn. A synchronous hold that outruns `stale`
|
|
24
|
+
* (e.g. the scrypt-bound rotation loop in filestore.ts, ~16s on a real store)
|
|
25
|
+
* never yields, so the timer cannot run: the lock ages past `stale` mid-hold and a
|
|
26
|
+
* peer contending for it treats the live holder as crashed, breaks the lock, and
|
|
27
|
+
* interleaves — corrupting the invariant the lock exists to protect, with no crash
|
|
28
|
+
* involved. `heartbeat()` drives the same refresh synchronously (bumps the lockfile
|
|
29
|
+
* mtime), so a long sync holder stays fresh while the short `stale` window still
|
|
30
|
+
* detects a genuinely crashed holder within LOCK_STALE_MS. Callers whose critical
|
|
31
|
+
* section is short (a single read-modify-write) can ignore it.
|
|
19
32
|
*/
|
|
20
33
|
export interface FileLockOptions {
|
|
21
34
|
staleMs?: number;
|
|
22
35
|
acquireTimeoutMs?: number;
|
|
23
36
|
}
|
|
24
|
-
export declare function withFileLock<T>(filePath: string, fn: () => T, opts?: FileLockOptions): T;
|
|
37
|
+
export declare function withFileLock<T>(filePath: string, fn: (heartbeat: () => void) => T, opts?: FileLockOptions): T;
|
package/dist/lib/fs-atomic.js
CHANGED
|
@@ -58,12 +58,20 @@ export function atomicWriteFileSync(filePath, content, options = 'utf-8') {
|
|
|
58
58
|
export function withFileLock(filePath, fn, opts = {}) {
|
|
59
59
|
let release = null;
|
|
60
60
|
let lastError;
|
|
61
|
+
// Set if a peer breaks this lock while we hold it. proper-lockfile reports that
|
|
62
|
+
// from its own refresh TIMER, so the default handler rethrows asynchronously —
|
|
63
|
+
// an uncatchable crash of the whole CLI process, from a callback no caller is
|
|
64
|
+
// on the stack for. Capture it instead and surface it synchronously below.
|
|
65
|
+
let compromised = null;
|
|
61
66
|
const staleMs = opts.staleMs ?? LOCK_STALE_MS;
|
|
62
67
|
const acquireTimeoutMs = opts.acquireTimeoutMs ?? LOCK_ACQUIRE_TIMEOUT_MS;
|
|
63
68
|
const deadline = Date.now() + acquireTimeoutMs;
|
|
64
69
|
for (let attempt = 0;; attempt++) {
|
|
65
70
|
try {
|
|
66
|
-
release = lockfile.lockSync(filePath, {
|
|
71
|
+
release = lockfile.lockSync(filePath, {
|
|
72
|
+
stale: staleMs,
|
|
73
|
+
onCompromised: (err) => { compromised = err; },
|
|
74
|
+
});
|
|
67
75
|
break;
|
|
68
76
|
}
|
|
69
77
|
catch (err) {
|
|
@@ -78,10 +86,34 @@ export function withFileLock(filePath, fn, opts = {}) {
|
|
|
78
86
|
const message = lastError instanceof Error ? lastError.message : String(lastError);
|
|
79
87
|
throw new Error(`Could not acquire lock for ${filePath} after ${acquireTimeoutMs}ms: ${message}`);
|
|
80
88
|
}
|
|
89
|
+
// proper-lockfile's lock dir is `<filePath>.lock`; touching its mtime is exactly
|
|
90
|
+
// what proper-lockfile's own async updater does, so the staleness check keys off
|
|
91
|
+
// a fresh mtime. Best-effort: a failed touch just leaves the async updater's
|
|
92
|
+
// behaviour unchanged (no worse than before this heartbeat existed).
|
|
93
|
+
const lockDir = `${filePath}.lock`;
|
|
94
|
+
const heartbeat = () => {
|
|
95
|
+
try {
|
|
96
|
+
const now = new Date();
|
|
97
|
+
fs.utimesSync(lockDir, now, now);
|
|
98
|
+
}
|
|
99
|
+
catch { /* best effort */ }
|
|
100
|
+
};
|
|
81
101
|
try {
|
|
82
|
-
|
|
102
|
+
const result = fn(heartbeat);
|
|
103
|
+
// A compromised lock means a peer may have written under us — the caller must
|
|
104
|
+
// not treat the result as if it held exclusivity throughout.
|
|
105
|
+
if (compromised) {
|
|
106
|
+
throw new Error(`Lock for ${filePath} was broken by another process while held: ` +
|
|
107
|
+
`${compromised.message}`);
|
|
108
|
+
}
|
|
109
|
+
return result;
|
|
83
110
|
}
|
|
84
111
|
finally {
|
|
85
|
-
|
|
112
|
+
// Releasing a lock a peer already stole throws ENOTACQUIRED; that is the
|
|
113
|
+
// stolen case, already reported above, so don't mask it with a teardown error.
|
|
114
|
+
try {
|
|
115
|
+
release();
|
|
116
|
+
}
|
|
117
|
+
catch { /* already gone */ }
|
|
86
118
|
}
|
|
87
119
|
}
|
package/dist/lib/funnel.d.ts
CHANGED
|
@@ -2,4 +2,5 @@ export declare const FUNNEL_PORTS: readonly [443, 8443, 10000];
|
|
|
2
2
|
export type FunnelPort = typeof FUNNEL_PORTS[number];
|
|
3
3
|
export declare function parseFunnelPort(value: string | number): FunnelPort;
|
|
4
4
|
export declare function buildFunnelStatusCommand(): string;
|
|
5
|
+
export declare function buildFunnelDownCommand(publicPort: FunnelPort): string;
|
|
5
6
|
export declare function buildFunnelUpCommand(publicPort: FunnelPort, localPort: number): string;
|
package/dist/lib/funnel.js
CHANGED
|
@@ -9,6 +9,14 @@ export function parseFunnelPort(value) {
|
|
|
9
9
|
export function buildFunnelStatusCommand() {
|
|
10
10
|
return 'tailscale funnel status';
|
|
11
11
|
}
|
|
12
|
+
export function buildFunnelDownCommand(publicPort) {
|
|
13
|
+
return [
|
|
14
|
+
'tailscale',
|
|
15
|
+
'funnel',
|
|
16
|
+
`--https=${publicPort}`,
|
|
17
|
+
'off',
|
|
18
|
+
].map(shellQuote).join(' ');
|
|
19
|
+
}
|
|
12
20
|
export function buildFunnelUpCommand(publicPort, localPort) {
|
|
13
21
|
if (!Number.isInteger(localPort) || localPort <= 0 || localPort > 65535) {
|
|
14
22
|
throw new Error('Local port must be between 1 and 65535');
|
|
Binary file
|
|
Binary file
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<dict>
|
|
7
7
|
<key>Resources/AppIcon.icns</key>
|
|
8
8
|
<data>
|
|
9
|
-
|
|
9
|
+
jOjZVimcFRHoP2VPzgn8uM8mjkA=
|
|
10
10
|
</data>
|
|
11
11
|
</dict>
|
|
12
12
|
<key>files2</key>
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
<dict>
|
|
16
16
|
<key>hash2</key>
|
|
17
17
|
<data>
|
|
18
|
-
|
|
18
|
+
GFvSLeNYJ3ASxW3OzcuiB4aID0gZUBkpozmWZkbTFNw=
|
|
19
19
|
</data>
|
|
20
20
|
</dict>
|
|
21
21
|
</dict>
|
|
@@ -124,6 +124,48 @@ export declare function disableMenubarService(): void;
|
|
|
124
124
|
* Best-effort — never throws into startup.
|
|
125
125
|
*/
|
|
126
126
|
export declare function installMenubarLaunchAgentOnUpgrade(): void;
|
|
127
|
+
/** One step of `agents menubar setup`, and how it came out. */
|
|
128
|
+
export interface SetupStep {
|
|
129
|
+
/** What was configured. */
|
|
130
|
+
name: string;
|
|
131
|
+
/** `ok` — already correct or now correct; `changed` — this run fixed it;
|
|
132
|
+
* `failed` — could not be configured (setup reports and exits nonzero). */
|
|
133
|
+
outcome: 'ok' | 'changed' | 'failed';
|
|
134
|
+
detail: string;
|
|
135
|
+
}
|
|
136
|
+
export interface SetupResult {
|
|
137
|
+
steps: SetupStep[];
|
|
138
|
+
/** Every step landed on `ok`/`changed` and exactly one helper is running. */
|
|
139
|
+
configured: boolean;
|
|
140
|
+
status: MenubarStatus;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Decide which live helper processes must be ended so exactly one status item
|
|
144
|
+
* survives. Pure so the choice is unit-testable without a live menu bar.
|
|
145
|
+
*
|
|
146
|
+
* EVERY current process is ended, including the wanted one: the caller
|
|
147
|
+
* re-kickstarts the launchd service straight after, so the survivor is the one
|
|
148
|
+
* launchd owns (RunAtLoad + KeepAlive), not whichever copy happened to win a
|
|
149
|
+
* race. Picking a survivor from a `ps` listing cannot do this — the list says
|
|
150
|
+
* nothing about which pid launchd will keep alive, so leaving one alive risks
|
|
151
|
+
* keeping the un-managed copy and re-creating the duplicate on next login.
|
|
152
|
+
*/
|
|
153
|
+
export declare function processesToEnd(status: Pick<MenubarStatus, 'instances' | 'foreignInstances'>): MenubarProcess[];
|
|
154
|
+
/**
|
|
155
|
+
* `agents menubar setup` — configure the menu bar end-to-end, idempotently.
|
|
156
|
+
*
|
|
157
|
+
* The one command that gets a machine to the intended state: exactly one status
|
|
158
|
+
* item, owned by a launchd service that starts it at login and restarts it if it
|
|
159
|
+
* dies. Each concern is a reported step, so a partial failure names itself
|
|
160
|
+
* instead of hiding behind "enabled".
|
|
161
|
+
*
|
|
162
|
+
* 1. bundle — install/refresh the .app at the stable App Support path
|
|
163
|
+
* 2. signature — a valid code identity (macOS 26+ SIGKILLs an invalid one)
|
|
164
|
+
* 3. duplicates — end every live helper, so the only survivor is launchd's
|
|
165
|
+
* 4. login item — write the plist (RunAtLoad + KeepAlive) and bootstrap it
|
|
166
|
+
* 5. single — verify exactly one helper came back up
|
|
167
|
+
*/
|
|
168
|
+
export declare function runMenubarSetup(): SetupResult;
|
|
127
169
|
/** A live MenubarHelper process: its pid and the executable it is running. */
|
|
128
170
|
export interface MenubarProcess {
|
|
129
171
|
pid: number;
|
|
@@ -131,19 +173,26 @@ export interface MenubarProcess {
|
|
|
131
173
|
}
|
|
132
174
|
/**
|
|
133
175
|
* Split the live MenubarHelper processes into the installed bundle's own
|
|
134
|
-
* (`
|
|
176
|
+
* (`own`) and every other copy (`foreign`).
|
|
135
177
|
*
|
|
136
178
|
* `pgrep -f MenubarHelper` conflated the two, so a stray dev build could hold
|
|
137
179
|
* the global Cmd-Shift-V chord (RegisterEventHotKey is first-come) while status
|
|
138
180
|
* still reported a healthy `running: yes` — the paste was dead and nothing said
|
|
139
181
|
* so. A foreign copy is the thing to look for, so name it.
|
|
140
182
|
*
|
|
183
|
+
* `own` is a LIST, not a boolean: two copies of the INSTALLED bundle can run at
|
|
184
|
+
* once (launchd's KeepAlive service plus a LaunchServices/`open` launch of the
|
|
185
|
+
* same .app), which is the duplicate the user actually sees — two agents marks
|
|
186
|
+
* in the menu bar. Collapsing them to `running: true` reported that state as
|
|
187
|
+
* healthy. The helper now refuses to be the second (SingleInstance.swift), and
|
|
188
|
+
* `agents menubar setup` ends any duplicate a pre-fix helper left behind.
|
|
189
|
+
*
|
|
141
190
|
* Identity comes from `comm` (the resolved executable), never from a substring
|
|
142
191
|
* of the command line: matching the latter flags any shell that merely mentions
|
|
143
192
|
* MenubarHelper. `command` is consulted only to drop `--notify` one-shots.
|
|
144
193
|
*/
|
|
145
194
|
export declare function classifyMenubarProcesses(commOutput: string, commandOutput: string, installedExec: string): {
|
|
146
|
-
|
|
195
|
+
own: MenubarProcess[];
|
|
147
196
|
foreign: MenubarProcess[];
|
|
148
197
|
};
|
|
149
198
|
export interface MenubarStatus {
|
|
@@ -155,6 +204,8 @@ export interface MenubarStatus {
|
|
|
155
204
|
stale: boolean;
|
|
156
205
|
serviceInstalled: boolean;
|
|
157
206
|
running: boolean;
|
|
207
|
+
/** Live processes of the INSTALLED bundle. More than one is the duplicate. */
|
|
208
|
+
instances: MenubarProcess[];
|
|
158
209
|
/** Live MenubarHelper processes that are NOT the installed bundle. */
|
|
159
210
|
foreignInstances: MenubarProcess[];
|
|
160
211
|
disabledByUser: boolean;
|