@anhlp/alp 0.0.0-beta.2 → 0.0.1-beta.3
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 +5 -5
- package/dist/acp.js +104 -21
- package/dist/alpd.js +705 -326
- package/docs/modular-monorepo.md +1 -1
- package/package.json +4 -4
- package/packages/cli/dist/acp/main.js +1 -1
- package/packages/cli/dist/acp.js +104 -21
- package/packages/cli/dist/alpd.js +705 -326
- package/packages/cli/dist/backup.js +1 -1
- package/packages/cli/dist/build-manifest.json +1 -1
- package/packages/cli/dist/cli.js +6 -2
- package/packages/cli/dist/daemon.js +6 -2
- package/packages/client/dist/index.d.ts +16 -1
- package/packages/client/dist/index.js +83 -5
- package/packages/client/dist/supervise.js +18 -12
- package/packages/core/dist/library-edit.d.ts +7 -4
- package/packages/core/dist/library-edit.js +59 -6
|
@@ -7,7 +7,7 @@ import { acquireDaemonLock, alpHome, assertPrivateDirectory, daemonPaths, lockAl
|
|
|
7
7
|
import { daemonHeld, holdDaemon, releaseHold } from "@anhlp/alp-client/supervise";
|
|
8
8
|
import { start, stop } from "./daemon.js";
|
|
9
9
|
import { UsageError } from "./usage.js";
|
|
10
|
-
var VERSION = true ? "0.0.
|
|
10
|
+
var VERSION = true ? "0.0.1-beta.3" : "dev";
|
|
11
11
|
var BY = `alp ${VERSION}`;
|
|
12
12
|
var size = (bytes) => bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : bytes >= 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${bytes} B`;
|
|
13
13
|
var stamp = () => (/* @__PURE__ */ new Date()).toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
|
package/packages/cli/dist/cli.js
CHANGED
|
@@ -15,8 +15,12 @@ import { backupCommand, restoreCommand } from "./backup.js";
|
|
|
15
15
|
var commands = { setup: (args2) => setup(args2, alpHome()), init: (args2) => project("init", args2), upgrade: (args2) => project("upgrade", args2), update: (args2) => project("upgrade", args2), daemon, doctor, run, ps, top, attach, send, questions, answer, log, board, tasks: tasksCommand, task: taskCommand, formula: formulaCommand, permissions: permissionsCommand, agents: (args2) => libraryCommand("agents", args2), skills: (args2) => libraryCommand("skills", args2), mcp: (args2) => ["add", "rm", "mv", "cp", "test", "show", "edit"].includes(args2[0]) ? editCommand("mcp", args2) : libraryCommand("mcp", args2), hooks: (args2) => libraryCommand("hooks", args2), providers: (args2) => libraryCommand("providers", args2), provider: (args2) => editCommand("providers", args2), teams: teamsCommand, trust: trustCommand, agent: (args2) => editCommand("agents", args2), team: (args2) => editCommand("teams", args2), skill: (args2) => editCommand("skills", args2), hook: (args2) => editCommand("hooks", args2), verify, recall, pause, resume, language, interrupt, acp: acpCommand, web: webCommand, backup: backupCommand, restore: restoreCommand };
|
|
16
16
|
var [command, ...args] = process.argv.slice(2);
|
|
17
17
|
try {
|
|
18
|
-
if (
|
|
19
|
-
|
|
18
|
+
if (command === "--version") {
|
|
19
|
+
console.log(true ? "0.0.1-beta.3" : JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version);
|
|
20
|
+
} else {
|
|
21
|
+
if (!Object.hasOwn(commands, command)) throw new UsageError();
|
|
22
|
+
await commands[command](args);
|
|
23
|
+
}
|
|
20
24
|
} catch (error) {
|
|
21
25
|
if (error instanceof UsageError || error?.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") console.error(USAGE);
|
|
22
26
|
else console.error(`alp: ${scrubText(error?.message ?? error)}`);
|
|
@@ -3,7 +3,7 @@ import { access } from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { installedProgram, installService, serviceFor, uninstallService } from "@anhlp/alp-client/service";
|
|
5
5
|
import { holdDaemon, startDaemon } from "@anhlp/alp-client/supervise";
|
|
6
|
-
import { alpHome, connect, lockAlive, readLock } from "@anhlp/alp-client/index";
|
|
6
|
+
import { alpHome, connect, lockAlive, readLock, withDaemonLifecycle } from "@anhlp/alp-client/index";
|
|
7
7
|
import { scrubText } from "@anhlp/alp-core/log";
|
|
8
8
|
import { UsageError } from "./usage.js";
|
|
9
9
|
import { DAEMON_ENTRY } from "./paths.js";
|
|
@@ -52,8 +52,12 @@ async function daemon([action, ...rest]) {
|
|
|
52
52
|
const lock = await readLock(alpHome());
|
|
53
53
|
console.log(`alpd ${lock.version} running (pid ${lock.pid}) on ${socket}`);
|
|
54
54
|
} else if (action === "stop") {
|
|
55
|
-
const stopped = await stop();
|
|
56
55
|
await holdDaemon(alpHome());
|
|
56
|
+
const stopped = await withDaemonLifecycle(alpHome(), "stop", async () => {
|
|
57
|
+
const stopped2 = await stop();
|
|
58
|
+
await holdDaemon(alpHome());
|
|
59
|
+
return stopped2;
|
|
60
|
+
});
|
|
57
61
|
console.log(stopped ? `alpd stopped; it stays stopped until alp daemon start, a prompt in Paseo, or ${await installed() ? "the next login" : "Paseo starting again"}` : "alpd is not running");
|
|
58
62
|
} else if (action === "restart") {
|
|
59
63
|
const client = await running().catch(() => void 0);
|
|
@@ -68,17 +68,32 @@ export function connect(socketPath: string, client?: {
|
|
|
68
68
|
name: string;
|
|
69
69
|
version: string;
|
|
70
70
|
}, options?: import("./rpc/client.js").AlpRpcClientOptions & import("./ipc.js").SocketTransportOptions): Promise<AlpClient>;
|
|
71
|
+
export function readDaemonLifecycle(home: any): Promise<{
|
|
72
|
+
token: string;
|
|
73
|
+
pid: number;
|
|
74
|
+
bootTime: number;
|
|
75
|
+
action: string;
|
|
76
|
+
} | undefined>;
|
|
77
|
+
/** Serializes requested starts and stops, including the time before alpd has a lock.
|
|
78
|
+
* Rename publishes a fully populated directory: an existing nonempty directory cannot
|
|
79
|
+
* be replaced. Reaping/release unlinks only its unique owner token, then rmdir; even
|
|
80
|
+
* racing stale reapers cannot remove a successor's nonempty directory.
|
|
81
|
+
*/
|
|
82
|
+
export function withDaemonLifecycle(home: any, action: any, run: any, { joinStart }?: {
|
|
83
|
+
joinStart?: boolean | undefined;
|
|
84
|
+
}): Promise<any>;
|
|
71
85
|
/**
|
|
72
86
|
* Returns the socket of a ready daemon, starting one when none runs.
|
|
73
87
|
* `entry` is the daemon script; it is started through its own detaching launcher so the
|
|
74
88
|
* daemon outlives the client that started it.
|
|
75
89
|
*/
|
|
76
|
-
export function ensureDaemon({ home, entry, env, execPath, timeoutMs }?: {
|
|
90
|
+
export function ensureDaemon({ home, entry, env, execPath, timeoutMs, lifecycleLocked }?: {
|
|
77
91
|
home?: string | undefined;
|
|
78
92
|
entry?: string | undefined;
|
|
79
93
|
env?: NodeJS.ProcessEnv | undefined;
|
|
80
94
|
execPath?: string | undefined;
|
|
81
95
|
timeoutMs?: number | undefined;
|
|
96
|
+
lifecycleLocked?: boolean | undefined;
|
|
82
97
|
}): Promise<any>;
|
|
83
98
|
export function discoverIntegration(name?: string, options?: {}): Promise<string | undefined>;
|
|
84
99
|
export function startIntegration(nameOrOptions?: string): Promise<any>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// packages/client/src/index.js
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import { createHash } from "node:crypto";
|
|
4
|
-
import { open, readFile, realpath, stat, lstat, mkdir, readlink, unlink } from "node:fs/promises";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { open, readFile, writeFile, realpath, stat, lstat, mkdir, readlink, unlink, readdir, rename, rmdir, link } from "node:fs/promises";
|
|
5
5
|
import net from "node:net";
|
|
6
6
|
import os from "node:os";
|
|
7
7
|
import path from "node:path";
|
|
@@ -197,10 +197,86 @@ async function tail(file, lines = 30) {
|
|
|
197
197
|
return "";
|
|
198
198
|
}
|
|
199
199
|
}
|
|
200
|
+
async function lifecycleOwners(home) {
|
|
201
|
+
const directory = path.join(home, "state", "alpd.starting");
|
|
202
|
+
const names = await readdir(directory).catch((error) => {
|
|
203
|
+
if (error.code === "ENOENT") return [];
|
|
204
|
+
throw error;
|
|
205
|
+
});
|
|
206
|
+
return names.map((token) => {
|
|
207
|
+
const match = /^owner-(\d+)-(\d+)-(start|stop)-[a-f0-9-]+$/.exec(token);
|
|
208
|
+
if (!match) throw new Error("Invalid alpd startup admission owner");
|
|
209
|
+
return { token, pid: Number(match[1]), bootTime: Number(match[2]), action: match[3] };
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
async function readDaemonLifecycle(home) {
|
|
213
|
+
return (await lifecycleOwners(home)).find((owner) => lockAlive(owner));
|
|
214
|
+
}
|
|
215
|
+
async function withDaemonLifecycle(home, action, run, { joinStart = false } = {}) {
|
|
216
|
+
try {
|
|
217
|
+
await assertPrivateDirectory(home, { create: true });
|
|
218
|
+
} catch (cause) {
|
|
219
|
+
throw Object.assign(new Error(`ALP_HOME_UNUSABLE, alpd failed to start while preparing its home: ${cause.message}`, { cause }), { code: "ALP_HOME_UNUSABLE" });
|
|
220
|
+
}
|
|
221
|
+
const state = path.join(home, "state");
|
|
222
|
+
await assertPrivateDirectory(state, { create: true });
|
|
223
|
+
const directory = path.join(state, "alpd.starting");
|
|
224
|
+
const token = `owner-${process.pid}-${bootTime()}-${action}-${randomUUID()}`;
|
|
225
|
+
const candidate = path.join(state, `alpd.starting-${token}`);
|
|
226
|
+
await mkdir(candidate, { mode: 448 });
|
|
227
|
+
await writeFile(path.join(candidate, token), JSON.stringify({ pid: process.pid, bootTime: bootTime(), action }), { mode: 384 });
|
|
228
|
+
try {
|
|
229
|
+
let joined = false;
|
|
230
|
+
if (joinStart) {
|
|
231
|
+
const owner = await readDaemonLifecycle(home);
|
|
232
|
+
if (owner?.action === "start") {
|
|
233
|
+
try {
|
|
234
|
+
await link(path.join(directory, owner.token), path.join(directory, token));
|
|
235
|
+
joined = true;
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (error.code !== "ENOENT") throw error;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
while (!joined) {
|
|
242
|
+
try {
|
|
243
|
+
await rename(candidate, directory);
|
|
244
|
+
break;
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (!["EEXIST", "ENOTEMPTY", "EPERM", "EACCES"].includes(error.code)) throw error;
|
|
247
|
+
for (const owner of await lifecycleOwners(home)) {
|
|
248
|
+
if (!lockAlive(owner)) await unlink(path.join(directory, owner.token)).catch((error2) => {
|
|
249
|
+
if (error2.code !== "ENOENT") throw error2;
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
await rmdir(directory).catch((error2) => {
|
|
253
|
+
if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error2.code)) throw error2;
|
|
254
|
+
});
|
|
255
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
return await run();
|
|
260
|
+
} finally {
|
|
261
|
+
await unlink(path.join(directory, token));
|
|
262
|
+
await rmdir(directory).catch((error) => {
|
|
263
|
+
if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code)) throw error;
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
} finally {
|
|
267
|
+
await unlink(path.join(candidate, token)).catch((error) => {
|
|
268
|
+
if (error.code !== "ENOENT") throw error;
|
|
269
|
+
});
|
|
270
|
+
await rmdir(candidate).catch((error) => {
|
|
271
|
+
if (error.code !== "ENOENT") throw error;
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
200
275
|
async function ensureDaemon({ home = alpHome(), entry = (
|
|
201
276
|
/** @type {string | undefined} */
|
|
202
277
|
void 0
|
|
203
|
-
), env = process.env, execPath = process.execPath, timeoutMs = 15e3 } = {}) {
|
|
278
|
+
), env = process.env, execPath = process.execPath, timeoutMs = 15e3, lifecycleLocked = false } = {}) {
|
|
279
|
+
if (!lifecycleLocked) return withDaemonLifecycle(home, "start", () => ensureDaemon({ home, entry, env, execPath, timeoutMs, lifecycleLocked: true }));
|
|
204
280
|
const ready = async () => {
|
|
205
281
|
const lock = await readLock(home);
|
|
206
282
|
return lockAlive(lock) && lock.ready ? lock.socket : void 0;
|
|
@@ -213,7 +289,7 @@ async function ensureDaemon({ home = alpHome(), entry = (
|
|
|
213
289
|
entry ??= await findDaemonEntry({ home, env });
|
|
214
290
|
if (!entry) throw new Error("ALP daemon is not running and alpd.js was not found. Install the ALP CLI and run `alp daemon start`, or set ALP_DAEMON_ENTRY to alpd.js");
|
|
215
291
|
await new Promise((resolve, reject) => {
|
|
216
|
-
const launcher = spawn(execPath, [entry, "--detach"], {
|
|
292
|
+
const launcher = spawn(execPath, [entry, "--detach", `--startup-timeout=${timeoutMs}`], {
|
|
217
293
|
env: { ...env, ALP_HOME: home, ELECTRON_RUN_AS_NODE: "1" },
|
|
218
294
|
stdio: ["ignore", "ignore", "pipe"],
|
|
219
295
|
windowsHide: true
|
|
@@ -415,8 +491,10 @@ export {
|
|
|
415
491
|
ensureDaemon,
|
|
416
492
|
findDaemonEntry,
|
|
417
493
|
lockAlive,
|
|
494
|
+
readDaemonLifecycle,
|
|
418
495
|
readLock,
|
|
419
496
|
redactUrl,
|
|
420
497
|
releaseDaemonLock,
|
|
421
|
-
startIntegration
|
|
498
|
+
startIntegration,
|
|
499
|
+
withDaemonLifecycle
|
|
422
500
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// packages/client/src/supervise.js
|
|
2
2
|
import { mkdir, unlink, writeFile, stat } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { ensureDaemon, lockAlive, readLock } from "./index.js";
|
|
4
|
+
import { ensureDaemon, lockAlive, readLock, withDaemonLifecycle } from "./index.js";
|
|
5
5
|
import { installedProgram, serviceFor, startService } from "./service.js";
|
|
6
6
|
var holdFile = (home) => path.join(home, "state", "alpd.held");
|
|
7
7
|
async function holdDaemon(home, by = "alp daemon stop") {
|
|
@@ -24,16 +24,20 @@ async function whenReady(home, timeoutMs) {
|
|
|
24
24
|
}
|
|
25
25
|
throw new Error(`alpd did not become ready within ${timeoutMs / 1e3} s; see ${path.join(home, "logs")}`);
|
|
26
26
|
}
|
|
27
|
-
async function startDaemon({ home, entry, env = process.env, timeoutMs = 15e3 }) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
27
|
+
async function startDaemon({ home, entry, env = process.env, timeoutMs = 15e3, automatic = false }) {
|
|
28
|
+
return withDaemonLifecycle(home, "start", async () => {
|
|
29
|
+
if (automatic) {
|
|
30
|
+
if (await daemonHeld(home)) return;
|
|
31
|
+
} else await releaseHold(home);
|
|
32
|
+
const lock = await readLock(home);
|
|
33
|
+
if (lockAlive(lock)) return lock.ready ? lock.socket : whenReady(home, timeoutMs);
|
|
34
|
+
const service = serviceFor({ home, env });
|
|
35
|
+
if (service && await installedProgram(service)) {
|
|
36
|
+
await startService(service);
|
|
37
|
+
return whenReady(home, timeoutMs);
|
|
38
|
+
}
|
|
39
|
+
return ensureDaemon({ home, entry, env, timeoutMs, lifecycleLocked: true });
|
|
40
|
+
});
|
|
37
41
|
}
|
|
38
42
|
function superviseDaemon({ home, entry, env = process.env, intervalMs = 5e3, misses = 2, log = () => {
|
|
39
43
|
} }) {
|
|
@@ -41,7 +45,9 @@ function superviseDaemon({ home, entry, env = process.env, intervalMs = 5e3, mis
|
|
|
41
45
|
let missed = 0;
|
|
42
46
|
let starting;
|
|
43
47
|
const start = (reason) => {
|
|
44
|
-
starting ??= startDaemon({ home, entry: typeof entry === "function" ? entry() : entry, env
|
|
48
|
+
starting ??= startDaemon({ home, entry: typeof entry === "function" ? entry() : entry, env, automatic: reason !== "started with Paseo" }).then((socket) => {
|
|
49
|
+
if (socket) log(`alpd ${reason}`);
|
|
50
|
+
}, (error) => log(`could not start alpd: ${error?.message ?? error}`)).finally(() => {
|
|
45
51
|
starting = void 0;
|
|
46
52
|
missed = 0;
|
|
47
53
|
});
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
/** Strict payload contract for opt-in project-data writes. Instructions carry prompt authority;
|
|
2
|
+
* executable configuration and references to it have no place in this policy. */
|
|
3
|
+
export function assertProjectDataContent(kind: any, content: any): void;
|
|
1
4
|
/**
|
|
2
5
|
* One entry: the version that applies (the project's, else the library's, else the
|
|
3
6
|
* built-in), or one scope's version with `scope`. `revision` goes back with a save.
|
|
@@ -52,7 +55,7 @@ export function usersOf(kind: any, name: any, { root, library, templates }?: {})
|
|
|
52
55
|
* that scope (null to create); a different current revision refuses the save.
|
|
53
56
|
* @returns {Promise<{ revision: string | null }>}
|
|
54
57
|
*/
|
|
55
|
-
export function saveEntry(kind: any, name: any, content: any, { scope, root, library, templates, revision }?: {}): Promise<{
|
|
58
|
+
export function saveEntry(kind: any, name: any, content: any, { scope, root, library, templates, revision, policy }?: {}): Promise<{
|
|
56
59
|
revision: string | null;
|
|
57
60
|
}>;
|
|
58
61
|
/**
|
|
@@ -60,16 +63,16 @@ export function saveEntry(kind: any, name: any, content: any, { scope, root, lib
|
|
|
60
63
|
* entry others refer to stays, and the error lists them.
|
|
61
64
|
* @returns {Promise<{ removed: boolean, now?: 'builtin' | 'library' }>}
|
|
62
65
|
*/
|
|
63
|
-
export function deleteEntry(kind: any, name: any, { scope, root, library, templates, revision }?: {}): Promise<{
|
|
66
|
+
export function deleteEntry(kind: any, name: any, { scope, root, library, templates, revision, policy }?: {}): Promise<{
|
|
64
67
|
removed: boolean;
|
|
65
68
|
now?: "builtin" | "library";
|
|
66
69
|
}>;
|
|
67
70
|
/** Copies the entry that applies under `from` to a new entry `to` in a scope; built-ins are copied this way. */
|
|
68
|
-
export function duplicateEntry(kind: any, from: any, to: any, { scope, root, library, templates }?: {}): Promise<{
|
|
71
|
+
export function duplicateEntry(kind: any, from: any, to: any, { scope, root, library, templates, policy }?: {}): Promise<{
|
|
69
72
|
revision: string | null;
|
|
70
73
|
}>;
|
|
71
74
|
/** Renames an entry within a scope; refused while others refer to it by its old name. */
|
|
72
|
-
export function renameEntry(kind: any, from: any, to: any, { scope, root, library, templates }?: {}): Promise<{
|
|
75
|
+
export function renameEntry(kind: any, from: any, to: any, { scope, root, library, templates, policy }?: {}): Promise<{
|
|
73
76
|
revision: string | null;
|
|
74
77
|
}>;
|
|
75
78
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// packages/core/src/library-edit.js
|
|
2
|
-
import { mkdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { lstat, mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { moveFile } from "./atomic.js";
|
|
@@ -39,6 +39,42 @@ function base(scope, { root, library }, kind) {
|
|
|
39
39
|
return fail("INVALID_SCOPE", `Scope must be library or project, not '${scope}'`);
|
|
40
40
|
}
|
|
41
41
|
var revisionOf = (content) => content === void 0 ? null : createHash("sha256").update(JSON.stringify(content)).digest("hex").slice(0, 16);
|
|
42
|
+
function assertProjectDataContent(kind, content) {
|
|
43
|
+
const deny = () => fail("UNSAFE_LIBRARY_WRITE", "Project-data writes allow only instruction entries and non-executable agent configuration");
|
|
44
|
+
if (!object(content) || !["agents", "skills"].includes(kind)) deny();
|
|
45
|
+
const keys = kind === "skills" ? ["body"] : ["instructions", "config"];
|
|
46
|
+
if (Object.keys(content).some((key) => !keys.includes(key))) deny();
|
|
47
|
+
const text = kind === "skills" ? content.body : content.instructions;
|
|
48
|
+
if (typeof text !== "string" || !text.trim()) deny();
|
|
49
|
+
if (content.config !== void 0) {
|
|
50
|
+
const config = content.config;
|
|
51
|
+
if (!object(config)) deny();
|
|
52
|
+
const allowed = ["description", "model", "thinking", "context", "autocompact"];
|
|
53
|
+
if (Object.keys(config).some((key) => !allowed.includes(key))) deny();
|
|
54
|
+
for (const key of ["description", "thinking"]) if (config[key] !== void 0 && typeof config[key] !== "string") deny();
|
|
55
|
+
if (config.model !== void 0 && (typeof config.model !== "string" || !/^(codex|claude):[^\s/\\:]{1,240}$/.test(config.model))) deny();
|
|
56
|
+
for (const key of ["context", "autocompact"]) if (config[key] !== void 0 && !(typeof config[key] === "string" || typeof config[key] === "number" && Number.isFinite(config[key]))) deny();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async function projectDataEntry(kind, name, { scope, root, library, templates }) {
|
|
60
|
+
if (scope !== "project" || !root || !["agents", "skills"].includes(kind)) fail("UNSAFE_LIBRARY_WRITE", "Project-data writes require project scope and agents or skills");
|
|
61
|
+
nameOf(name);
|
|
62
|
+
const directory = base(scope, { root, library }, kind);
|
|
63
|
+
await safeEntryPaths(directory, kind, name);
|
|
64
|
+
const found = await layers(kind, name, { root, library, templates });
|
|
65
|
+
if (found.library !== void 0 || found.builtin !== void 0) fail("UNSAFE_LIBRARY_WRITE", "Project-data writes cannot shadow or reveal a library or built-in entry");
|
|
66
|
+
if (found.project !== void 0) assertProjectDataContent(kind, found.project);
|
|
67
|
+
const owned = path.join(directory, kind, name);
|
|
68
|
+
const files = await readdir(owned).catch((error) => {
|
|
69
|
+
if (error.code === "ENOENT") return [];
|
|
70
|
+
throw error;
|
|
71
|
+
});
|
|
72
|
+
const allowed = new Set(FILES[kind](name).map(([file]) => path.basename(file)));
|
|
73
|
+
for (const file of files) {
|
|
74
|
+
const info = await lstat(path.join(owned, file));
|
|
75
|
+
if (!allowed.has(file) || !info.isFile() || info.isSymbolicLink() || info.nlink !== 1) fail("UNSAFE_LIBRARY_WRITE", "Project-data entries cannot contain sidecars, links or executable assets");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
42
78
|
async function safeEntryPaths(directory, kind, name) {
|
|
43
79
|
for (const [file] of FILES[kind](name)) {
|
|
44
80
|
await assertNoSymlinks(directory, path.join(kind, file));
|
|
@@ -236,9 +272,13 @@ async function writeAtomic(file, text) {
|
|
|
236
272
|
await moveFile(temporary, file);
|
|
237
273
|
}
|
|
238
274
|
var exists = (target) => stat(target).then(() => true, () => false);
|
|
239
|
-
async function saveEntry(kind, name, content, { scope, root, library, templates, revision } = {}) {
|
|
275
|
+
async function saveEntry(kind, name, content, { scope, root, library, templates, revision, policy } = {}) {
|
|
240
276
|
kindOf(kind);
|
|
241
277
|
nameOf(name);
|
|
278
|
+
if (policy === "project-data") {
|
|
279
|
+
assertProjectDataContent(kind, content);
|
|
280
|
+
await projectDataEntry(kind, name, { scope, root, library, templates });
|
|
281
|
+
}
|
|
242
282
|
const directory = base(scope, { root, library }, kind);
|
|
243
283
|
await safeEntryPaths(directory, kind, name);
|
|
244
284
|
await check(kind, name, content, { scope, root, library, templates });
|
|
@@ -260,9 +300,10 @@ async function saveEntry(kind, name, content, { scope, root, library, templates,
|
|
|
260
300
|
}
|
|
261
301
|
return { revision: revisionOf(await readLayer(kind, name, directory)) };
|
|
262
302
|
}
|
|
263
|
-
async function deleteEntry(kind, name, { scope, root, library, templates, revision } = {}) {
|
|
303
|
+
async function deleteEntry(kind, name, { scope, root, library, templates, revision, policy } = {}) {
|
|
264
304
|
kindOf(kind);
|
|
265
305
|
nameOf(name);
|
|
306
|
+
if (policy === "project-data") await projectDataEntry(kind, name, { scope, root, library, templates });
|
|
266
307
|
const directory = base(scope, { root, library }, kind);
|
|
267
308
|
const current = await readLayer(kind, name, directory);
|
|
268
309
|
if (current === void 0) fail("NOT_FOUND", `No ${kind} entry '${name}' in the ${scope}`);
|
|
@@ -278,14 +319,25 @@ async function deleteEntry(kind, name, { scope, root, library, templates, revisi
|
|
|
278
319
|
const now = scope === "project" && found.library !== void 0 ? "library" : "builtin";
|
|
279
320
|
return { removed: true, ...fallback !== void 0 ? { now } : {} };
|
|
280
321
|
}
|
|
281
|
-
async function duplicateEntry(kind, from, to, { scope, root, library, templates } = {}) {
|
|
322
|
+
async function duplicateEntry(kind, from, to, { scope, root, library, templates, policy } = {}) {
|
|
323
|
+
kindOf(kind);
|
|
324
|
+
nameOf(from);
|
|
325
|
+
nameOf(to);
|
|
326
|
+
if (policy === "project-data") {
|
|
327
|
+
await projectDataEntry(kind, from, { scope, root, library, templates });
|
|
328
|
+
await projectDataEntry(kind, to, { scope, root, library, templates });
|
|
329
|
+
}
|
|
282
330
|
const { content } = await getEntry(kind, from, { root, library, templates });
|
|
283
|
-
return saveEntry(kind, to, structuredClone(content), { scope, root, library, templates, revision: null });
|
|
331
|
+
return saveEntry(kind, to, structuredClone(content), { scope, root, library, templates, revision: null, policy });
|
|
284
332
|
}
|
|
285
|
-
async function renameEntry(kind, from, to, { scope, root, library, templates } = {}) {
|
|
333
|
+
async function renameEntry(kind, from, to, { scope, root, library, templates, policy } = {}) {
|
|
286
334
|
kindOf(kind);
|
|
287
335
|
nameOf(from);
|
|
288
336
|
nameOf(to);
|
|
337
|
+
if (policy === "project-data") {
|
|
338
|
+
await projectDataEntry(kind, from, { scope, root, library, templates });
|
|
339
|
+
await projectDataEntry(kind, to, { scope, root, library, templates });
|
|
340
|
+
}
|
|
289
341
|
const directory = base(scope, { root, library }, kind);
|
|
290
342
|
const content = await readLayer(kind, from, directory);
|
|
291
343
|
if (content === void 0) fail("NOT_FOUND", `No ${kind} entry '${from}' in the ${scope}`);
|
|
@@ -303,6 +355,7 @@ async function renameEntry(kind, from, to, { scope, root, library, templates } =
|
|
|
303
355
|
}
|
|
304
356
|
export {
|
|
305
357
|
EDIT_KINDS,
|
|
358
|
+
assertProjectDataContent,
|
|
306
359
|
deleteEntry,
|
|
307
360
|
duplicateEntry,
|
|
308
361
|
getEntry,
|