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.
@@ -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
- // Directory conflict detection is existence-only, not a deep content
103
- // hash (see BackupOperation's "dir-create"/"dir-remove" doc comment
104
- // in backup.ts) — a real, documented simplification, not an
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) {
@@ -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?: {
@@ -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
- console.log(`${name} — ${scope.length > 0 ? scope.join(", ") : "(no managed agent reaches it)"}`);
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 };
@@ -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. */
@@ -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` };
@@ -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 skill in. Unlike `file-create`, there is no
37
- * `afterHash`: hashing an arbitrary directory tree for drift detection
38
- * is deliberately not attempted here (see `dir-remove`'s doc comment
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. */
@@ -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
- writeFileSync(path, content);
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;