@a2f0/agent-tool 0.1.13 → 0.1.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a2f0/agent-tool",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "type": "module",
5
5
  "description": "Portable cross-agent review, PR workflows, and shared skills",
6
6
  "license": "UNLICENSED",
@@ -1,7 +1,7 @@
1
1
  import { initWasm, Resvg } from "@resvg/resvg-wasm";
2
2
  import wasm from "@resvg/resvg-wasm/index_bg.wasm" with { type: "file" };
3
3
  import { randomUUID } from "node:crypto";
4
- import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { closeSync, constants, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
5
5
  import path from "node:path";
6
6
  import { composeSvg, type Canvas } from "./compose";
7
7
  import { expand, format, SOURCE_DIRECTORY, verbatim, type ImageOutput, type ImagesConfig, type ImageTarget } from "./config";
@@ -96,6 +96,38 @@ function assertNoLinks(base: string, destination: string): void {
96
96
  }
97
97
  }
98
98
 
99
+ /**
100
+ * Replaces a file atomically through an unpredictable, exclusively created
101
+ * temporary name, so no committed link can stand in for it. Sandboxed build
102
+ * phases, such as Xcode's, may write only their declared outputs; there the
103
+ * file is rewritten in place, refusing to follow a link.
104
+ */
105
+ export function replaceFile(destination: string, bytes: Uint8Array): void {
106
+ const temporary = `${destination}.${randomUUID()}.tmp`;
107
+ let created = false;
108
+ try {
109
+ writeFileSync(temporary, bytes, { flag: "wx" });
110
+ created = true;
111
+ renameSync(temporary, destination);
112
+ } catch (error) {
113
+ if (created || !["EACCES", "EPERM"].includes((error as NodeJS.ErrnoException).code ?? "")) {
114
+ // Best effort: the original error matters more than a temporary file left behind.
115
+ try { rmSync(temporary, { force: true }); } catch {}
116
+ throw error;
117
+ }
118
+ // Nothing was created, and a sandbox may refuse even an attempt to remove an undeclared path.
119
+ const descriptor = openSync(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW);
120
+ try {
121
+ // A hard link would share the file with a path outside the output, so only a sole link is rewritten.
122
+ const stats = fstatSync(descriptor);
123
+ if (!stats.isFile() || stats.nlink !== 1) throw new Error(`Refusing to rewrite ${destination}, which is not a file with a single link.`);
124
+ ftruncateSync(descriptor, 0);
125
+ // writeFileSync on a descriptor repeats short writes until every byte is written.
126
+ writeFileSync(descriptor, bytes);
127
+ } finally { closeSync(descriptor); }
128
+ }
129
+ }
130
+
99
131
  function directory(name: string, target: ImageTarget, out: string | undefined): string {
100
132
  const dir = out ?? target.dir;
101
133
  if (dir === null) throw new Error(`Image target '${name}' has no dir; pass --out <directory>.`);
@@ -146,12 +178,7 @@ export async function buildImages(root: string, config: ImagesConfig, names: str
146
178
  for (const { relative, destination, bytes } of files) {
147
179
  if (existsSync(destination) && Buffer.compare(readFileSync(destination), bytes) === 0) { unchanged.push(relative); continue; }
148
180
  mkdirSync(path.dirname(destination), { recursive: true });
149
- // An unpredictable name created exclusively, so no committed link can stand in for it.
150
- const temporary = `${destination}.${randomUUID()}.tmp`;
151
- try {
152
- writeFileSync(temporary, bytes, { flag: "wx" });
153
- renameSync(temporary, destination);
154
- } catch (error) { rmSync(temporary, { force: true }); throw error; }
181
+ replaceFile(destination, bytes);
155
182
  written.push(relative);
156
183
  }
157
184
  return { name, dir, written, unchanged };