@anhlp/alp 0.0.1-beta.3 → 0.0.1-beta.4
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/README.md +18 -16
- package/dist/acp.js +37 -4
- package/dist/alpd.js +10350 -3346
- package/docs/modular-monorepo.md +17 -13
- package/package.json +8 -6
- package/packages/cli/dist/THIRD-PARTY-NOTICES.txt +25 -0
- package/packages/cli/dist/acp/main.js +1 -1
- package/packages/cli/dist/acp.js +37 -4
- package/packages/cli/dist/alp-web/THIRD-PARTY-NOTICES.txt +2590 -0
- package/packages/cli/dist/alp-web/alp-boundary.json +15 -0
- package/packages/cli/dist/alp-web/apple-touch-icon.png +0 -0
- package/packages/cli/dist/alp-web/assets/TerminalPanel-DLuoa74B.css +1 -0
- package/packages/cli/dist/alp-web/assets/TerminalPanel-PrU2l3u5.js +16 -0
- package/packages/cli/dist/alp-web/assets/index-BJdBTDFX.css +1 -0
- package/packages/cli/dist/alp-web/assets/index-DpfiPv5s.js +52 -0
- package/packages/cli/dist/alp-web/favicon-attention.svg +6 -0
- package/packages/cli/dist/alp-web/favicon-running.svg +6 -0
- package/packages/cli/dist/alp-web/favicon.svg +5 -0
- package/packages/cli/dist/alp-web/index.html +20 -0
- package/packages/cli/dist/alp-web.js +9640 -0
- package/packages/cli/dist/alpd.js +10350 -3346
- package/packages/cli/dist/backup.js +2 -2
- package/packages/cli/dist/build-manifest.json +99 -1
- package/packages/cli/dist/cli.js +4 -2
- package/packages/cli/dist/interfaces.d.ts +9 -1
- package/packages/cli/dist/interfaces.js +99 -39
- package/packages/cli/dist/schedules.d.ts +2 -0
- package/packages/cli/dist/schedules.js +45 -0
- package/packages/cli/dist/sessions.d.ts +3 -1
- package/packages/cli/dist/sessions.js +22 -11
- package/packages/cli/dist/terminal.d.ts +8 -0
- package/packages/cli/dist/terminal.js +79 -0
- package/packages/cli/dist/usage.d.ts +1 -1
- package/packages/cli/dist/usage.js +8 -2
- package/packages/cli/dist/web-service.d.ts +38 -0
- package/packages/cli/dist/web-service.js +119 -0
- package/packages/client/README.md +6 -1
- package/packages/client/dist/index.d.ts +37 -2
- package/packages/client/dist/index.js +55 -22
- package/packages/client/dist/integration-lock.d.ts +35 -0
- package/packages/client/dist/integration-lock.js +179 -0
- package/packages/client/dist/portable.d.ts +1 -0
- package/packages/client/dist/portable.js +7 -1
- package/packages/client/dist/rpc/registry.d.ts +211 -0
- package/packages/client/dist/rpc/registry.js +145 -0
- package/packages/client/dist/supervise.d.ts +2 -0
- package/packages/client/dist/supervise.js +4 -4
- package/packages/core/dist/backup.js +103 -4
- package/packages/core/dist/index.d.ts +1 -0
- package/packages/core/dist/index.js +2 -0
- package/packages/core/dist/migrations.js +1 -1
- package/packages/core/dist/user-worktree.d.ts +29 -0
- package/packages/core/dist/user-worktree.js +242 -0
- package/packages/core/dist/validation.js +3 -2
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export const USAGE: "Usage:\n alp <init|upgrade|update> [directory]\n alp setup [directory] [--runtime codex|claude|mix] [--prefer claude|codex]\n [--claude-model M] [--codex-model M] [--claude-supervisor M] [--codex-supervisor M]\n [--claude-oracle M] [--codex-oracle M] write Ph\u1EDF/Cafe's agent profiles to .alp/settings.json\n alp daemon <start|stop|restart>\n alp daemon status [--json] liveness and readiness; exit 0 ready, 3 not running, 4 not ready\n alp daemon <install|uninstall> run alpd as a login service that restarts after a crash\n alp acp serve ALP to an editor (Zed, JetBrains) over the Agent Client Protocol on stdio\n alp web [--print] open the
|
|
1
|
+
export const USAGE: "Usage:\n alp <init|upgrade|update> [directory]\n alp setup [directory] [--runtime codex|claude|mix] [--prefer claude|codex]\n [--claude-model M] [--codex-model M] [--claude-supervisor M] [--codex-supervisor M]\n [--claude-oracle M] [--codex-oracle M] write Ph\u1EDF/Cafe's agent profiles to .alp/settings.json\n alp daemon <start|stop|restart>\n alp daemon status [--json] liveness and readiness; exit 0 ready, 3 not running, 4 not ready\n alp daemon <install|uninstall> run alpd as a login service that restarts after a crash\n alp acp serve ALP to an editor (Zed, JetBrains) over the Agent Client Protocol on stdio\n alp web [--print] open the ALP web app on this machine (alp-web, loopback); --print only prints its address\n alp web rotate give alp web a new token: open pages lose access; prints the new address\n alp web pair [--on|--off] the link that pairs Paseo's app on another device through the relay; --on or --off turns the relay on or off\n alp terminal <allow [--paseo] --yes|deny [--paseo]|status> [--json] remote terminals (a shell as you on this host; a workspace is not a sandbox), off by default\n alp doctor [--project DIR] [--fix] [--json] check this machine and project; --fix repairs what is safe to\n alp backup [-o FILE] [--project DIR]... [--no-projects] [--stop] [--json] sessions, settings, library and tasks in one file\n alp backup verify <file> [--json] | alp backup list [--json]\n alp restore <file> [--home-only] [--dry-run] [--stop] [--replace-newer] [--json] checks the whole backup first; keeps what it replaces\n alpd must be stopped for both; --stop stops it and starts it again after\n alp run [--agent A] [--team pho|cafe|ID] [--model M] [--mode read-only|workspace-write|full-access] [--thinking T] [--project DIR] [--json] <prompt>\n alp schedule create --session ID --at ISO_WITH_OFFSET --prompt TEXT [--project DIR] [--json]\n alp schedule list [--status pending|delivered|failed|cancelled] [--project DIR] [--json]\n alp schedule <show|cancel> ID [--project DIR] [--json] one-shot wake; timer gates only unblock tasks\n alp ps [--all]\n alp top [session] [--once]\n alp attach <session> [--json]\n alp send <session> [--prompt] [--json] <text>\n child default: user mail; --prompt: explicit new prompt to a live root or child\n alp questions [--json]\n alp answer <question> <text> | alp answer <question> --dismiss [--reason R]\n alp log <session> [--json]\n alp board [--project DIR] [--json]\n alp <agents|skills|mcp|hooks> [--project DIR] [--json] the library: built-in, ~/.alp and project entries\n alp providers [--json] ACP and Antigravity providers in your library\n alp teams [--project DIR] [--json] teams sessions run in: members, delegation, house rules\n alp agent <new|edit> <name> [--project [DIR]] [--from A] [-d text] [--provider P] [--model M] [--thinking T] [--mode M]\n [--skills a,b] [--mcp a,b] [--hooks a,b] [--instructions FILE]\n alp team <new|edit> <id> [--project [DIR]] [--from ID] [--label L] [-d text] [--main A] [--member name=role]...\n [--member-model name=M]... [--member-thinking name=T]... [--delegate owner=a,b]... [--max-peers N]\n [--supervisor A] [--supervisor-model M] [--supervisor-thinking T] [--no-supervisor] [--rules FILE]\n alp skill new <name> [--project [DIR]] [--from S] [--file FILE]\n alp mcp add <name> [--project [DIR]] (--command C [--arg A]... [--env K=V]... [--cwd D] | --url U [--header K=V]...)\n alp hook add <name> [--project [DIR]] --event E --command C [--blocking] [--timeout SEC] [--match-agent A] [--match-label L]\n alp provider add <id> --command C [--arg A]... [--env K=V]... [--label L] [-d text] [--models a,b] an ACP agent; use id antigravity for native agy\n alp provider edit antigravity --read-only-mode instructions|reject opt into prompt-only read-only for advisory roles\n alp <agent|team|skill|mcp|hook|provider> <rm|show> <name> [--project [DIR]] | <cp|mv> <from> <to> [--project [DIR]]\n alp <mcp|hook|provider> test <name> [--project [DIR]] list an MCP server's tools; run a hook once; initialize a provider\n without --project, changes go to your library in ~/.alp; built-in agents and teams are never changed\n alp trust [--project DIR] | --revoke [--project DIR] | --list [--json] whether the project's hooks may run\n alp permissions [--project DIR] [--json] each agent's permission profile\n alp permissions check <agent> \"<command>\" what that agent's profile says about a command\n alp tasks [ready] [--all] [--status S] [--label L] [--project DIR] [--json]\n alp tasks gates [--json] open gates; checks GitHub ones\n alp tasks compact [--days 30] [--dry-run]\n alp tasks export [-o file.jsonl] as beads JSONL\n alp tasks import [file.jsonl] [--dry-run] beads JSONL; default .beads/issues.jsonl\n alp formula list | show <name> | pour <name> [--var k=v]... [--parent ID] [--dry-run]\n alp task add <title> [-d text] [-p 0-4] [-t task|bug|feature|chore|epic] [--parent ID] [--after ID]... [-l label]... [--path P]... [--from ID]\n alp task show <id> [--json]\n alp task report <epic> [--json] what an epic came to: tasks, time, rework, verification\n alp task edit <id> [--title T] [-d text] [-p N] [-t type] [-l label]... [--path P]... [-m note]\n alp task close <id> [--reason done|wontfix|duplicate|superseded] [-m summary] [--unverified \"why\"]\n alp verify [--project DIR] [--task ID] [--json] run the project's verify commands; --task records the result\n alp task reopen <id> [-m note]\n alp task dep <add|rm> <id> [--after ID]... [--parent ID] [--related ID]...\n alp task gate add <id> --human \"question\" | --timer +2h|ISO | --pr N|owner/repo#N | --run N|owner/repo#N\n alp task gate <clear|rm> <id> <gate> [-m note]\n alp pause [codex|claude] [--now] [-m reason] | alp pause status hold delegation; --now parks running assignments\n alp resume [codex|claude] lift a pause; parked assignments continue\n alp language [name | --reset] the language agents and ALP write to you in; default Vietnamese\n alp recall <assignment|task> [--project DIR] [--json] <question> ask a finished assignment about its work\n alp interrupt <session>";
|
|
2
2
|
export class UsageError extends Error {
|
|
3
3
|
}
|
|
@@ -8,18 +8,24 @@ var USAGE = `Usage:
|
|
|
8
8
|
alp daemon status [--json] liveness and readiness; exit 0 ready, 3 not running, 4 not ready
|
|
9
9
|
alp daemon <install|uninstall> run alpd as a login service that restarts after a crash
|
|
10
10
|
alp acp serve ALP to an editor (Zed, JetBrains) over the Agent Client Protocol on stdio
|
|
11
|
-
alp web [--print] open the
|
|
11
|
+
alp web [--print] open the ALP web app on this machine (alp-web, loopback); --print only prints its address
|
|
12
|
+
alp web rotate give alp web a new token: open pages lose access; prints the new address
|
|
12
13
|
alp web pair [--on|--off] the link that pairs Paseo's app on another device through the relay; --on or --off turns the relay on or off
|
|
14
|
+
alp terminal <allow [--paseo] --yes|deny [--paseo]|status> [--json] remote terminals (a shell as you on this host; a workspace is not a sandbox), off by default
|
|
13
15
|
alp doctor [--project DIR] [--fix] [--json] check this machine and project; --fix repairs what is safe to
|
|
14
16
|
alp backup [-o FILE] [--project DIR]... [--no-projects] [--stop] [--json] sessions, settings, library and tasks in one file
|
|
15
17
|
alp backup verify <file> [--json] | alp backup list [--json]
|
|
16
18
|
alp restore <file> [--home-only] [--dry-run] [--stop] [--replace-newer] [--json] checks the whole backup first; keeps what it replaces
|
|
17
19
|
alpd must be stopped for both; --stop stops it and starts it again after
|
|
18
20
|
alp run [--agent A] [--team pho|cafe|ID] [--model M] [--mode read-only|workspace-write|full-access] [--thinking T] [--project DIR] [--json] <prompt>
|
|
21
|
+
alp schedule create --session ID --at ISO_WITH_OFFSET --prompt TEXT [--project DIR] [--json]
|
|
22
|
+
alp schedule list [--status pending|delivered|failed|cancelled] [--project DIR] [--json]
|
|
23
|
+
alp schedule <show|cancel> ID [--project DIR] [--json] one-shot wake; timer gates only unblock tasks
|
|
19
24
|
alp ps [--all]
|
|
20
25
|
alp top [session] [--once]
|
|
21
26
|
alp attach <session> [--json]
|
|
22
|
-
alp send <session> [--json] <text>
|
|
27
|
+
alp send <session> [--prompt] [--json] <text>
|
|
28
|
+
child default: user mail; --prompt: explicit new prompt to a live root or child
|
|
23
29
|
alp questions [--json]
|
|
24
30
|
alp answer <question> <text> | alp answer <question> --dismiss [--reason R]
|
|
25
31
|
alp log <session> [--json]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** The alp-web service script beside this CLI; a clear error when this CLI was built without it. */
|
|
2
|
+
export function alpWebEntry(): string;
|
|
3
|
+
/**
|
|
4
|
+
* The process that owns alp web's lock, if it is proven safe to stop; else why not. `kind` says
|
|
5
|
+
* whether waiting can help: `transient` (an owner still starting has not published web.json yet),
|
|
6
|
+
* `foreign` (proven not to be the integration: another user's, another boot's, or a reused pid) or
|
|
7
|
+
* `unknown` (it cannot be proven either way, for example without ps or on Windows).
|
|
8
|
+
*
|
|
9
|
+
* The reuse proof compares the lock's wall-clock `startedAt` with the process's start from ps: a
|
|
10
|
+
* wall clock stepped back by more than two seconds after the lock was written could make a reused
|
|
11
|
+
* pid look older than the lock. A step forward only refuses.
|
|
12
|
+
* @param {string} home
|
|
13
|
+
* @returns {Promise<{ pid: number, app: unknown } | { reason: string, alive: boolean, kind?: 'transient' | 'foreign' | 'unknown' }>}
|
|
14
|
+
*/
|
|
15
|
+
export function webOwner(home: string): Promise<{
|
|
16
|
+
pid: number;
|
|
17
|
+
app: unknown;
|
|
18
|
+
} | {
|
|
19
|
+
reason: string;
|
|
20
|
+
alive: boolean;
|
|
21
|
+
kind?: "transient" | "foreign" | "unknown";
|
|
22
|
+
}>;
|
|
23
|
+
/**
|
|
24
|
+
* Stops the Paseo integration from before its rename when it still runs as integration `web` (its
|
|
25
|
+
* web.json has no `app: 'alp-web'`), so alp-web can take the lock and web.port. Returns the pid it
|
|
26
|
+
* stopped. A process that cannot be proven to be that integration is left alone.
|
|
27
|
+
* @param {string} home
|
|
28
|
+
*/
|
|
29
|
+
export function stopPreRenamePaseo(home: string): Promise<number | undefined>;
|
|
30
|
+
/**
|
|
31
|
+
* Stops whatever runs as integration `web` (alp-web, or the Paseo integration from before its
|
|
32
|
+
* rename), waiting up to `timeoutMs` for a starting owner to publish what proves it. Resolves once
|
|
33
|
+
* no live process owns the lock; refuses at once an owner that is foreign or cannot be proven.
|
|
34
|
+
* @param {string} home @param {number} [timeoutMs]
|
|
35
|
+
*/
|
|
36
|
+
export function stopWebService(home: string, timeoutMs?: number): Promise<number | undefined>;
|
|
37
|
+
/** The opening of the message for a web lock held by a process alp would not stop. @param {string} home */
|
|
38
|
+
export function refusal(home: string): string;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// packages/cli/src/web-service.js
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { bootTime, readLock } from "@anhlp/alp-client/index";
|
|
9
|
+
import { distributionEntry } from "./paths.js";
|
|
10
|
+
var execFileAsync = promisify(execFile);
|
|
11
|
+
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
12
|
+
function alpWebEntry() {
|
|
13
|
+
const entry = fileURLToPath(distributionEntry("alp-web.js"));
|
|
14
|
+
if (!existsSync(entry)) throw new Error(`alp web is not built: ${entry} is missing. Build the CLI (npm run build in the ALP checkout) or reinstall ALP.`);
|
|
15
|
+
return entry;
|
|
16
|
+
}
|
|
17
|
+
var readJson = async (file) => JSON.parse(await readFile(file, "utf8"));
|
|
18
|
+
async function processStart(pid) {
|
|
19
|
+
try {
|
|
20
|
+
const { stdout } = await execFileAsync("ps", ["-o", "etime=", "-p", String(pid)], { encoding: "utf8", timeout: 5e3 });
|
|
21
|
+
const match = /^\s*(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)\s*$/.exec(stdout);
|
|
22
|
+
if (!match) return void 0;
|
|
23
|
+
const [, days = "0", hours = "0", minutes, seconds] = match;
|
|
24
|
+
const elapsed = ((Number(days) * 24 + Number(hours)) * 60 + Number(minutes)) * 60 + Number(seconds);
|
|
25
|
+
return Date.now() - elapsed * 1e3;
|
|
26
|
+
} catch {
|
|
27
|
+
return void 0;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function webOwner(home) {
|
|
31
|
+
const lockFile = path.join(home, "integrations", "web.lock");
|
|
32
|
+
const infoFile = path.join(home, "web.json");
|
|
33
|
+
let lock, lockInfo;
|
|
34
|
+
try {
|
|
35
|
+
lockInfo = await lstat(lockFile);
|
|
36
|
+
lock = await readJson(lockFile);
|
|
37
|
+
} catch {
|
|
38
|
+
return { reason: `${lockFile} is missing or unreadable`, alive: false };
|
|
39
|
+
}
|
|
40
|
+
if (!lockInfo.isFile() || !Number.isSafeInteger(lock?.pid) || lock.pid <= 0 || typeof lock.bootTime !== "number" || !Number.isFinite(Date.parse(lock.startedAt))) {
|
|
41
|
+
return { reason: `${lockFile} is not a complete integration lock`, alive: false };
|
|
42
|
+
}
|
|
43
|
+
const { pid } = lock;
|
|
44
|
+
try {
|
|
45
|
+
process.kill(pid, 0);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error?.code === "ESRCH") return { reason: `its pid ${pid} has exited`, alive: false };
|
|
48
|
+
if (error?.code === "EPERM") return { reason: `pid ${pid} belongs to another user`, alive: true, kind: "foreign" };
|
|
49
|
+
return { reason: `pid ${pid} cannot be signalled (${error?.code ?? error})`, alive: true, kind: "unknown" };
|
|
50
|
+
}
|
|
51
|
+
const refuse = (reason, kind) => ({ reason, alive: true, kind });
|
|
52
|
+
if (process.platform === "win32") return refuse("alp does not stop it on Windows", "unknown");
|
|
53
|
+
const uid = process.getuid?.();
|
|
54
|
+
if (lockInfo.uid !== uid) return refuse("its lock is not yours", "foreign");
|
|
55
|
+
if (Math.abs(lock.bootTime - bootTime()) > 30) return refuse("its lock is from another boot", "foreign");
|
|
56
|
+
if (pid === process.pid || pid === (await readLock(home).catch(() => void 0))?.pid) return refuse(`pid ${pid} is not an integration`, "foreign");
|
|
57
|
+
const started = await processStart(pid);
|
|
58
|
+
if (started === void 0) return refuse(`the start time of pid ${pid} is unknown`, "unknown");
|
|
59
|
+
if (started > Date.parse(lock.startedAt) + 2e3) return refuse(`pid ${pid} started after the lock was written (a reused pid)`, "foreign");
|
|
60
|
+
let info, infoStat;
|
|
61
|
+
try {
|
|
62
|
+
infoStat = await lstat(infoFile);
|
|
63
|
+
info = await readJson(infoFile);
|
|
64
|
+
} catch {
|
|
65
|
+
return refuse(`${infoFile} is missing or unreadable`, "transient");
|
|
66
|
+
}
|
|
67
|
+
if (info?.pid !== pid) return refuse(`${infoFile} names another process than the lock`, "transient");
|
|
68
|
+
if (!infoStat.isFile() || infoStat.uid !== uid) return refuse(`${infoFile} is not yours`, "foreign");
|
|
69
|
+
return { pid, app: info.app };
|
|
70
|
+
}
|
|
71
|
+
async function terminate(pid, what, timeoutMs = 2e4) {
|
|
72
|
+
try {
|
|
73
|
+
process.kill(pid, "SIGTERM");
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error?.code === "ESRCH") return;
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
const deadline = Date.now() + timeoutMs;
|
|
79
|
+
while (Date.now() < deadline) {
|
|
80
|
+
try {
|
|
81
|
+
process.kill(pid, 0);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (error?.code === "ESRCH") return;
|
|
84
|
+
}
|
|
85
|
+
await sleep(100);
|
|
86
|
+
}
|
|
87
|
+
throw new Error(`${what} (pid ${pid}) did not stop within ${timeoutMs / 1e3} s of SIGTERM; stop it, then run the command again`);
|
|
88
|
+
}
|
|
89
|
+
var isAlpWeb = (app) => app === "alp-web";
|
|
90
|
+
var PASEO_BEFORE_RENAME = "The Paseo integration from before its rename";
|
|
91
|
+
async function stopPreRenamePaseo(home) {
|
|
92
|
+
const owner = await webOwner(home);
|
|
93
|
+
if (!("pid" in owner) || isAlpWeb(owner.app)) return void 0;
|
|
94
|
+
await terminate(owner.pid, PASEO_BEFORE_RENAME);
|
|
95
|
+
return owner.pid;
|
|
96
|
+
}
|
|
97
|
+
async function stopWebService(home, timeoutMs = 3e4) {
|
|
98
|
+
const deadline = Date.now() + timeoutMs;
|
|
99
|
+
for (; ; ) {
|
|
100
|
+
const owner = await webOwner(home);
|
|
101
|
+
if ("pid" in owner) {
|
|
102
|
+
await terminate(owner.pid, isAlpWeb(owner.app) ? "alp web" : PASEO_BEFORE_RENAME);
|
|
103
|
+
return owner.pid;
|
|
104
|
+
}
|
|
105
|
+
if (!owner.alive) return void 0;
|
|
106
|
+
if (owner.kind !== "transient" || Date.now() >= deadline) throw new Error(`${refusal(home)} (${owner.reason}); stop it, then run the command again`);
|
|
107
|
+
await sleep(200);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function refusal(home) {
|
|
111
|
+
return `${path.join(home, "integrations", "web.lock")} is held by a process that alp cannot prove is alp web or the Paseo integration from before its rename, so it was left running`;
|
|
112
|
+
}
|
|
113
|
+
export {
|
|
114
|
+
alpWebEntry,
|
|
115
|
+
refusal,
|
|
116
|
+
stopPreRenamePaseo,
|
|
117
|
+
stopWebService,
|
|
118
|
+
webOwner
|
|
119
|
+
};
|
|
@@ -11,13 +11,18 @@ and setup helpers.
|
|
|
11
11
|
- `@anhlp/alp-client` (Node): everything above plus `connect`, `AlpClient`, `connectSocket`,
|
|
12
12
|
`ensureDaemon`, the private-socket checks and lock helpers, unchanged for existing callers.
|
|
13
13
|
- `@anhlp/alp-client/ipc` (Node): `createSocketTransport` for a Unix socket or named pipe.
|
|
14
|
+
- `@anhlp/alp-client/integration-lock` (Node): `createIntegrationLock(home, name)`, the single-owner
|
|
15
|
+
lock of one integration service at `<home>/integrations/<name>.lock`, serialized by the exclusive
|
|
16
|
+
guard directory `<name>.lock.reclaim`. Each instance is single-use (`acquire`, then `setReady` and
|
|
17
|
+
`release` while it owns the lock); its recovery rules are in
|
|
18
|
+
[docs/modular-monorepo.md](../../docs/modular-monorepo.md).
|
|
14
19
|
|
|
15
20
|
Timeouts and aborts end only the caller's wait, and nothing is ever resent after a disconnect.
|
|
16
21
|
See [docs/alp/client-sdk.md](../../docs/alp/client-sdk.md) for the transport contract, the
|
|
17
22
|
request lifecycle, the old-to-new API table and what still waits on the protocol (Task 06) and
|
|
18
23
|
the gateway (Task 08).
|
|
19
24
|
|
|
20
|
-
`startIntegration` discovers an optional integration's descriptor and starts it detached with `--service`, so the entry writes its own log; discovery and the selected `ALP_HOME` are pinned by tests. Restarting is non-destructive: a daemon left running by an older install is reported as older than the current CLI and is never killed in place.
|
|
25
|
+
`startIntegration({ name, entry, home, args })` (or `startIntegration(name)`; `name` defaults to `web`) discovers an optional integration's descriptor and starts it detached with `--service` and then `args`, so the entry writes its own log; discovery and the selected `ALP_HOME` are pinned by tests. `name` is a single lowercase path segment (`assertIntegrationName`). With `entry`, that script runs with the current Node and no descriptor, environment or PATH discovery happens; a missing entry is an error even when a live owner would be reused. `web` is alp-web, the service of the ALP CLI (`alp web`): it is never discovered, so it needs its `entry`, and `"web": { "enabled": false }` in the home's settings refuses it and no other integration. Everything once registered under `web` (`integrations/web.json`, the `alp-web-integration` binary, `ALP_WEB_INTEGRATION`) is the Paseo integration from before its rename; `discoverIntegration('paseo')` reads `ALP_PASEO_INTEGRATION`, then `ALP_WEB_INTEGRATION` for one release, then `ALP_INTEGRATION_ENTRY`. Errors name the integration, the entry and its log under `<home>/logs/integrations`. Either way a live owner of `integrations/<name>.lock` is reused, and the call resolves once that lock is ready and `<home>/<name>.json` names the same pid. Restarting is non-destructive: a daemon left running by an older install is reported as older than the current CLI and is never killed in place.
|
|
21
26
|
|
|
22
27
|
Build: `npm run build --workspace=@anhlp/alp-client`. Test: `npm run test --workspace=@anhlp/alp-client`.
|
|
23
28
|
|
|
@@ -21,6 +21,13 @@ export function findDaemonEntry({ home, env, candidates }?: {
|
|
|
21
21
|
env?: NodeJS.ProcessEnv | undefined;
|
|
22
22
|
candidates?: (string | undefined)[] | undefined;
|
|
23
23
|
}): Promise<string | undefined>;
|
|
24
|
+
/**
|
|
25
|
+
* Integration names are single lowercase path segments: they name `<home>/integrations/<name>.lock`,
|
|
26
|
+
* its guard and `<home>/<name>.json`. Returns the name, or throws a TypeError.
|
|
27
|
+
* @param {unknown} name
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
export function assertIntegrationName(name: unknown): string;
|
|
24
31
|
/** Wall-clock boot time; a lock from an earlier boot is stale even if its pid is reused. */
|
|
25
32
|
export function bootTime(): number;
|
|
26
33
|
export function readLock(home: any): Promise<any>;
|
|
@@ -95,8 +102,34 @@ export function ensureDaemon({ home, entry, env, execPath, timeoutMs, lifecycleL
|
|
|
95
102
|
timeoutMs?: number | undefined;
|
|
96
103
|
lifecycleLocked?: boolean | undefined;
|
|
97
104
|
}): Promise<any>;
|
|
98
|
-
|
|
99
|
-
|
|
105
|
+
/**
|
|
106
|
+
* The entry of integration `name`: an environment override, a descriptor under the home or a
|
|
107
|
+
* distribution root, or an `alp-<name>-integration` binary. Discovery never returns an entry for
|
|
108
|
+
* `web`: that name belongs to alp-web, which ships with the CLI and is started with its explicit
|
|
109
|
+
* entry. Everything once registered under `web` (the descriptor `integrations/web.json`, the
|
|
110
|
+
* `alp-web-integration` binary, `ALP_WEB_INTEGRATION`) is the Paseo integration from before its
|
|
111
|
+
* rename to `paseo`, which must never run under the web lock. `ALP_WEB_INTEGRATION` is read for
|
|
112
|
+
* `paseo` only, after `ALP_PASEO_INTEGRATION`, for one release (like the alp-web-integration alias).
|
|
113
|
+
* @param {string} [name]
|
|
114
|
+
* @param {{ home?: string }} [options]
|
|
115
|
+
*/
|
|
116
|
+
export function discoverIntegration(name?: string, options?: {
|
|
117
|
+
home?: string;
|
|
118
|
+
}): Promise<string | undefined>;
|
|
119
|
+
/**
|
|
120
|
+
* Starts integration `name` (default `web`) detached with `--service` (then `args`) unless its lock
|
|
121
|
+
* already names a live owner, then waits until its ready lock and `<home>/<name>.json` name the same
|
|
122
|
+
* pid. `entry` names the script to run with this Node and skips descriptor, environment and PATH
|
|
123
|
+
* discovery; `web` (alp-web) is never discovered, so it needs its entry. `"web": { "enabled": false }`
|
|
124
|
+
* in the home's settings.json refuses `web` only.
|
|
125
|
+
* @param {string | { name?: string, entry?: string, home?: string, args?: string[] }} [nameOrOptions]
|
|
126
|
+
*/
|
|
127
|
+
export function startIntegration(nameOrOptions?: string | {
|
|
128
|
+
name?: string;
|
|
129
|
+
entry?: string;
|
|
130
|
+
home?: string;
|
|
131
|
+
args?: string[];
|
|
132
|
+
}): Promise<any>;
|
|
100
133
|
export * from "./portable.js";
|
|
101
134
|
export { createSocketTransport } from "./ipc.js";
|
|
102
135
|
/**
|
|
@@ -110,6 +143,8 @@ export class AlpClient extends AlpRpcClient {
|
|
|
110
143
|
* @param {import('./rpc/client.js').AlpRpcClientOptions & import('./ipc.js').SocketTransportOptions} [options]
|
|
111
144
|
*/
|
|
112
145
|
constructor(socket: import("node:net").Socket, { maxFrame, maxBufferedBytes, ...options }?: import("./rpc/client.js").AlpRpcClientOptions & import("./ipc.js").SocketTransportOptions);
|
|
146
|
+
/** Host features captured by connect's handshake. @type {readonly string[]} */
|
|
147
|
+
capabilities: readonly string[];
|
|
113
148
|
}
|
|
114
149
|
import { AlpRpcClient } from './rpc/client.js';
|
|
115
150
|
export { PROTOCOL_VERSION, MAX_FRAME, AlpRpcError, encodeFrame } from "@anhlp/alp-protocol";
|
|
@@ -98,6 +98,10 @@ async function findDaemonEntry({ home = alpHome(), env = process.env, candidates
|
|
|
98
98
|
}
|
|
99
99
|
return void 0;
|
|
100
100
|
}
|
|
101
|
+
function assertIntegrationName(name) {
|
|
102
|
+
if (typeof name !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(name)) throw new TypeError(`Invalid integration name: ${JSON.stringify(name)}`);
|
|
103
|
+
return name;
|
|
104
|
+
}
|
|
101
105
|
function bootTime() {
|
|
102
106
|
return Math.round((Date.now() - os.uptime() * 1e3) / 1e3);
|
|
103
107
|
}
|
|
@@ -144,6 +148,8 @@ async function releaseDaemonLock(home) {
|
|
|
144
148
|
});
|
|
145
149
|
}
|
|
146
150
|
var AlpClient = class extends AlpRpcClient {
|
|
151
|
+
/** Host features captured by connect's handshake. @type {readonly string[]} */
|
|
152
|
+
capabilities = [];
|
|
147
153
|
/**
|
|
148
154
|
* @param {import('node:net').Socket} socket a connected or connecting socket; the client owns it
|
|
149
155
|
* @param {import('./rpc/client.js').AlpRpcClientOptions & import('./ipc.js').SocketTransportOptions} [options]
|
|
@@ -172,7 +178,8 @@ async function connectSocket(socketPath, options) {
|
|
|
172
178
|
async function connect(socketPath, client = { name: "alp", version: "0" }, options = {}) {
|
|
173
179
|
const connection = new AlpClient(await openSocket(socketPath), options);
|
|
174
180
|
try {
|
|
175
|
-
await hello(connection, client);
|
|
181
|
+
const greeting = await hello(connection, client);
|
|
182
|
+
connection.capabilities = Object.freeze(Array.isArray(greeting.capabilities) ? greeting.capabilities.filter((value) => typeof value === "string") : []);
|
|
176
183
|
} catch (error) {
|
|
177
184
|
connection.close();
|
|
178
185
|
throw error;
|
|
@@ -346,7 +353,7 @@ async function integrationCandidateFromHome(home, name) {
|
|
|
346
353
|
return await isFile(legacy) ? legacy : void 0;
|
|
347
354
|
}
|
|
348
355
|
function integrationBinaryName(name) {
|
|
349
|
-
return
|
|
356
|
+
return `alp-${name}-integration`;
|
|
350
357
|
}
|
|
351
358
|
async function distributionRoots() {
|
|
352
359
|
const roots = [];
|
|
@@ -366,10 +373,12 @@ async function distributionRoots() {
|
|
|
366
373
|
return [...new Set(roots)];
|
|
367
374
|
}
|
|
368
375
|
async function discoverIntegration(name = "web", options = {}) {
|
|
376
|
+
assertIntegrationName(name);
|
|
377
|
+
if (name === "web") return void 0;
|
|
369
378
|
const envCandidates = [
|
|
370
|
-
process.env.
|
|
371
|
-
process.env.
|
|
372
|
-
|
|
379
|
+
process.env[`ALP_${name.toUpperCase()}_INTEGRATION`],
|
|
380
|
+
name === "paseo" ? process.env.ALP_WEB_INTEGRATION : void 0,
|
|
381
|
+
process.env.ALP_INTEGRATION_ENTRY
|
|
373
382
|
].filter(Boolean);
|
|
374
383
|
for (const candidate of envCandidates) if (await isFile(candidate)) return candidate;
|
|
375
384
|
const home = options?.home ? path.resolve(options.home) : alpHome();
|
|
@@ -398,21 +407,46 @@ function integrationAlive(lock) {
|
|
|
398
407
|
if (typeof lock.bootTime === "number" && Math.abs(lock.bootTime - bootTime()) > 30) return false;
|
|
399
408
|
return true;
|
|
400
409
|
}
|
|
410
|
+
function integrationText(name, home) {
|
|
411
|
+
const logs = path.join(home, "logs", "integrations");
|
|
412
|
+
if (name === "web") return {
|
|
413
|
+
what: "alp web",
|
|
414
|
+
log: path.join(logs, "alp-web.log"),
|
|
415
|
+
missing: "alp web runs the alp-web service of the ALP CLI, which is started with its entry (packages/cli/dist/alp-web.js); run `alp web`"
|
|
416
|
+
};
|
|
417
|
+
if (name === "paseo") return {
|
|
418
|
+
what: "The Paseo integration",
|
|
419
|
+
log: path.join(logs, "paseo.log"),
|
|
420
|
+
missing: "The Paseo integration is an optional integration package. Install it (for example `npm install -g @anhlp/alp-paseo@beta`), then run the command again."
|
|
421
|
+
};
|
|
422
|
+
return {
|
|
423
|
+
what: `ALP integration ${name}`,
|
|
424
|
+
log: path.join(logs, `${name}.log`),
|
|
425
|
+
missing: `ALP integration ${name} is not installed: no ALP_${name.toUpperCase()}_INTEGRATION, descriptor <home>/integrations/${name}.json or alp-${name}-integration binary was found.`
|
|
426
|
+
};
|
|
427
|
+
}
|
|
401
428
|
async function startIntegration(nameOrOptions = "web") {
|
|
402
429
|
const options = typeof nameOrOptions === "string" ? { name: nameOrOptions } : nameOrOptions ?? {};
|
|
403
|
-
const name = options.name ?? "web";
|
|
430
|
+
const name = assertIntegrationName(options.name ?? "web");
|
|
431
|
+
if (options.entry !== void 0 && (typeof options.entry !== "string" || !options.entry)) throw new TypeError("startIntegration entry must be a non-empty path");
|
|
432
|
+
const args = options.args ?? [];
|
|
433
|
+
if (!Array.isArray(args) || !args.every((arg) => typeof arg === "string")) throw new TypeError("startIntegration args must be a list of strings");
|
|
404
434
|
const fs = await import("node:fs/promises");
|
|
405
435
|
const pathMod = await import("node:path");
|
|
406
436
|
const home = options.home ? pathMod.resolve(options.home) : alpHome();
|
|
437
|
+
const text = integrationText(name, home);
|
|
438
|
+
const explicit = options.entry !== void 0 ? pathMod.resolve(options.entry) : void 0;
|
|
439
|
+
if (explicit !== void 0 && !await isFile(explicit)) throw new Error(`${text.what}: integration entry not found: ${explicit}`);
|
|
407
440
|
const lockPath = pathMod.join(home, "integrations", `${name}.lock`);
|
|
408
441
|
const webPath = pathMod.join(home, `${name}.json`);
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
settings =
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
442
|
+
const started = explicit ? ` (entry ${explicit})` : "";
|
|
443
|
+
if (name === "web") {
|
|
444
|
+
let settings = {};
|
|
445
|
+
try {
|
|
446
|
+
settings = JSON.parse(await fs.readFile(pathMod.join(home, "settings.json"), "utf8"));
|
|
447
|
+
} catch {
|
|
448
|
+
}
|
|
449
|
+
if (settings?.web?.enabled === false) throw new Error('alp web is disabled by settings ("web.enabled": false).');
|
|
416
450
|
}
|
|
417
451
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
418
452
|
const readLock2 = async () => {
|
|
@@ -440,20 +474,18 @@ async function startIntegration(nameOrOptions = "web") {
|
|
|
440
474
|
await sleep(100);
|
|
441
475
|
}
|
|
442
476
|
const lock = await readLock2();
|
|
443
|
-
if (lock && !lock.ready) throw new Error(
|
|
444
|
-
if (!lock) throw new Error(
|
|
445
|
-
throw new Error(
|
|
477
|
+
if (lock && !lock.ready) throw new Error(`${text.what}${started} started but did not become ready; see ${text.log}`);
|
|
478
|
+
if (!lock) throw new Error(explicit ? `${text.what}${started} did not start; see ${text.log}` : text.missing);
|
|
479
|
+
throw new Error(`${text.what}${started} did not publish a ${name}.json matching its lock; see ${text.log}`);
|
|
446
480
|
}
|
|
447
481
|
const existing = await readLock2();
|
|
448
482
|
if (existing && integrationAlive(existing)) {
|
|
449
483
|
return waitForReady();
|
|
450
484
|
}
|
|
451
|
-
const entry = await discoverIntegration(name, { home });
|
|
452
|
-
if (!entry)
|
|
453
|
-
throw new Error("ALP web app is provided by an optional integration package. Install it (for example `npm install -g @anhlp/alp-paseo@beta`) and run `alp web` again.");
|
|
454
|
-
}
|
|
485
|
+
const entry = explicit ?? await discoverIntegration(name, { home });
|
|
486
|
+
if (!entry) throw new Error(text.missing);
|
|
455
487
|
const { spawn: spawn2 } = await import("node:child_process");
|
|
456
|
-
const child = spawn2(process.execPath, [entry, "--service"], { stdio: "ignore", detached: true, env: { ...process.env, ALP_HOME: home } });
|
|
488
|
+
const child = spawn2(process.execPath, [entry, "--service", ...args], { stdio: "ignore", detached: true, env: { ...process.env, ALP_HOME: home } });
|
|
457
489
|
let spawnError = null;
|
|
458
490
|
child.on("error", (error) => {
|
|
459
491
|
spawnError = error;
|
|
@@ -466,7 +498,7 @@ async function startIntegration(nameOrOptions = "web") {
|
|
|
466
498
|
if (spawnError) throw spawnError;
|
|
467
499
|
await sleep(150);
|
|
468
500
|
if (spawnError) throw spawnError;
|
|
469
|
-
if (exited) throw new Error(
|
|
501
|
+
if (exited) throw new Error(`${text.what}${explicit ? started : ` (${entry})`} failed to start; see ${text.log}`);
|
|
470
502
|
return waitForReady();
|
|
471
503
|
}
|
|
472
504
|
export {
|
|
@@ -477,6 +509,7 @@ export {
|
|
|
477
509
|
PROTOCOL_VERSION,
|
|
478
510
|
acquireDaemonLock,
|
|
479
511
|
alpHome,
|
|
512
|
+
assertIntegrationName,
|
|
480
513
|
assertPrivateDirectory,
|
|
481
514
|
assertPrivateSocket,
|
|
482
515
|
bootTime,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {{ pid: number, bootTime: number, startedAt: string, ready: boolean, lockVersion?: number, owner?: string }} IntegrationLockRecord
|
|
3
|
+
* @typedef {{
|
|
4
|
+
* acquire(): Promise<{ acquired: boolean, lock: IntegrationLockRecord }>,
|
|
5
|
+
* setReady(ready: boolean): Promise<boolean>,
|
|
6
|
+
* release(): Promise<boolean>,
|
|
7
|
+
* }} IntegrationLock
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The single-owner lock of one integration service, at `<home>/integrations/<name>.lock`, with its
|
|
11
|
+
* mutation guard `<name>.lock.reclaim`. Each instance is single-use: acquire once, then setReady and
|
|
12
|
+
* release while it owns the lock.
|
|
13
|
+
*
|
|
14
|
+
* Single-host, same PID namespace, private local filesystem only. Never reap the mutation guard.
|
|
15
|
+
* @param {string} home the ALP_HOME the integration runs under
|
|
16
|
+
* @param {string} name the integration name, for example `web`
|
|
17
|
+
* @returns {IntegrationLock}
|
|
18
|
+
*/
|
|
19
|
+
export function createIntegrationLock(home: string, name: string): IntegrationLock;
|
|
20
|
+
export type IntegrationLockRecord = {
|
|
21
|
+
pid: number;
|
|
22
|
+
bootTime: number;
|
|
23
|
+
startedAt: string;
|
|
24
|
+
ready: boolean;
|
|
25
|
+
lockVersion?: number;
|
|
26
|
+
owner?: string;
|
|
27
|
+
};
|
|
28
|
+
export type IntegrationLock = {
|
|
29
|
+
acquire(): Promise<{
|
|
30
|
+
acquired: boolean;
|
|
31
|
+
lock: IntegrationLockRecord;
|
|
32
|
+
}>;
|
|
33
|
+
setReady(ready: boolean): Promise<boolean>;
|
|
34
|
+
release(): Promise<boolean>;
|
|
35
|
+
};
|