@prohost/cli 0.13.3 → 0.14.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/CHANGELOG.md +75 -0
- package/README.md +129 -0
- package/dist/agent/command.d.ts +8 -0
- package/dist/agent/command.js +40 -7
- package/dist/agent/daemon.d.ts +3 -1
- package/dist/agent/daemon.js +1 -2
- package/dist/agent/run.d.ts +30 -1
- package/dist/agent/run.js +221 -9
- package/dist/agent/runtime_status.d.ts +99 -3
- package/dist/agent/runtime_status.js +173 -5
- package/dist/agent/worktrees.d.ts +2 -0
- package/dist/agent/worktrees.js +3 -2
- package/dist/codehost/activity.d.ts +64 -0
- package/dist/codehost/activity.js +135 -0
- package/dist/codehost/agents.d.ts +31 -0
- package/dist/codehost/agents.js +57 -0
- package/dist/codehost/checkouts.d.ts +130 -0
- package/dist/codehost/checkouts.js +270 -0
- package/dist/codehost/claude_trust.d.ts +40 -0
- package/dist/codehost/claude_trust.js +129 -0
- package/dist/codehost/command.d.ts +25 -0
- package/dist/codehost/command.js +136 -0
- package/dist/codehost/decision.d.ts +156 -0
- package/dist/codehost/decision.js +553 -0
- package/dist/codehost/events.d.ts +49 -0
- package/dist/codehost/events.js +87 -0
- package/dist/codehost/folders.d.ts +122 -0
- package/dist/codehost/folders.js +317 -0
- package/dist/codehost/hooks.d.ts +178 -0
- package/dist/codehost/hooks.js +726 -0
- package/dist/codehost/host.d.ts +290 -0
- package/dist/codehost/host.js +625 -0
- package/dist/codehost/host_stats.d.ts +71 -0
- package/dist/codehost/host_stats.js +166 -0
- package/dist/codehost/invocations.d.ts +12 -0
- package/dist/codehost/invocations.js +82 -0
- package/dist/codehost/ledger.d.ts +35 -0
- package/dist/codehost/ledger.js +91 -0
- package/dist/codehost/local_server.d.ts +106 -0
- package/dist/codehost/local_server.js +339 -0
- package/dist/codehost/pty.d.ts +38 -0
- package/dist/codehost/pty.js +47 -0
- package/dist/codehost/ready.d.ts +31 -0
- package/dist/codehost/ready.js +109 -0
- package/dist/codehost/relay.d.ts +170 -0
- package/dist/codehost/relay.js +548 -0
- package/dist/codehost/settings.d.ts +52 -0
- package/dist/codehost/settings.js +114 -0
- package/dist/codehost/sizing.d.ts +163 -0
- package/dist/codehost/sizing.js +394 -0
- package/dist/codehost/terminals.d.ts +390 -0
- package/dist/codehost/terminals.js +965 -0
- package/dist/codehost/tmux.d.ts +196 -0
- package/dist/codehost/tmux.js +391 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +21 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +4 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code-thread events: what a Mac-hosted terminal tells ProhostAI about its
|
|
3
|
+
* thread — a prompt, a finished turn, a permission prompt, an interrupt.
|
|
4
|
+
*
|
|
5
|
+
* Both the daemon and the hook commands Claude Code / Codex run post here. The
|
|
6
|
+
* credential is not the paired API key: it is a narrow, expiring `host_events`
|
|
7
|
+
* token the relay mints for this machine over its control socket, kept in
|
|
8
|
+
* `$PROHOST_HOME/code-host/hook.json` (0600) and re-read on every post. So a
|
|
9
|
+
* hook in a shell that has been open for days uses whatever token the current
|
|
10
|
+
* daemon last wrote, and a machine whose daemon has been off for a day posts
|
|
11
|
+
* nothing rather than holding a long-lived secret in a file.
|
|
12
|
+
*
|
|
13
|
+
* Posting never costs the agent anything: every failure — no token yet, an
|
|
14
|
+
* expired one, a server without the endpoint (404), a closed thread — is
|
|
15
|
+
* swallowed. Never throws.
|
|
16
|
+
*/
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { endpointProblem } from '../agent/base-url.js';
|
|
19
|
+
import { USER_AGENT } from '../version.js';
|
|
20
|
+
import { codeHostDir, readJsonObject, writePrivateJson } from './settings.js';
|
|
21
|
+
export const EVENT_POST_TIMEOUT_MS = 5_000;
|
|
22
|
+
export function hookConfigPath(env = process.env) {
|
|
23
|
+
return path.join(codeHostDir(env), 'hook.json');
|
|
24
|
+
}
|
|
25
|
+
export function writeHookConfig(config, env = process.env) {
|
|
26
|
+
writePrivateJson(hookConfigPath(env), config);
|
|
27
|
+
}
|
|
28
|
+
/** The current config, or `undefined` when there is none, it is malformed, or it has expired. */
|
|
29
|
+
export function readHookConfig(env = process.env, nowMs = Date.now()) {
|
|
30
|
+
const raw = readJsonObject(hookConfigPath(env));
|
|
31
|
+
if (!raw || raw.version !== 1)
|
|
32
|
+
return undefined;
|
|
33
|
+
const { endpoint, token, expires_at: expiresAt } = raw;
|
|
34
|
+
if (typeof endpoint !== 'string' || typeof token !== 'string' || !token || typeof expiresAt !== 'number') {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
// Same rule as every other credential the CLI sends: never in plaintext off loopback.
|
|
38
|
+
if (endpointProblem(endpoint, 'http'))
|
|
39
|
+
return undefined;
|
|
40
|
+
if (expiresAt * 1000 <= nowMs)
|
|
41
|
+
return undefined;
|
|
42
|
+
return { version: 1, endpoint, token, expires_at: expiresAt };
|
|
43
|
+
}
|
|
44
|
+
export function codeThreadEventsPath(codeThreadId) {
|
|
45
|
+
return `/v1/code-threads/${encodeURIComponent(codeThreadId)}/events`;
|
|
46
|
+
}
|
|
47
|
+
export function event(type, data, nowMs = Date.now()) {
|
|
48
|
+
return { type, at: new Date(nowMs).toISOString(), data };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* POST one event. Returns the HTTP status, or 0 when nothing was sent or no
|
|
52
|
+
* answer came back. Never throws.
|
|
53
|
+
*/
|
|
54
|
+
export async function postCodeThreadEvent(config, codeThreadId, body, fetchImpl = fetch, timeoutMs = EVENT_POST_TIMEOUT_MS) {
|
|
55
|
+
if (!config)
|
|
56
|
+
return 0;
|
|
57
|
+
const controller = new AbortController();
|
|
58
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
59
|
+
try {
|
|
60
|
+
const response = await fetchImpl(`${config.endpoint.replace(/\/+$/, '')}${codeThreadEventsPath(codeThreadId)}`, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
redirect: 'error',
|
|
63
|
+
headers: {
|
|
64
|
+
'Content-Type': 'application/json',
|
|
65
|
+
Authorization: `Bearer ${config.token}`,
|
|
66
|
+
'User-Agent': USER_AGENT,
|
|
67
|
+
},
|
|
68
|
+
body: JSON.stringify(body),
|
|
69
|
+
signal: controller.signal,
|
|
70
|
+
});
|
|
71
|
+
// Drain so the socket is reusable; the answer carries nothing we act on.
|
|
72
|
+
await response.text().catch(() => undefined);
|
|
73
|
+
return response.status;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** The daemon's sink: re-reads `hook.json` for every post, like the hooks do. */
|
|
83
|
+
export function fileEventSink(env = process.env, fetchImpl) {
|
|
84
|
+
return {
|
|
85
|
+
post: (codeThreadId, body) => postCodeThreadEvent(readHookConfig(env), codeThreadId, body, fetchImpl),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The folders this Mac has opened for Code threads, and the rule a folder
|
|
3
|
+
* start is held to.
|
|
4
|
+
*
|
|
5
|
+
* A folder thread runs in place: in the folder itself, with no worktree and no
|
|
6
|
+
* branch (or, with "Work in a separate copy", in a worktree of the git
|
|
7
|
+
* checkout the folder is in). On this Mac the owner picks any folder in the
|
|
8
|
+
* desktop app's native picker, which records it here
|
|
9
|
+
* (`prohost agent code-host folders add`). From anywhere else — a phone, the
|
|
10
|
+
* web — a start may only name a folder this Mac has opened, or one under it:
|
|
11
|
+
* the server never checks a path; this file is the only list that counts.
|
|
12
|
+
*
|
|
13
|
+
* `$PROHOST_HOME/code-host/folders.json` (0600):
|
|
14
|
+
*
|
|
15
|
+
* { "v": 1, "folders": [{ "path": "/abs/realpath", "openedAt": "<ISO>" }] }
|
|
16
|
+
*
|
|
17
|
+
* Most recent first, at most {@link MAX_OPENED_FOLDERS}. Every path is a
|
|
18
|
+
* realpath, and a start's folder is realpathed before it is compared, by path
|
|
19
|
+
* segments: a symlink inside an opened folder that points out of it does not
|
|
20
|
+
* count as inside.
|
|
21
|
+
*/
|
|
22
|
+
export declare const FOLDERS_VERSION = 1;
|
|
23
|
+
export declare const MAX_OPENED_FOLDERS = 50;
|
|
24
|
+
/** The server's bound on a start's `folder` (CONTRACT: ≤ 1024 chars). */
|
|
25
|
+
export declare const MAX_FOLDER_PATH_CHARS = 1024;
|
|
26
|
+
/** What a start's thread says when its folder is not one this Mac opened (the contract's words). */
|
|
27
|
+
export declare const FOLDER_NOT_OPENED = "This folder hasn't been opened on this Mac. Open it from the Prohost desktop app first.";
|
|
28
|
+
/** What a separate-copy start says when its folder is not in a git checkout (the contract's words). */
|
|
29
|
+
export declare const SEPARATE_COPY_NEEDS_GIT = "Work in a separate copy needs a git repository.";
|
|
30
|
+
export interface OpenedFolder {
|
|
31
|
+
path: string;
|
|
32
|
+
openedAt: string;
|
|
33
|
+
}
|
|
34
|
+
/** One opened folder as the status frame reports it. */
|
|
35
|
+
export interface ReportedFolder {
|
|
36
|
+
path: string;
|
|
37
|
+
git: boolean;
|
|
38
|
+
}
|
|
39
|
+
export type FolderResult = {
|
|
40
|
+
ok: true;
|
|
41
|
+
path: string;
|
|
42
|
+
} | {
|
|
43
|
+
ok: false;
|
|
44
|
+
reason: string;
|
|
45
|
+
};
|
|
46
|
+
export declare function foldersPath(env?: NodeJS.ProcessEnv): string;
|
|
47
|
+
/**
|
|
48
|
+
* `input` as an absolute path: `~` and `~/…` are the home directory, any other
|
|
49
|
+
* relative path is refused (there is no "current directory" for a start sent
|
|
50
|
+
* from a phone). Normalised, not yet realpathed.
|
|
51
|
+
*/
|
|
52
|
+
export declare function expandFolder(input: string, home?: string): string | undefined;
|
|
53
|
+
/** The realpath of an existing directory, or `undefined`. */
|
|
54
|
+
export declare function realDirectory(absolute: string): string | undefined;
|
|
55
|
+
/** Whether `child` is `parent` or inside it, by whole path segments (`/a/bc` is not inside `/a/b`). */
|
|
56
|
+
export declare function isWithin(child: string, parent: string): boolean;
|
|
57
|
+
/** The list on disk, newest first; a missing, unreadable or malformed file is empty. */
|
|
58
|
+
export declare function loadFolders(env?: NodeJS.ProcessEnv): OpenedFolder[];
|
|
59
|
+
/** A lock older than this was left by a process that died holding it (a change takes milliseconds). */
|
|
60
|
+
export declare const FOLDERS_LOCK_STALE_MS = 10000;
|
|
61
|
+
/** How long a change waits for another process's change to the list before giving up. */
|
|
62
|
+
export declare const FOLDERS_LOCK_WAIT_MS = 3000;
|
|
63
|
+
/** What a start's move-to-front waits at most: it runs on the daemon's event loop, and may be skipped. */
|
|
64
|
+
export declare const TOUCH_LOCK_WAIT_MS = 100;
|
|
65
|
+
export declare class FoldersBusyError extends Error {
|
|
66
|
+
constructor();
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Remove a stale lock without ever removing a live one: it is moved aside atomically first, then
|
|
70
|
+
* judged by its age again. A file that is no longer stale is another process's fresh lock (it took
|
|
71
|
+
* over the same stale one a moment earlier) and is linked back, unless a lock exists again. Judged
|
|
72
|
+
* by age, never by inode: Linux reuses a deleted file's inode for the next one made.
|
|
73
|
+
*/
|
|
74
|
+
export declare function takeOverStale(lock: string, nowMs?: number): void;
|
|
75
|
+
/**
|
|
76
|
+
* Run `work` (a read-modify-write of `folders.json`) holding the list's lock: `folders.json.lock`,
|
|
77
|
+
* created exclusively. The desktop app's `folders add`, a second `folders add` and the daemon
|
|
78
|
+
* moving a started folder to the front are separate processes; without it, two that read the same
|
|
79
|
+
* list each write it back without the other's folder. A lock left by a process that died is taken
|
|
80
|
+
* over once it is {@link FOLDERS_LOCK_STALE_MS} old.
|
|
81
|
+
*
|
|
82
|
+
* @throws FoldersBusyError when another process holds it past {@link FOLDERS_LOCK_WAIT_MS}.
|
|
83
|
+
*/
|
|
84
|
+
export declare function withFoldersLock<T>(env: NodeJS.ProcessEnv, work: () => T, options?: {
|
|
85
|
+
waitMs?: number;
|
|
86
|
+
now?: () => number;
|
|
87
|
+
}): T;
|
|
88
|
+
/**
|
|
89
|
+
* Open `input` on this Mac (`folders add`): it must be an existing directory;
|
|
90
|
+
* its realpath goes to the front of the list.
|
|
91
|
+
*/
|
|
92
|
+
export declare function addFolder(input: string, env?: NodeJS.ProcessEnv, options?: {
|
|
93
|
+
home?: string;
|
|
94
|
+
now?: Date;
|
|
95
|
+
}): FolderResult;
|
|
96
|
+
/**
|
|
97
|
+
* Forget `input` (`folders remove`), matched as written or by its realpath.
|
|
98
|
+
* Returns the path removed, or `undefined` when it was not in the list.
|
|
99
|
+
*/
|
|
100
|
+
export declare function removeFolder(input: string, env?: NodeJS.ProcessEnv, options?: {
|
|
101
|
+
home?: string;
|
|
102
|
+
}): string | undefined;
|
|
103
|
+
/** A thread started in `folder` (a realpath): it moves to the front of the list. Never throws. */
|
|
104
|
+
export declare function touchFolder(folder: string, env?: NodeJS.ProcessEnv, now?: Date): void;
|
|
105
|
+
/**
|
|
106
|
+
* The folder a start names, held to the rule: an existing directory that is,
|
|
107
|
+
* after realpath, an opened folder or inside one. Returns its realpath, or why
|
|
108
|
+
* it is refused (shown on the thread).
|
|
109
|
+
*/
|
|
110
|
+
export declare function resolveOpenedFolder(input: string, env?: NodeJS.ProcessEnv, options?: {
|
|
111
|
+
home?: string;
|
|
112
|
+
}): FolderResult;
|
|
113
|
+
/**
|
|
114
|
+
* Whether `folder` is in a git work tree, by looking for `.git` in it and each
|
|
115
|
+
* parent — no `git` process per folder per status frame. The start of a
|
|
116
|
+
* separate copy asks git itself.
|
|
117
|
+
*/
|
|
118
|
+
export declare function inGitWorkTree(folder: string): boolean;
|
|
119
|
+
/** The opened folders that still exist, for the status frame (newest first). */
|
|
120
|
+
export declare function reportFolders(env?: NodeJS.ProcessEnv): ReportedFolder[];
|
|
121
|
+
/** The file's mtime and size (`''` when there is none): the host's cheap "did the list change". */
|
|
122
|
+
export declare function foldersStamp(env?: NodeJS.ProcessEnv): string;
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The folders this Mac has opened for Code threads, and the rule a folder
|
|
3
|
+
* start is held to.
|
|
4
|
+
*
|
|
5
|
+
* A folder thread runs in place: in the folder itself, with no worktree and no
|
|
6
|
+
* branch (or, with "Work in a separate copy", in a worktree of the git
|
|
7
|
+
* checkout the folder is in). On this Mac the owner picks any folder in the
|
|
8
|
+
* desktop app's native picker, which records it here
|
|
9
|
+
* (`prohost agent code-host folders add`). From anywhere else — a phone, the
|
|
10
|
+
* web — a start may only name a folder this Mac has opened, or one under it:
|
|
11
|
+
* the server never checks a path; this file is the only list that counts.
|
|
12
|
+
*
|
|
13
|
+
* `$PROHOST_HOME/code-host/folders.json` (0600):
|
|
14
|
+
*
|
|
15
|
+
* { "v": 1, "folders": [{ "path": "/abs/realpath", "openedAt": "<ISO>" }] }
|
|
16
|
+
*
|
|
17
|
+
* Most recent first, at most {@link MAX_OPENED_FOLDERS}. Every path is a
|
|
18
|
+
* realpath, and a start's folder is realpathed before it is compared, by path
|
|
19
|
+
* segments: a symlink inside an opened folder that points out of it does not
|
|
20
|
+
* count as inside.
|
|
21
|
+
*/
|
|
22
|
+
import { closeSync, linkSync, openSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeSync } from 'node:fs';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { codeHostDir, ensurePrivateDir, readJsonObject, writePrivateJson } from './settings.js';
|
|
26
|
+
export const FOLDERS_VERSION = 1;
|
|
27
|
+
export const MAX_OPENED_FOLDERS = 50;
|
|
28
|
+
/** The server's bound on a start's `folder` (CONTRACT: ≤ 1024 chars). */
|
|
29
|
+
export const MAX_FOLDER_PATH_CHARS = 1024;
|
|
30
|
+
/** What a start's thread says when its folder is not one this Mac opened (the contract's words). */
|
|
31
|
+
export const FOLDER_NOT_OPENED = "This folder hasn't been opened on this Mac. Open it from the Prohost desktop app first.";
|
|
32
|
+
/** What a separate-copy start says when its folder is not in a git checkout (the contract's words). */
|
|
33
|
+
export const SEPARATE_COPY_NEEDS_GIT = 'Work in a separate copy needs a git repository.';
|
|
34
|
+
export function foldersPath(env = process.env) {
|
|
35
|
+
return path.join(codeHostDir(env), 'folders.json');
|
|
36
|
+
}
|
|
37
|
+
/** NUL, C0 controls and DEL: never part of a folder this CLI accepts. */
|
|
38
|
+
const CONTROL = /[\u0000-\u001f\u007f]/;
|
|
39
|
+
/**
|
|
40
|
+
* `input` as an absolute path: `~` and `~/…` are the home directory, any other
|
|
41
|
+
* relative path is refused (there is no "current directory" for a start sent
|
|
42
|
+
* from a phone). Normalised, not yet realpathed.
|
|
43
|
+
*/
|
|
44
|
+
export function expandFolder(input, home = os.homedir()) {
|
|
45
|
+
if (typeof input !== 'string' || !input || input.length > MAX_FOLDER_PATH_CHARS || CONTROL.test(input))
|
|
46
|
+
return undefined;
|
|
47
|
+
let expanded;
|
|
48
|
+
if (input === '~')
|
|
49
|
+
expanded = home;
|
|
50
|
+
else if (input.startsWith('~/'))
|
|
51
|
+
expanded = path.join(home, input.slice(2));
|
|
52
|
+
else if (path.isAbsolute(input))
|
|
53
|
+
expanded = input;
|
|
54
|
+
else
|
|
55
|
+
return undefined;
|
|
56
|
+
return path.resolve(expanded);
|
|
57
|
+
}
|
|
58
|
+
/** The realpath of an existing directory, or `undefined`. */
|
|
59
|
+
export function realDirectory(absolute) {
|
|
60
|
+
try {
|
|
61
|
+
const real = realpathSync.native(absolute);
|
|
62
|
+
return statSync(real).isDirectory() ? real : undefined;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Whether `child` is `parent` or inside it, by whole path segments (`/a/bc` is not inside `/a/b`). */
|
|
69
|
+
export function isWithin(child, parent) {
|
|
70
|
+
const relative = path.relative(parent, child);
|
|
71
|
+
if (relative === '')
|
|
72
|
+
return true;
|
|
73
|
+
if (path.isAbsolute(relative))
|
|
74
|
+
return false;
|
|
75
|
+
return relative.split(path.sep)[0] !== '..';
|
|
76
|
+
}
|
|
77
|
+
/** The list on disk, newest first; a missing, unreadable or malformed file is empty. */
|
|
78
|
+
export function loadFolders(env = process.env) {
|
|
79
|
+
const raw = readJsonObject(foldersPath(env));
|
|
80
|
+
if (!raw || raw.v !== FOLDERS_VERSION || !Array.isArray(raw.folders))
|
|
81
|
+
return [];
|
|
82
|
+
const folders = [];
|
|
83
|
+
for (const entry of raw.folders) {
|
|
84
|
+
if (!entry || typeof entry !== 'object')
|
|
85
|
+
continue;
|
|
86
|
+
const { path: folder, openedAt } = entry;
|
|
87
|
+
if (typeof folder !== 'string' || !path.isAbsolute(folder) || folder.length > MAX_FOLDER_PATH_CHARS)
|
|
88
|
+
continue;
|
|
89
|
+
if (CONTROL.test(folder) || folders.some((seen) => seen.path === folder))
|
|
90
|
+
continue;
|
|
91
|
+
folders.push({ path: folder, openedAt: typeof openedAt === 'string' ? openedAt : new Date(0).toISOString() });
|
|
92
|
+
if (folders.length >= MAX_OPENED_FOLDERS)
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
return folders;
|
|
96
|
+
}
|
|
97
|
+
/** Unlocked: only ever called inside {@link withFoldersLock}, so not exported. */
|
|
98
|
+
function saveFolders(folders, env) {
|
|
99
|
+
writePrivateJson(foldersPath(env), { v: FOLDERS_VERSION, folders: folders.slice(0, MAX_OPENED_FOLDERS) });
|
|
100
|
+
}
|
|
101
|
+
/** A lock older than this was left by a process that died holding it (a change takes milliseconds). */
|
|
102
|
+
export const FOLDERS_LOCK_STALE_MS = 10_000;
|
|
103
|
+
/** How long a change waits for another process's change to the list before giving up. */
|
|
104
|
+
export const FOLDERS_LOCK_WAIT_MS = 3_000;
|
|
105
|
+
/** What a start's move-to-front waits at most: it runs on the daemon's event loop, and may be skipped. */
|
|
106
|
+
export const TOUCH_LOCK_WAIT_MS = 100;
|
|
107
|
+
export class FoldersBusyError extends Error {
|
|
108
|
+
constructor() {
|
|
109
|
+
super('The opened-folders list is busy (another prohost is changing it). Try again.');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Remove a stale lock without ever removing a live one: it is moved aside atomically first, then
|
|
114
|
+
* judged by its age again. A file that is no longer stale is another process's fresh lock (it took
|
|
115
|
+
* over the same stale one a moment earlier) and is linked back, unless a lock exists again. Judged
|
|
116
|
+
* by age, never by inode: Linux reuses a deleted file's inode for the next one made.
|
|
117
|
+
*/
|
|
118
|
+
export function takeOverStale(lock, nowMs = Date.now()) {
|
|
119
|
+
const aside = `${lock}.${process.pid}.${Math.random().toString(16).slice(2)}.stale`;
|
|
120
|
+
try {
|
|
121
|
+
renameSync(lock, aside);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return; // someone else moved or released it
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
if (nowMs - statSync(aside).mtimeMs <= FOLDERS_LOCK_STALE_MS) {
|
|
128
|
+
try {
|
|
129
|
+
linkSync(aside, lock);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
/* a newer lock is there: that one stands */
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
rmSync(aside, { force: true });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function sleepSync(ms) {
|
|
141
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Run `work` (a read-modify-write of `folders.json`) holding the list's lock: `folders.json.lock`,
|
|
145
|
+
* created exclusively. The desktop app's `folders add`, a second `folders add` and the daemon
|
|
146
|
+
* moving a started folder to the front are separate processes; without it, two that read the same
|
|
147
|
+
* list each write it back without the other's folder. A lock left by a process that died is taken
|
|
148
|
+
* over once it is {@link FOLDERS_LOCK_STALE_MS} old.
|
|
149
|
+
*
|
|
150
|
+
* @throws FoldersBusyError when another process holds it past {@link FOLDERS_LOCK_WAIT_MS}.
|
|
151
|
+
*/
|
|
152
|
+
export function withFoldersLock(env, work, options = {}) {
|
|
153
|
+
const lock = `${foldersPath(env)}.lock`;
|
|
154
|
+
ensurePrivateDir(path.dirname(lock));
|
|
155
|
+
const now = options.now ?? Date.now;
|
|
156
|
+
const deadline = now() + (options.waitMs ?? FOLDERS_LOCK_WAIT_MS);
|
|
157
|
+
// Released only while the file is still this one: never a lock another process holds now.
|
|
158
|
+
const token = `${process.pid}.${Math.random().toString(16).slice(2)}`;
|
|
159
|
+
for (;;) {
|
|
160
|
+
try {
|
|
161
|
+
const fd = openSync(lock, 'wx', 0o600);
|
|
162
|
+
try {
|
|
163
|
+
writeSync(fd, token);
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
closeSync(fd);
|
|
167
|
+
}
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
if (err.code !== 'EEXIST')
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
if (now() - statSync(lock).mtimeMs > FOLDERS_LOCK_STALE_MS) {
|
|
176
|
+
takeOverStale(lock, now());
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
continue; // released between the open and the stat: try again at once
|
|
182
|
+
}
|
|
183
|
+
if (now() >= deadline)
|
|
184
|
+
throw new FoldersBusyError();
|
|
185
|
+
sleepSync(10 + Math.floor(Math.random() * 20));
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
return work();
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
try {
|
|
192
|
+
if (readFileSync(lock, 'utf8') === token)
|
|
193
|
+
rmSync(lock, { force: true });
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
/* already gone */
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/** `folder` (a realpath) at the front, opened now; the oldest past the cap drops off. */
|
|
201
|
+
function upsertFront(folder, env, now, waitMs) {
|
|
202
|
+
withFoldersLock(env, () => {
|
|
203
|
+
const rest = loadFolders(env).filter((entry) => entry.path !== folder);
|
|
204
|
+
saveFolders([{ path: folder, openedAt: now.toISOString() }, ...rest], env);
|
|
205
|
+
}, { waitMs });
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Open `input` on this Mac (`folders add`): it must be an existing directory;
|
|
209
|
+
* its realpath goes to the front of the list.
|
|
210
|
+
*/
|
|
211
|
+
export function addFolder(input, env = process.env, options = {}) {
|
|
212
|
+
const absolute = expandFolder(input, options.home);
|
|
213
|
+
if (!absolute)
|
|
214
|
+
return { ok: false, reason: 'Give an absolute path (or one starting with ~/).' };
|
|
215
|
+
const real = realDirectory(absolute);
|
|
216
|
+
if (!real)
|
|
217
|
+
return { ok: false, reason: `There's no folder at ${absolute}.` };
|
|
218
|
+
try {
|
|
219
|
+
upsertFront(real, env, options.now ?? new Date());
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
if (err instanceof FoldersBusyError)
|
|
223
|
+
return { ok: false, reason: err.message };
|
|
224
|
+
throw err;
|
|
225
|
+
}
|
|
226
|
+
return { ok: true, path: real };
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Forget `input` (`folders remove`), matched as written or by its realpath.
|
|
230
|
+
* Returns the path removed, or `undefined` when it was not in the list.
|
|
231
|
+
*/
|
|
232
|
+
export function removeFolder(input, env = process.env, options = {}) {
|
|
233
|
+
const absolute = expandFolder(input, options.home);
|
|
234
|
+
if (!absolute)
|
|
235
|
+
return undefined;
|
|
236
|
+
const real = realDirectory(absolute);
|
|
237
|
+
return withFoldersLock(env, () => {
|
|
238
|
+
const folders = loadFolders(env);
|
|
239
|
+
const match = folders.find((entry) => entry.path === absolute || (real !== undefined && entry.path === real));
|
|
240
|
+
if (!match)
|
|
241
|
+
return undefined;
|
|
242
|
+
saveFolders(folders.filter((entry) => entry !== match), env);
|
|
243
|
+
return match.path;
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
/** A thread started in `folder` (a realpath): it moves to the front of the list. Never throws. */
|
|
247
|
+
export function touchFolder(folder, env = process.env, now = new Date()) {
|
|
248
|
+
try {
|
|
249
|
+
// Best effort, on the daemon's event loop: never wait long for another process's change.
|
|
250
|
+
upsertFront(folder, env, now, TOUCH_LOCK_WAIT_MS);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
/* the thread runs either way; the list is only what phones are offered */
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* The folder a start names, held to the rule: an existing directory that is,
|
|
258
|
+
* after realpath, an opened folder or inside one. Returns its realpath, or why
|
|
259
|
+
* it is refused (shown on the thread).
|
|
260
|
+
*/
|
|
261
|
+
export function resolveOpenedFolder(input, env = process.env, options = {}) {
|
|
262
|
+
const absolute = expandFolder(input, options.home);
|
|
263
|
+
if (!absolute)
|
|
264
|
+
return { ok: false, reason: FOLDER_NOT_OPENED };
|
|
265
|
+
const real = realDirectory(absolute);
|
|
266
|
+
if (!real)
|
|
267
|
+
return { ok: false, reason: `There's no folder at ${absolute} on this Mac.` };
|
|
268
|
+
for (const opened of loadFolders(env)) {
|
|
269
|
+
// An entry is a realpath when it is written; re-read in case what it names moved since.
|
|
270
|
+
const roots = new Set([opened.path, realDirectory(opened.path) ?? opened.path]);
|
|
271
|
+
for (const root of roots)
|
|
272
|
+
if (isWithin(real, root))
|
|
273
|
+
return { ok: true, path: real };
|
|
274
|
+
}
|
|
275
|
+
return { ok: false, reason: FOLDER_NOT_OPENED };
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Whether `folder` is in a git work tree, by looking for `.git` in it and each
|
|
279
|
+
* parent — no `git` process per folder per status frame. The start of a
|
|
280
|
+
* separate copy asks git itself.
|
|
281
|
+
*/
|
|
282
|
+
export function inGitWorkTree(folder) {
|
|
283
|
+
let current = folder;
|
|
284
|
+
for (;;) {
|
|
285
|
+
try {
|
|
286
|
+
statSync(path.join(current, '.git'));
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
/* not here */
|
|
291
|
+
}
|
|
292
|
+
const parent = path.dirname(current);
|
|
293
|
+
if (parent === current)
|
|
294
|
+
return false;
|
|
295
|
+
current = parent;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/** The opened folders that still exist, for the status frame (newest first). */
|
|
299
|
+
export function reportFolders(env = process.env) {
|
|
300
|
+
const reported = [];
|
|
301
|
+
for (const { path: folder } of loadFolders(env)) {
|
|
302
|
+
if (realDirectory(folder) !== folder)
|
|
303
|
+
continue;
|
|
304
|
+
reported.push({ path: folder, git: inGitWorkTree(folder) });
|
|
305
|
+
}
|
|
306
|
+
return reported;
|
|
307
|
+
}
|
|
308
|
+
/** The file's mtime and size (`''` when there is none): the host's cheap "did the list change". */
|
|
309
|
+
export function foldersStamp(env = process.env) {
|
|
310
|
+
try {
|
|
311
|
+
const stat = statSync(foldersPath(env));
|
|
312
|
+
return `${stat.mtimeMs}:${stat.size}`;
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
return '';
|
|
316
|
+
}
|
|
317
|
+
}
|