agent-trellis 0.8.2 → 0.9.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/README.md +3 -0
- package/dist/cli.js +94 -1
- package/dist/commands/remoteSkill.d.ts +90 -0
- package/dist/commands/remoteSkill.js +392 -0
- package/dist/commands/rollback.js +36 -5
- package/dist/commands/skill.d.ts +2 -0
- package/dist/commands/skill.js +6 -2
- package/dist/core/canonical.d.ts +17 -1
- package/dist/core/canonical.js +54 -0
- package/dist/lib/backup.d.ts +17 -5
- package/dist/lib/backup.js +22 -4
- package/dist/lib/dirDigest.d.ts +11 -0
- package/dist/lib/dirDigest.js +52 -0
- package/dist/lib/remoteSkillLock.d.ts +26 -0
- package/dist/lib/remoteSkillLock.js +88 -0
- package/dist/lib/remoteSkillSource.d.ts +45 -0
- package/dist/lib/remoteSkillSource.js +206 -0
- package/docs/getting-started.md +71 -2
- package/package.json +1 -1
|
@@ -13,6 +13,7 @@ import { readlink, rm, symlink } from "node:fs/promises";
|
|
|
13
13
|
import { homedir } from "node:os";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { backupsRoot } from "../lib/backup.js";
|
|
16
|
+
import { directoryDigest } from "../lib/dirDigest.js";
|
|
16
17
|
function sha256(content) {
|
|
17
18
|
return `sha256:${createHash("sha256").update(content).digest("hex")}`;
|
|
18
19
|
}
|
|
@@ -99,15 +100,25 @@ async function planOperation(homeDir, runId, op) {
|
|
|
99
100
|
}
|
|
100
101
|
return { action: "restore", path: op.path, description: `recreate ${op.path} -> ${op.beforeLinkTarget}` };
|
|
101
102
|
}
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
// oversight: something touching a file inside an otherwise-untouched
|
|
106
|
-
// directory won't be caught as a conflict.
|
|
103
|
+
// Historical `dir-remove` operations remain existence-only because they
|
|
104
|
+
// predate directory manifests. New directory creates/replacements carry a
|
|
105
|
+
// deterministic digest, so remote Skill rollback refuses an edited tree.
|
|
107
106
|
case "dir-create": {
|
|
108
107
|
if (!existsSync(op.path)) {
|
|
109
108
|
return { action: "already-reverted", path: op.path, description: `${op.path} no longer exists — nothing to undo` };
|
|
110
109
|
}
|
|
110
|
+
if (op.afterDigest !== undefined) {
|
|
111
|
+
let digest;
|
|
112
|
+
try {
|
|
113
|
+
digest = directoryDigest(op.path);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return { action: "conflict", path: op.path, description: `${op.path} can no longer be read safely — left untouched` };
|
|
117
|
+
}
|
|
118
|
+
if (digest !== op.afterDigest) {
|
|
119
|
+
return { action: "conflict", path: op.path, description: `${op.path} has changed since this run — left untouched` };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
111
122
|
return { action: "restore", path: op.path, description: `delete ${op.path} (created by this run)` };
|
|
112
123
|
}
|
|
113
124
|
case "dir-remove": {
|
|
@@ -116,6 +127,22 @@ async function planOperation(homeDir, runId, op) {
|
|
|
116
127
|
}
|
|
117
128
|
return { action: "restore", path: op.path, description: `recreate ${op.path} from its content before this run (${join(runDir, op.beforeDir)})` };
|
|
118
129
|
}
|
|
130
|
+
case "dir-replace": {
|
|
131
|
+
if (!existsSync(op.path)) {
|
|
132
|
+
return { action: "already-reverted", path: op.path, description: `${op.path} no longer exists — nothing to undo` };
|
|
133
|
+
}
|
|
134
|
+
let digest;
|
|
135
|
+
try {
|
|
136
|
+
digest = directoryDigest(op.path);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return { action: "conflict", path: op.path, description: `${op.path} can no longer be read safely — left untouched` };
|
|
140
|
+
}
|
|
141
|
+
if (digest !== op.afterDigest) {
|
|
142
|
+
return { action: "conflict", path: op.path, description: `${op.path} has changed since this run — left untouched` };
|
|
143
|
+
}
|
|
144
|
+
return { action: "restore", path: op.path, description: `restore ${op.path} to its content before this run (${join(runDir, op.beforeDir)})` };
|
|
145
|
+
}
|
|
119
146
|
}
|
|
120
147
|
}
|
|
121
148
|
export async function collectRollbackPlan(homeDirInput, runIdInput) {
|
|
@@ -153,6 +180,10 @@ async function restoreOperation(homeDir, runId, op) {
|
|
|
153
180
|
case "dir-remove":
|
|
154
181
|
cpSync(join(runDir, op.beforeDir), op.path, { recursive: true });
|
|
155
182
|
return;
|
|
183
|
+
case "dir-replace":
|
|
184
|
+
rmSync(op.path, { recursive: true, force: true });
|
|
185
|
+
cpSync(join(runDir, op.beforeDir), op.path, { recursive: true });
|
|
186
|
+
return;
|
|
156
187
|
}
|
|
157
188
|
}
|
|
158
189
|
export async function applyRollbackPlan(homeDir, runId, manifest, items) {
|
package/dist/commands/skill.d.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import type { AgentId } from "../core/types.js";
|
|
11
11
|
import { type BackupSession } from "../lib/backup.js";
|
|
12
12
|
import type { SyncReport } from "./sync.js";
|
|
13
|
+
import type { RemoteSkillLockEntry } from "../lib/remoteSkillLock.js";
|
|
13
14
|
export type BuiltinSkillUpdateAction = "create" | "update" | "already-current";
|
|
14
15
|
export interface BuiltinSkillUpdatePlan {
|
|
15
16
|
name: "trellis-runtime";
|
|
@@ -29,6 +30,7 @@ export declare function runSkillUpdateBuiltin(opts?: {
|
|
|
29
30
|
export interface SkillListEntry {
|
|
30
31
|
name: string;
|
|
31
32
|
scope: readonly AgentId[];
|
|
33
|
+
remote?: Pick<RemoteSkillLockEntry, "source" | "requestedRef" | "commit" | "subdirectory" | "digest">;
|
|
32
34
|
}
|
|
33
35
|
export declare function collectSkillList(homeDir?: string): SkillListEntry[];
|
|
34
36
|
export declare function runSkillList(opts?: {
|
package/dist/commands/skill.js
CHANGED
|
@@ -19,6 +19,7 @@ import { resolveScope } from "../core/types.js";
|
|
|
19
19
|
import { isBuiltinSkillName } from "../lib/builtinSkills.js";
|
|
20
20
|
import { openBackupSession } from "../lib/backup.js";
|
|
21
21
|
import { collectSyncReport, printReport as printSyncReport } from "./sync.js";
|
|
22
|
+
import { remoteSkillProvenance } from "./remoteSkill.js";
|
|
22
23
|
function builtinSkillTemplate() {
|
|
23
24
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
24
25
|
return readFileSync(join(here, "..", "..", "schema", "builtin-skills", "trellis-runtime", "SKILL.md"), "utf8");
|
|
@@ -57,9 +58,11 @@ export function runSkillUpdateBuiltin(opts = {}) {
|
|
|
57
58
|
}
|
|
58
59
|
export function collectSkillList(homeDir = homedir()) {
|
|
59
60
|
const canonical = loadCanonicalSource(homeDir);
|
|
61
|
+
const provenance = remoteSkillProvenance(homeDir);
|
|
60
62
|
return canonical.skills.filter((skill) => !isBuiltinSkillName(skill.name)).map((skill) => ({
|
|
61
63
|
name: skill.name,
|
|
62
64
|
scope: resolveScope(skill.scope, canonical.managedAgents),
|
|
65
|
+
...(provenance[skill.name] ? { remote: provenance[skill.name] } : {}),
|
|
63
66
|
}));
|
|
64
67
|
}
|
|
65
68
|
export function runSkillList(opts = {}) {
|
|
@@ -71,8 +74,9 @@ export function runSkillList(opts = {}) {
|
|
|
71
74
|
console.log("No skills in canonical source yet.");
|
|
72
75
|
}
|
|
73
76
|
else {
|
|
74
|
-
for (const { name, scope } of entries) {
|
|
75
|
-
|
|
77
|
+
for (const { name, scope, remote } of entries) {
|
|
78
|
+
const provenance = remote ? ` — remote: ${remote.source}@${remote.commit.slice(0, 12)}` : "";
|
|
79
|
+
console.log(`${name} — ${scope.length > 0 ? scope.join(", ") : "(no managed agent reaches it)"}${provenance}`);
|
|
76
80
|
}
|
|
77
81
|
}
|
|
78
82
|
return { exitCode: 0 };
|
package/dist/core/canonical.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* workspace scope"). See openspec/changes/trellis-sync-p1/specs/
|
|
5
5
|
* canonical-source-loading/spec.md for the exact contract this implements.
|
|
6
6
|
*/
|
|
7
|
-
import type { AgentId, CapabilityDelivery, CanonicalSource, McpRoute, McpServerDef } from "./types.js";
|
|
7
|
+
import type { AgentId, CapabilityDelivery, CanonicalSource, McpRoute, McpServerDef, Scope } from "./types.js";
|
|
8
8
|
import type { BackupSession } from "../lib/backup.js";
|
|
9
9
|
/**
|
|
10
10
|
* The on-disk shape for one server entry — `static_env`/`env_aliases`
|
|
@@ -28,6 +28,22 @@ export type ServersYamlWriteResult = {
|
|
|
28
28
|
ok: false;
|
|
29
29
|
error: string;
|
|
30
30
|
};
|
|
31
|
+
export type ScopeYamlWriteResult = {
|
|
32
|
+
ok: true;
|
|
33
|
+
} | {
|
|
34
|
+
ok: false;
|
|
35
|
+
error: string;
|
|
36
|
+
};
|
|
37
|
+
/** Read-only preflight for a later scope edit. Remote import calls this
|
|
38
|
+
* before creating canonical content so an invalid hand-authored scope file
|
|
39
|
+
* cannot leave a half-applied import behind. */
|
|
40
|
+
export declare function validateSkillScopeYaml(path: string): ScopeYamlWriteResult;
|
|
41
|
+
/**
|
|
42
|
+
* Sets one Skill's canonical scope without rebuilding unrelated hand-authored
|
|
43
|
+
* scope entries. `undefined` removes this Skill's explicit entry and
|
|
44
|
+
* therefore restores the normal all-managed default.
|
|
45
|
+
*/
|
|
46
|
+
export declare function writeSkillScopeYaml(path: string, name: string, scope: Scope, backup?: BackupSession): ScopeYamlWriteResult;
|
|
31
47
|
export declare function upsertServerYaml(path: string, name: string, def: McpServerDef, backup?: BackupSession): ServersYamlWriteResult;
|
|
32
48
|
/** Inverse of `upsertServerYaml` — same preservation guarantee, same
|
|
33
49
|
* refusal posture on a missing/unparseable file. */
|
package/dist/core/canonical.js
CHANGED
|
@@ -176,6 +176,60 @@ function persistText(path, content, backup) {
|
|
|
176
176
|
else
|
|
177
177
|
writeFileSync(path, content);
|
|
178
178
|
}
|
|
179
|
+
/** Read-only preflight for a later scope edit. Remote import calls this
|
|
180
|
+
* before creating canonical content so an invalid hand-authored scope file
|
|
181
|
+
* cannot leave a half-applied import behind. */
|
|
182
|
+
export function validateSkillScopeYaml(path) {
|
|
183
|
+
if (!existsSync(path))
|
|
184
|
+
return { ok: true };
|
|
185
|
+
try {
|
|
186
|
+
const doc = parseDocument(readFileSync(path, "utf-8"));
|
|
187
|
+
if (doc.errors.length > 0)
|
|
188
|
+
throw new Error(doc.errors.map((error) => error.message).join("; "));
|
|
189
|
+
return { ok: true };
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
return { ok: false, error: `could not parse ${path}: ${err instanceof Error ? err.message : String(err)}` };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Sets one Skill's canonical scope without rebuilding unrelated hand-authored
|
|
197
|
+
* scope entries. `undefined` removes this Skill's explicit entry and
|
|
198
|
+
* therefore restores the normal all-managed default.
|
|
199
|
+
*/
|
|
200
|
+
export function writeSkillScopeYaml(path, name, scope, backup) {
|
|
201
|
+
if (!existsSync(path) && scope === undefined)
|
|
202
|
+
return { ok: true };
|
|
203
|
+
let doc;
|
|
204
|
+
try {
|
|
205
|
+
doc = existsSync(path) ? parseDocument(readFileSync(path, "utf-8")) : parseDocument("");
|
|
206
|
+
if (doc.errors.length > 0)
|
|
207
|
+
throw new Error(doc.errors.map((error) => error.message).join("; "));
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
return { ok: false, error: `could not parse ${path}: ${err instanceof Error ? err.message : String(err)}` };
|
|
211
|
+
}
|
|
212
|
+
const current = doc.getIn(["skills", name], true);
|
|
213
|
+
if (scope === undefined) {
|
|
214
|
+
if (current === undefined)
|
|
215
|
+
return { ok: true };
|
|
216
|
+
doc.deleteIn(["skills", name]);
|
|
217
|
+
const skills = doc.get("skills", true);
|
|
218
|
+
if (isMap(skills) && skills.items.length === 0)
|
|
219
|
+
doc.delete("skills");
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
const currentJson = current && typeof current === "object" && "toJSON" in current && typeof current.toJSON === "function" ? current.toJSON() : current;
|
|
223
|
+
if (Array.isArray(currentJson) && currentJson.length === scope.length && currentJson.every((agent, index) => agent === scope[index])) {
|
|
224
|
+
return { ok: true };
|
|
225
|
+
}
|
|
226
|
+
doc.setIn(["skills", name], [...scope]);
|
|
227
|
+
forceBlockStyle(doc.get("skills", true));
|
|
228
|
+
}
|
|
229
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
230
|
+
persistText(path, doc.toString(), backup);
|
|
231
|
+
return { ok: true };
|
|
232
|
+
}
|
|
179
233
|
export function upsertServerYaml(path, name, def, backup) {
|
|
180
234
|
if (!existsSync(path)) {
|
|
181
235
|
return { ok: false, error: `${path} does not exist — run \`trellis init\` first` };
|
package/dist/lib/backup.d.ts
CHANGED
|
@@ -33,14 +33,13 @@ export type BackupOperation = {
|
|
|
33
33
|
beforeLinkTarget: string;
|
|
34
34
|
}
|
|
35
35
|
/** A whole directory created where none existed — e.g. `skill add`
|
|
36
|
-
* copying a canonical
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* for why); rollback for this kind only checks existence, same
|
|
40
|
-
* simplification both directions. */
|
|
36
|
+
* copying a canonical Skill in. New operations carry the resulting
|
|
37
|
+
* directory digest so rollback can refuse a tree edited after the run;
|
|
38
|
+
* the optional field preserves compatibility with old backup manifests. */
|
|
41
39
|
| {
|
|
42
40
|
kind: "dir-create";
|
|
43
41
|
path: string;
|
|
42
|
+
afterDigest?: string;
|
|
44
43
|
}
|
|
45
44
|
/** A whole directory removed — e.g. `skill remove`. `beforeDir` is a
|
|
46
45
|
* full recursive copy of the directory's pre-removal content, stored
|
|
@@ -55,6 +54,15 @@ export type BackupOperation = {
|
|
|
55
54
|
kind: "dir-remove";
|
|
56
55
|
path: string;
|
|
57
56
|
beforeDir: string;
|
|
57
|
+
}
|
|
58
|
+
/** A whole directory replaced in-place. The before-tree snapshot and the
|
|
59
|
+
* exact after-tree digest let rollback refuse a locally edited remote Skill
|
|
60
|
+
* instead of overwriting it. */
|
|
61
|
+
| {
|
|
62
|
+
kind: "dir-replace";
|
|
63
|
+
path: string;
|
|
64
|
+
beforeDir: string;
|
|
65
|
+
afterDigest: string;
|
|
58
66
|
};
|
|
59
67
|
export interface BackupManifest {
|
|
60
68
|
runId: string;
|
|
@@ -74,6 +82,10 @@ export interface BackupSession {
|
|
|
74
82
|
/** Snapshots `path`'s current content into this run's own storage,
|
|
75
83
|
* then removes it, and records it as a `dir-remove`. */
|
|
76
84
|
removeDir(path: string): void;
|
|
85
|
+
/** Snapshots an existing directory then copies `sourceDir` in its place,
|
|
86
|
+
* recorded as one operation so rollback can restore it without an
|
|
87
|
+
* intermediate same-path conflict. */
|
|
88
|
+
replaceDirFromSource(path: string, sourceDir: string): void;
|
|
77
89
|
hasOperations(): boolean;
|
|
78
90
|
/** Writes manifest.json. No-op (creates nothing) if zero operations
|
|
79
91
|
* were ever recorded. */
|
package/dist/lib/backup.js
CHANGED
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
* that never records anything creates nothing on disk.
|
|
10
10
|
*/
|
|
11
11
|
import { createHash } from "node:crypto";
|
|
12
|
-
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
13
13
|
import { readlink, rm, symlink } from "node:fs/promises";
|
|
14
|
-
import { basename, join } from "node:path";
|
|
14
|
+
import { basename, dirname, join } from "node:path";
|
|
15
|
+
import { directoryDigest } from "./dirDigest.js";
|
|
15
16
|
function sha256(content) {
|
|
16
17
|
return `sha256:${createHash("sha256").update(content).digest("hex")}`;
|
|
17
18
|
}
|
|
@@ -88,7 +89,12 @@ export function openBackupSession(homeDir, command) {
|
|
|
88
89
|
else {
|
|
89
90
|
operations.push({ kind: "file-create", path, afterHash });
|
|
90
91
|
}
|
|
91
|
-
|
|
92
|
+
// A lock/provenance update must not leave a partially-written JSON file
|
|
93
|
+
// if the process stops mid-write. This also strengthens the existing
|
|
94
|
+
// backup-aware file writes without changing their manifest contract.
|
|
95
|
+
const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.${fileIndex}.tmp`);
|
|
96
|
+
writeFileSync(temporary, content);
|
|
97
|
+
renameSync(temporary, path);
|
|
92
98
|
},
|
|
93
99
|
async createSymlink(path, linkTarget) {
|
|
94
100
|
ensureDir();
|
|
@@ -111,7 +117,7 @@ export function openBackupSession(homeDir, command) {
|
|
|
111
117
|
ensureDir();
|
|
112
118
|
mkdirSync(path, { recursive: true });
|
|
113
119
|
cpSync(sourceDir, path, { recursive: true });
|
|
114
|
-
operations.push({ kind: "dir-create", path });
|
|
120
|
+
operations.push({ kind: "dir-create", path, afterDigest: directoryDigest(path) });
|
|
115
121
|
},
|
|
116
122
|
removeDir(path) {
|
|
117
123
|
ensureDir();
|
|
@@ -121,6 +127,18 @@ export function openBackupSession(homeDir, command) {
|
|
|
121
127
|
rmSync(path, { recursive: true, force: true });
|
|
122
128
|
operations.push({ kind: "dir-remove", path, beforeDir: relSnapshot });
|
|
123
129
|
},
|
|
130
|
+
replaceDirFromSource(path, sourceDir) {
|
|
131
|
+
if (!existsSync(path))
|
|
132
|
+
throw new Error(`Cannot replace missing directory: ${path}`);
|
|
133
|
+
ensureDir();
|
|
134
|
+
const relSnapshot = join("dirs", `${dirIndex}-${basename(path)}`);
|
|
135
|
+
dirIndex += 1;
|
|
136
|
+
cpSync(path, join(runDir, relSnapshot), { recursive: true });
|
|
137
|
+
rmSync(path, { recursive: true, force: true });
|
|
138
|
+
mkdirSync(path, { recursive: true });
|
|
139
|
+
cpSync(sourceDir, path, { recursive: true });
|
|
140
|
+
operations.push({ kind: "dir-replace", path, beforeDir: relSnapshot, afterDigest: directoryDigest(path) });
|
|
141
|
+
},
|
|
124
142
|
hasOperations() {
|
|
125
143
|
return operations.length > 0;
|
|
126
144
|
},
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A deterministic digest for a directory tree. Paths, empty directories,
|
|
3
|
+
* file bytes, and link targets all contribute to the digest so it can be
|
|
4
|
+
* used for provenance and for detecting edits before a remote update.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Hashes a tree without following symbolic links. A caller that needs a
|
|
8
|
+
* stricter source policy can reject links separately; keeping their target in
|
|
9
|
+
* this digest means a locally added link is still detected as drift.
|
|
10
|
+
*/
|
|
11
|
+
export declare function directoryDigest(dir: string): string;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A deterministic digest for a directory tree. Paths, empty directories,
|
|
3
|
+
* file bytes, and link targets all contribute to the digest so it can be
|
|
4
|
+
* used for provenance and for detecting edits before a remote update.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { lstatSync, readFileSync, readdirSync, readlinkSync } from "node:fs";
|
|
8
|
+
import { join, relative, sep } from "node:path";
|
|
9
|
+
function portablePath(path) {
|
|
10
|
+
return path.split(sep).join("/");
|
|
11
|
+
}
|
|
12
|
+
function addPart(hash, value) {
|
|
13
|
+
hash.update(String(Buffer.byteLength(value)));
|
|
14
|
+
hash.update(":");
|
|
15
|
+
hash.update(value);
|
|
16
|
+
hash.update("\0");
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Hashes a tree without following symbolic links. A caller that needs a
|
|
20
|
+
* stricter source policy can reject links separately; keeping their target in
|
|
21
|
+
* this digest means a locally added link is still detected as drift.
|
|
22
|
+
*/
|
|
23
|
+
export function directoryDigest(dir) {
|
|
24
|
+
const hash = createHash("sha256");
|
|
25
|
+
const walk = (current) => {
|
|
26
|
+
const rel = portablePath(relative(dir, current)) || ".";
|
|
27
|
+
const stat = lstatSync(current);
|
|
28
|
+
if (stat.isDirectory()) {
|
|
29
|
+
addPart(hash, "directory");
|
|
30
|
+
addPart(hash, rel);
|
|
31
|
+
for (const name of readdirSync(current).sort((a, b) => a.localeCompare(b))) {
|
|
32
|
+
walk(join(current, name));
|
|
33
|
+
}
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (stat.isFile()) {
|
|
37
|
+
addPart(hash, "file");
|
|
38
|
+
addPart(hash, rel);
|
|
39
|
+
addPart(hash, readFileSync(current));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (stat.isSymbolicLink()) {
|
|
43
|
+
addPart(hash, "symlink");
|
|
44
|
+
addPart(hash, rel);
|
|
45
|
+
addPart(hash, readlinkSync(current));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`Cannot digest unsupported filesystem entry: ${current}`);
|
|
49
|
+
};
|
|
50
|
+
walk(dir);
|
|
51
|
+
return `sha256:${hash.digest("hex")}`;
|
|
52
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Durable provenance for remote Skills in ~/.trellis/skills.lock.json. */
|
|
2
|
+
import type { BackupSession } from "./backup.js";
|
|
3
|
+
export declare const REMOTE_SKILL_LOCK_VERSION = 1;
|
|
4
|
+
export interface RemoteSkillLockEntry {
|
|
5
|
+
source: string;
|
|
6
|
+
requestedRef: string;
|
|
7
|
+
commit: string;
|
|
8
|
+
subdirectory: string;
|
|
9
|
+
digest: string;
|
|
10
|
+
}
|
|
11
|
+
export interface RemoteSkillLock {
|
|
12
|
+
version: typeof REMOTE_SKILL_LOCK_VERSION;
|
|
13
|
+
skills: Record<string, RemoteSkillLockEntry>;
|
|
14
|
+
}
|
|
15
|
+
export type RemoteSkillLockRead = {
|
|
16
|
+
ok: true;
|
|
17
|
+
lock: RemoteSkillLock;
|
|
18
|
+
} | {
|
|
19
|
+
ok: false;
|
|
20
|
+
error: string;
|
|
21
|
+
};
|
|
22
|
+
export declare function remoteSkillLockPath(homeDir: string): string;
|
|
23
|
+
export declare function emptyRemoteSkillLock(): RemoteSkillLock;
|
|
24
|
+
export declare function readRemoteSkillLock(homeDir: string): RemoteSkillLockRead;
|
|
25
|
+
export declare function serializeRemoteSkillLock(lock: RemoteSkillLock): string;
|
|
26
|
+
export declare function writeRemoteSkillLock(homeDir: string, lock: RemoteSkillLock, backup?: BackupSession): void;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** Durable provenance for remote Skills in ~/.trellis/skills.lock.json. */
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
export const REMOTE_SKILL_LOCK_VERSION = 1;
|
|
5
|
+
export function remoteSkillLockPath(homeDir) {
|
|
6
|
+
return join(homeDir, ".trellis", "skills.lock.json");
|
|
7
|
+
}
|
|
8
|
+
export function emptyRemoteSkillLock() {
|
|
9
|
+
return { version: REMOTE_SKILL_LOCK_VERSION, skills: {} };
|
|
10
|
+
}
|
|
11
|
+
function isObject(value) {
|
|
12
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
function validSource(value) {
|
|
15
|
+
if (typeof value !== "string")
|
|
16
|
+
return false;
|
|
17
|
+
try {
|
|
18
|
+
const parsed = new URL(value);
|
|
19
|
+
return parsed.protocol === "https:"
|
|
20
|
+
&& parsed.hostname === "github.com"
|
|
21
|
+
&& !parsed.username
|
|
22
|
+
&& !parsed.password
|
|
23
|
+
&& !parsed.search
|
|
24
|
+
&& !parsed.hash
|
|
25
|
+
&& /^\/[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9_.-]+\.git$/.test(parsed.pathname);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function validEntry(value) {
|
|
32
|
+
if (!isObject(value))
|
|
33
|
+
return false;
|
|
34
|
+
const keys = Object.keys(value).sort();
|
|
35
|
+
if (keys.join(",") !== "commit,digest,requestedRef,source,subdirectory")
|
|
36
|
+
return false;
|
|
37
|
+
return validSource(value.source)
|
|
38
|
+
&& typeof value.requestedRef === "string" && value.requestedRef.length > 0 && !/[\0\r\n]/.test(value.requestedRef)
|
|
39
|
+
&& typeof value.commit === "string" && /^[0-9a-f]{40}$/.test(value.commit)
|
|
40
|
+
&& typeof value.subdirectory === "string" && value.subdirectory.length > 0 && !value.subdirectory.startsWith("/") && !value.subdirectory.split("/").includes("..")
|
|
41
|
+
&& typeof value.digest === "string" && /^sha256:[0-9a-f]{64}$/.test(value.digest);
|
|
42
|
+
}
|
|
43
|
+
function validateLock(value) {
|
|
44
|
+
if (!isObject(value) || value.version !== REMOTE_SKILL_LOCK_VERSION || !isObject(value.skills) || Object.keys(value).some((key) => key !== "version" && key !== "skills")) {
|
|
45
|
+
throw new Error("expected version 1 and a skills object");
|
|
46
|
+
}
|
|
47
|
+
const skills = {};
|
|
48
|
+
for (const [name, entry] of Object.entries(value.skills)) {
|
|
49
|
+
if (!name || name.includes("/") || name.includes("\\") || !validEntry(entry)) {
|
|
50
|
+
throw new Error(`invalid provenance entry for Skill ${JSON.stringify(name)}`);
|
|
51
|
+
}
|
|
52
|
+
skills[name] = entry;
|
|
53
|
+
}
|
|
54
|
+
return { version: REMOTE_SKILL_LOCK_VERSION, skills };
|
|
55
|
+
}
|
|
56
|
+
export function readRemoteSkillLock(homeDir) {
|
|
57
|
+
const path = remoteSkillLockPath(homeDir);
|
|
58
|
+
try {
|
|
59
|
+
if (!existsSync(path))
|
|
60
|
+
return { ok: true, lock: emptyRemoteSkillLock() };
|
|
61
|
+
return { ok: true, lock: validateLock(JSON.parse(readFileSync(path, "utf8"))) };
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
return { ok: false, error: `Cannot use ${path}: ${error instanceof Error ? error.message : String(error)}` };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function serializeRemoteSkillLock(lock) {
|
|
68
|
+
const valid = validateLock(lock);
|
|
69
|
+
const skills = Object.fromEntries(Object.entries(valid.skills).sort(([a], [b]) => a.localeCompare(b)));
|
|
70
|
+
return `${JSON.stringify({ version: REMOTE_SKILL_LOCK_VERSION, skills }, null, 2)}\n`;
|
|
71
|
+
}
|
|
72
|
+
export function writeRemoteSkillLock(homeDir, lock, backup) {
|
|
73
|
+
const path = remoteSkillLockPath(homeDir);
|
|
74
|
+
const content = serializeRemoteSkillLock(lock);
|
|
75
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
76
|
+
if (backup) {
|
|
77
|
+
backup.writeFile(path, content);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const temporary = join(dirname(path), `.${path.split("/").pop()}.${process.pid}.${Date.now()}.tmp`);
|
|
81
|
+
try {
|
|
82
|
+
writeFileSync(temporary, content, { mode: 0o600 });
|
|
83
|
+
renameSync(temporary, path);
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
rmSync(temporary, { force: true });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote Skill source handling. The public command accepts only GitHub
|
|
3
|
+
* identities, while the lower-level fetch function intentionally accepts a
|
|
4
|
+
* repository URL so unit and sandbox tests can use a local bare Git remote.
|
|
5
|
+
* No command in this module evaluates checked-out content.
|
|
6
|
+
*/
|
|
7
|
+
export interface GitHubRepository {
|
|
8
|
+
source: string;
|
|
9
|
+
url: string;
|
|
10
|
+
owner: string;
|
|
11
|
+
repository: string;
|
|
12
|
+
}
|
|
13
|
+
export interface RemoteSkillCandidate {
|
|
14
|
+
name: string;
|
|
15
|
+
directory: string;
|
|
16
|
+
subdirectory: string;
|
|
17
|
+
}
|
|
18
|
+
export interface SelectedRemoteSkill extends RemoteSkillCandidate {
|
|
19
|
+
digest: string;
|
|
20
|
+
}
|
|
21
|
+
export interface FetchedRemoteRepository {
|
|
22
|
+
repository: {
|
|
23
|
+
url: string;
|
|
24
|
+
};
|
|
25
|
+
requestedRef: string;
|
|
26
|
+
commit: string;
|
|
27
|
+
root: string;
|
|
28
|
+
candidates: readonly RemoteSkillCandidate[];
|
|
29
|
+
select(name: string): SelectedRemoteSkill;
|
|
30
|
+
dispose(): void;
|
|
31
|
+
}
|
|
32
|
+
/** Accept the exact two source syntaxes Trellis documents, with no URL state
|
|
33
|
+
* that could put a credential in the provenance lock. */
|
|
34
|
+
export declare function normalizeGitHubRepository(source: string): GitHubRepository;
|
|
35
|
+
/**
|
|
36
|
+
* Fetches a single immutable commit into a system temporary directory. It is
|
|
37
|
+
* exported with a URL-shaped repository argument to let tests exercise the
|
|
38
|
+
* real Git protocol against a local bare repo; CLI callers first use
|
|
39
|
+
* normalizeGitHubRepository and therefore cannot pass arbitrary hosts.
|
|
40
|
+
*/
|
|
41
|
+
export declare function fetchRemoteRepository(repository: {
|
|
42
|
+
url: string;
|
|
43
|
+
}, opts?: {
|
|
44
|
+
branch?: string;
|
|
45
|
+
}): FetchedRemoteRepository;
|