add S3-backed config + memory sync extension

New extensions/sync/ that bidirectionally syncs ~/.pi/agent/ configs +
memories with an S3 bucket so fresh OpenShell sandboxes (or new laptops)
boot with existing pi state.

- Allowlist: settings.json, k8s.json, websearch.json, custom-providers.json,
  hosts.json, local-models.json, memory/**. Excludes auth.json
  (provider API keys; injected per-sandbox).
- Per-file last-write-wins by real mtime carried in x-amz-meta-pi-mtime;
  1s clock-jitter tolerance.
- Per-machine ~/.pi/agent/.sync-manifest.json records what was last seen
  on S3 so auto-prune can't wipe a concurrent laptop's data.
- Manual /sync command opens a TUI panel; subcommands /sync pull, push,
  prune, status work without the panel.
- session_start auto-pulls (5s soft timeout, non-blocking).
- session_shutdown auto-prunes then auto-pushes (10s hard timeout).
- Config via PI_AWS_* env vars only (chicken/egg: nothing to bootstrap).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
EislM0203
2026-05-31 22:01:58 +02:00
co-authored by Claude Opus 4.7
parent 9c64807ac3
commit 4f387a278b
15 changed files with 1873 additions and 195 deletions
+63
View File
@@ -0,0 +1,63 @@
# sync extension — setup
Bidirectionally sync `~/.pi/agent/` (configs + memories) with an S3 bucket so a fresh OpenShell sandbox boots with your existing pi state already present.
## What gets synced
| Path under `~/.pi/agent/` | Synced |
|---|---|
| `settings.json` | yes |
| `k8s.json`, `websearch.json`, `custom-providers.json`, `hosts.json`, `local-models.json` | yes |
| `memory/**` | yes (recursive) |
| `auth.json` | **no** (provider API keys; inject via OpenShell credentials) |
| `.sync-manifest.json` | **no** (per-machine bookkeeping; never uploaded) |
S3 key shape: `${PI_AWS_PREFIX}/agent/<relative path>`.
## Configuration
Set these env vars before launching pi. In an OpenShell sandbox, inject them through the provider/credential mechanism; on a laptop, set them in your shell rc.
| Var | Purpose | Required | Default |
|---|---|---|---|
| `PI_AWS_ACCESS_KEY_ID` | S3 access key | yes | — |
| `PI_AWS_SECRET_ACCESS_KEY` | S3 secret key | yes | — |
| `PI_AWS_ENDPOINT_URL_S3` | S3 endpoint URL | yes | — |
| `PI_AWS_BUCKET` | bucket name | yes | — |
| `PI_AWS_REGION` | region | no | `us-east-1` |
| `PI_AWS_PREFIX` | object key prefix | no | empty |
| `PI_AWS_FORCE_PATH_STYLE` | path-style addressing (rustfs / minio) | no | `true` |
If any required var is missing, the lifecycle hooks silently no-op and `/sync` shows a "not configured" panel listing exactly which vars are missing.
## Lifecycle hooks
- **session_start** → pull-only (newer remote → local). Soft timeout 5 s. Never blocks pi from starting.
- **session_shutdown** → manifest-based prune, then push-only. Hard timeout 10 s. Never hangs exit.
Crash-safe: if pi is killed, neither hook fires. Files on disk are never half-written (atomic tmp + rename). Run `/sync` manually after the next start to recover.
## Slash commands
| Command | Behavior |
|---|---|
| `/sync` | Open the panel (status + interactive actions). |
| `/sync pull` | Pull only (newer remote → local). |
| `/sync push` | Push only (newer local → remote). |
| `/sync prune` | Manifest-based prune: delete S3 keys this machine pulled but no longer has locally. |
| `/sync status` | Notify-only summary; the panel is the live view. |
Bidirectional sync ("sync now") is reachable from the panel's `s` key only — there is no `/sync sync` slash command, by design.
## Conflict + deletion model
- Per-file last-write-wins by **real mtime** carried in `x-amz-meta-pi-mtime`. Clock-jitter tolerance: 1 s.
- Deletions are NOT automatic across machines. If you delete a memory locally then restart pi without exiting cleanly, auto-pull re-creates it. The correct deletion path is: delete → exit pi cleanly → auto-prune runs → next start sees nothing.
- `/sync prune` only deletes S3 keys this machine pulled before. S3-only-never-pulled keys (e.g. another laptop's recent additions) are left alone. To force-delete, operate on the bucket directly.
## Security
- `auth.json` is never read or transmitted by this extension.
- `PI_AWS_*` secrets stay in process env; never written to disk by sync code.
- `.sync-manifest.json` is `0600`. Atomic writes (tmp + rename).
- Bucket-level encryption is out of scope. Enable server-side encryption at the S3 layer if your memories or configs contain sensitive content (e.g. the k8s SA token in `k8s.json`).
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { readSyncConfig } from "./config.js";
const fullEnv = {
PI_AWS_ACCESS_KEY_ID: "ak",
PI_AWS_SECRET_ACCESS_KEY: "sk",
PI_AWS_ENDPOINT_URL_S3: "https://rustfs.lan",
PI_AWS_BUCKET: "homelab",
};
describe("readSyncConfig", () => {
it("returns ok with all required vars and applies defaults", () => {
const result = readSyncConfig(fullEnv);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.config).toEqual({
accessKeyId: "ak",
secretAccessKey: "sk",
endpoint: "https://rustfs.lan",
bucket: "homelab",
region: "us-east-1",
prefix: "",
forcePathStyle: true,
});
});
it("honors PI_AWS_REGION, PI_AWS_PREFIX, PI_AWS_FORCE_PATH_STYLE", () => {
const result = readSyncConfig({
...fullEnv,
PI_AWS_REGION: "eu-west-1",
PI_AWS_PREFIX: "pi",
PI_AWS_FORCE_PATH_STYLE: "false",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.config.region).toBe("eu-west-1");
expect(result.config.prefix).toBe("pi");
expect(result.config.forcePathStyle).toBe(false);
});
it("returns missing list with every absent required var", () => {
const result = readSyncConfig({});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.missing.sort()).toEqual([
"PI_AWS_ACCESS_KEY_ID",
"PI_AWS_BUCKET",
"PI_AWS_ENDPOINT_URL_S3",
"PI_AWS_SECRET_ACCESS_KEY",
]);
});
it("treats empty / whitespace-only required vars as missing", () => {
const result = readSyncConfig({
...fullEnv,
PI_AWS_ACCESS_KEY_ID: "",
PI_AWS_BUCKET: " ",
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.missing.sort()).toEqual([
"PI_AWS_ACCESS_KEY_ID",
"PI_AWS_BUCKET",
]);
});
it("trims trailing slashes from prefix", () => {
const result = readSyncConfig({ ...fullEnv, PI_AWS_PREFIX: "pi/" });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.config.prefix).toBe("pi");
});
});
+57
View File
@@ -0,0 +1,57 @@
export interface SyncConfig {
accessKeyId: string;
secretAccessKey: string;
endpoint: string;
bucket: string;
region: string;
prefix: string;
forcePathStyle: boolean;
}
export type ReadSyncConfigResult =
| { ok: true; config: SyncConfig }
| { ok: false; missing: string[] };
const REQUIRED = [
"PI_AWS_ACCESS_KEY_ID",
"PI_AWS_SECRET_ACCESS_KEY",
"PI_AWS_ENDPOINT_URL_S3",
"PI_AWS_BUCKET",
] as const;
function read(
env: Record<string, string | undefined>,
name: string,
): string | undefined {
const v = env[name];
if (v === undefined) return undefined;
const trimmed = v.trim();
return trimmed === "" ? undefined : trimmed;
}
export function readSyncConfig(
env: Record<string, string | undefined> = process.env,
): ReadSyncConfigResult {
const missing: string[] = [];
for (const name of REQUIRED) {
if (read(env, name) === undefined) missing.push(name);
}
if (missing.length > 0) return { ok: false, missing };
const forcePathStyleRaw = read(env, "PI_AWS_FORCE_PATH_STYLE");
const forcePathStyle =
forcePathStyleRaw === undefined ? true : forcePathStyleRaw !== "false";
return {
ok: true,
config: {
accessKeyId: read(env, "PI_AWS_ACCESS_KEY_ID")!,
secretAccessKey: read(env, "PI_AWS_SECRET_ACCESS_KEY")!,
endpoint: read(env, "PI_AWS_ENDPOINT_URL_S3")!,
bucket: read(env, "PI_AWS_BUCKET")!,
region: read(env, "PI_AWS_REGION") ?? "us-east-1",
prefix: (read(env, "PI_AWS_PREFIX") ?? "").replace(/\/+$/, ""),
forcePathStyle,
},
};
}
+120
View File
@@ -0,0 +1,120 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { readSyncConfig } from "./config.js";
import { createS3Wrapper } from "./s3.js";
import { openSyncPanel } from "./panel.js";
import { executePrune, executeSync, type SyncReport } from "./sync.js";
const STARTUP_PULL_SOFT_MS = 5000;
const SHUTDOWN_PUSH_HARD_MS = 10_000;
function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
p.then(
(v) => {
clearTimeout(timer);
resolve(v);
},
(e) => {
clearTimeout(timer);
reject(e);
},
);
});
}
function summarize(report: SyncReport): string {
if (report.mode === "prune") {
return `sync: pruned ${report.deleted}, errors ${report.errors}`;
}
return `sync: pushed ${report.pushed}, pulled ${report.pulled}, skipped ${report.skipped}, errors ${report.errors}`;
}
export default function (pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => {
const cfg = readSyncConfig();
if (!cfg.ok) return;
const s3 = createS3Wrapper(cfg.config);
try {
const report = await withTimeout(
executeSync({ mode: "pull", s3, prefix: cfg.config.prefix }),
STARTUP_PULL_SOFT_MS,
"sync: startup pull",
);
if (ctx.hasUI) ctx.ui.notify(summarize(report), "info");
} catch (err) {
if (ctx.hasUI) ctx.ui.notify(`sync: pull failed — ${(err as Error).message}`, "warning");
}
});
pi.on("session_shutdown", async () => {
const cfg = readSyncConfig();
if (!cfg.ok) return;
const s3 = createS3Wrapper(cfg.config);
try {
await withTimeout(
(async () => {
await executePrune({ s3, prefix: cfg.config.prefix });
await executeSync({ mode: "push", s3, prefix: cfg.config.prefix });
})(),
SHUTDOWN_PUSH_HARD_MS,
"sync: shutdown prune+push",
);
} catch {
}
});
pi.registerCommand("sync", {
description: "Sync ~/.pi/agent configs + memories with S3 (panel + subcommands)",
handler: async (args, ctx) => {
const sub = args.trim();
if (sub === "") {
if (!ctx.hasUI) {
ctx.ui.notify("/sync needs an interactive session", "warning");
return;
}
try {
await openSyncPanel(ctx);
} catch (err) {
ctx.ui.notify(`/sync error: ${(err as Error).message}`, "error");
}
return;
}
const cfg = readSyncConfig();
if (!cfg.ok) {
ctx.ui.notify(
`sync not configured — missing: ${cfg.missing.join(", ")}`,
"warning",
);
return;
}
const s3 = createS3Wrapper(cfg.config);
try {
let report: SyncReport;
if (sub === "pull") {
report = await executeSync({ mode: "pull", s3, prefix: cfg.config.prefix });
} else if (sub === "push") {
report = await executeSync({ mode: "push", s3, prefix: cfg.config.prefix });
} else if (sub === "prune") {
report = await executePrune({ s3, prefix: cfg.config.prefix });
} else if (sub === "status") {
ctx.ui.notify(
"/sync status is summary-only — open the panel (/sync) for a live view",
"info",
);
return;
} else {
ctx.ui.notify(
`/sync: unknown subcommand "${sub}" — try pull, push, prune, status, or just /sync`,
"warning",
);
return;
}
ctx.ui.notify(summarize(report), report.errors > 0 ? "warning" : "info");
} catch (err) {
ctx.ui.notify(`/sync ${sub} error: ${(err as Error).message}`, "error");
}
},
});
}
+88
View File
@@ -0,0 +1,88 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { manifestPath, readManifest, writeManifest } from "./manifest.js";
let tmpHome: string;
let originalHome: string | undefined;
beforeEach(() => {
originalHome = process.env.HOME;
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-sync-manifest-"));
process.env.HOME = tmpHome;
});
afterEach(() => {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
fs.rmSync(tmpHome, { recursive: true, force: true });
});
describe("manifest", () => {
it("manifestPath lives at ~/.pi/agent/.sync-manifest.json", () => {
expect(manifestPath()).toBe(
path.join(tmpHome, ".pi", "agent", ".sync-manifest.json"),
);
});
it("returns an empty manifest when the file is missing", () => {
expect(readManifest()).toEqual({ version: 1, entries: {} });
});
it("round-trips entries", () => {
writeManifest({
version: 1,
entries: {
"pi/agent/settings.json": { mtime: 1000, size: 42 },
"pi/agent/memory/x.md": { mtime: 2000, size: 100 },
},
});
expect(readManifest().entries).toEqual({
"pi/agent/settings.json": { mtime: 1000, size: 42 },
"pi/agent/memory/x.md": { mtime: 2000, size: 100 },
});
});
it("writes mode 0600", () => {
writeManifest({ version: 1, entries: {} });
const mode = fs.statSync(manifestPath()).mode & 0o777;
expect(mode).toBe(0o600);
});
it("ignores a manifest with the wrong version", () => {
fs.mkdirSync(path.dirname(manifestPath()), { recursive: true });
fs.writeFileSync(manifestPath(), JSON.stringify({ version: 2, entries: {} }));
expect(readManifest()).toEqual({ version: 1, entries: {} });
});
it("ignores unparseable manifest content", () => {
fs.mkdirSync(path.dirname(manifestPath()), { recursive: true });
fs.writeFileSync(manifestPath(), "not json");
expect(readManifest()).toEqual({ version: 1, entries: {} });
});
it("ignores entries with malformed shape", () => {
fs.mkdirSync(path.dirname(manifestPath()), { recursive: true });
fs.writeFileSync(
manifestPath(),
JSON.stringify({
version: 1,
entries: {
"good/key": { mtime: 100, size: 1 },
"bad/key": { mtime: "nope", size: 1 },
},
}),
);
expect(readManifest().entries).toEqual({
"good/key": { mtime: 100, size: 1 },
});
});
it("writes atomically (no temp file left behind on success)", () => {
writeManifest({ version: 1, entries: { foo: { mtime: 1, size: 1 } } });
const dir = path.dirname(manifestPath());
const stragglers = fs.readdirSync(dir).filter((f) => f.endsWith(".tmp"));
expect(stragglers).toEqual([]);
});
});
+57
View File
@@ -0,0 +1,57 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
export interface ManifestEntry {
mtime: number;
size: number;
}
export interface Manifest {
version: 1;
entries: Record<string, ManifestEntry>;
}
export function manifestPath(): string {
return path.join(getAgentDir(), ".sync-manifest.json");
}
function isEntry(value: unknown): value is ManifestEntry {
return (
typeof value === "object" &&
value !== null &&
typeof (value as ManifestEntry).mtime === "number" &&
typeof (value as ManifestEntry).size === "number"
);
}
export function readManifest(): Manifest {
try {
const raw = fs.readFileSync(manifestPath(), "utf8");
const parsed = JSON.parse(raw) as unknown;
if (
parsed &&
typeof parsed === "object" &&
(parsed as { version?: unknown }).version === 1 &&
typeof (parsed as { entries?: unknown }).entries === "object" &&
(parsed as { entries?: unknown }).entries !== null
) {
const rawEntries = (parsed as { entries: Record<string, unknown> }).entries;
const entries: Record<string, ManifestEntry> = {};
for (const [k, v] of Object.entries(rawEntries)) {
if (isEntry(v)) entries[k] = v;
}
return { version: 1, entries };
}
} catch {}
return { version: 1, entries: {} };
}
export function writeManifest(manifest: Manifest): void {
const dir = getAgentDir();
fs.mkdirSync(dir, { recursive: true });
const final = manifestPath();
const tmp = `${final}.tmp`;
fs.writeFileSync(tmp, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
fs.renameSync(tmp, final);
}
+146
View File
@@ -0,0 +1,146 @@
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
import { readSyncConfig, type SyncConfig } from "./config.js";
import { createS3Wrapper } from "./s3.js";
import { executePrune, executeSync, type SyncReport } from "./sync.js";
type PanelAction =
| { kind: "close" }
| { kind: "refresh" }
| { kind: "sync" }
| { kind: "pull" }
| { kind: "push" }
| { kind: "prune" };
function renderNotConfigured(
ctx: ExtensionCommandContext,
missing: string[],
): Promise<PanelAction> {
return ctx.ui.custom<PanelAction>((_tui, theme, _kb, done) => {
function handleInput(data: string): void {
if (matchesKey(data, Key.escape) || matchesKey(data, Key.enter)) {
done({ kind: "close" });
}
}
function render(width: number): string[] {
const lines: string[] = [];
const add = (s: string) => lines.push(truncateToWidth(s, width));
add(theme.fg("accent", "─".repeat(width)));
add(theme.fg("text", " Sync (S3)"));
lines.push("");
add(theme.fg("warning", " sync not configured."));
lines.push("");
add(theme.fg("dim", " Missing env vars:"));
for (const name of missing) add(theme.fg("text", ` ${name}`));
lines.push("");
add(theme.fg("dim", " Esc / Enter close"));
add(theme.fg("accent", "─".repeat(width)));
return lines;
}
return { render, invalidate: () => {}, handleInput };
});
}
function renderStatus(
ctx: ExtensionCommandContext,
config: SyncConfig,
lastReport: SyncReport | undefined,
): Promise<PanelAction> {
return ctx.ui.custom<PanelAction>((_tui, theme, _kb, done) => {
let cached: string[] | undefined;
function handleInput(data: string): void {
if (matchesKey(data, Key.escape)) {
done({ kind: "close" });
return;
}
if (data === "r") done({ kind: "refresh" });
if (data === "s") done({ kind: "sync" });
if (data === "p") done({ kind: "pull" });
if (data === "u") done({ kind: "push" });
if (data === "x") done({ kind: "prune" });
}
function render(width: number): string[] {
if (cached) return cached;
const lines: string[] = [];
const add = (s: string) => lines.push(truncateToWidth(s, width));
add(theme.fg("accent", "─".repeat(width)));
add(theme.fg("text", " Sync (S3)"));
lines.push("");
const labelW = 11;
add(" " + theme.fg("muted", "Endpoint:".padEnd(labelW)) + theme.fg("text", config.endpoint));
add(" " + theme.fg("muted", "Bucket:".padEnd(labelW)) + theme.fg("text", config.bucket));
add(" " + theme.fg("muted", "Prefix:".padEnd(labelW)) + theme.fg("text", config.prefix === "" ? "(root)" : config.prefix));
lines.push("");
if (lastReport) {
const summary =
lastReport.mode === "prune"
? `${lastReport.deleted} deleted, ${lastReport.errors} errors`
: `${lastReport.pushed} pushed, ${lastReport.pulled} pulled, ${lastReport.skipped} skipped, ${lastReport.errors} errors`;
add(" " + theme.fg("muted", "Last:".padEnd(labelW)) + theme.fg("text", `${lastReport.mode}${summary}`));
for (const entry of lastReport.actions.slice(0, 20)) {
const tag =
entry.status === "error"
? theme.fg("warning", "✗")
: entry.action.kind === "upload"
? theme.fg("success", "↑")
: entry.action.kind === "download"
? theme.fg("success", "↓")
: entry.action.kind === "delete-remote"
? theme.fg("warning", "✗")
: theme.fg("dim", "·");
const relPath = "relPath" in entry.action ? entry.action.relPath : "";
add(` ${tag} ${theme.fg("text", relPath)}`);
}
if (lastReport.actions.length > 20) {
add(theme.fg("dim", `${lastReport.actions.length - 20} more`));
}
} else {
add(" " + theme.fg("muted", "Last:".padEnd(labelW)) + theme.fg("dim", "(no sync this session)"));
}
lines.push("");
add(theme.fg("dim", " r refresh • s sync now • p pull • u push • x prune • Esc close"));
add(theme.fg("accent", "─".repeat(width)));
cached = lines;
return lines;
}
return {
render,
invalidate: () => {
cached = undefined;
},
handleInput,
};
});
}
export async function openSyncPanel(ctx: ExtensionCommandContext): Promise<void> {
let lastReport: SyncReport | undefined;
for (;;) {
const cfg = readSyncConfig();
if (!cfg.ok) {
const action = await renderNotConfigured(ctx, cfg.missing);
if (action.kind === "close") return;
continue;
}
const action = await renderStatus(ctx, cfg.config, lastReport);
if (action.kind === "close") return;
if (action.kind === "refresh") {
lastReport = undefined;
continue;
}
const s3 = createS3Wrapper(cfg.config);
try {
if (action.kind === "sync") {
lastReport = await executeSync({ mode: "sync", s3, prefix: cfg.config.prefix });
} else if (action.kind === "pull") {
lastReport = await executeSync({ mode: "pull", s3, prefix: cfg.config.prefix });
} else if (action.kind === "push") {
lastReport = await executeSync({ mode: "push", s3, prefix: cfg.config.prefix });
} else if (action.kind === "prune") {
lastReport = await executePrune({ s3, prefix: cfg.config.prefix });
}
} catch (err) {
ctx.ui.notify(`sync error: ${(err as Error).message}`, "error");
}
}
}
+120
View File
@@ -0,0 +1,120 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
SYNCED_FILES,
SYNCED_DIRS,
EXCLUDED_FILES,
keyForRelPath,
relPathForKey,
walkLocalFiles,
} from "./paths.js";
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-sync-paths-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe("paths allowlist", () => {
it("includes the expected config files", () => {
expect(SYNCED_FILES).toEqual([
"settings.json",
"k8s.json",
"websearch.json",
"custom-providers.json",
"hosts.json",
"local-models.json",
]);
});
it("includes the memory directory", () => {
expect(SYNCED_DIRS).toEqual(["memory"]);
});
it("explicitly excludes auth.json and the manifest", () => {
expect(EXCLUDED_FILES).toContain("auth.json");
expect(EXCLUDED_FILES).toContain(".sync-manifest.json");
});
});
describe("keyForRelPath", () => {
it("prefixes with <prefix>/agent/", () => {
expect(keyForRelPath("pi", "settings.json")).toBe("pi/agent/settings.json");
});
it("handles empty prefix", () => {
expect(keyForRelPath("", "settings.json")).toBe("agent/settings.json");
});
it("preserves nested memory paths", () => {
expect(keyForRelPath("pi", "memory/global/feedback/foo.md")).toBe(
"pi/agent/memory/global/feedback/foo.md",
);
});
it("strips trailing slashes from the prefix", () => {
expect(keyForRelPath("pi/", "settings.json")).toBe("pi/agent/settings.json");
});
});
describe("relPathForKey", () => {
it("inverts keyForRelPath", () => {
expect(relPathForKey("pi", "pi/agent/settings.json")).toBe("settings.json");
expect(relPathForKey("pi", "pi/agent/memory/x/y.md")).toBe("memory/x/y.md");
expect(relPathForKey("", "agent/settings.json")).toBe("settings.json");
});
it("returns undefined for a key outside the prefix", () => {
expect(relPathForKey("pi", "other/agent/settings.json")).toBeUndefined();
expect(relPathForKey("pi", "pi/different/settings.json")).toBeUndefined();
});
});
describe("walkLocalFiles", () => {
it("returns an empty array when nothing exists", async () => {
expect(await walkLocalFiles(tmpDir)).toEqual([]);
});
it("returns listed SYNCED_FILES that exist", async () => {
fs.writeFileSync(path.join(tmpDir, "settings.json"), "{}");
fs.writeFileSync(path.join(tmpDir, "k8s.json"), "{}");
const result = await walkLocalFiles(tmpDir);
expect(result.sort()).toEqual(["k8s.json", "settings.json"]);
});
it("walks the memory directory recursively", async () => {
fs.mkdirSync(path.join(tmpDir, "memory", "global", "feedback"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "memory", "MEMORY.md"), "#");
fs.writeFileSync(path.join(tmpDir, "memory", "global", "feedback", "foo.md"), "x");
const result = await walkLocalFiles(tmpDir);
expect(result.sort()).toEqual([
"memory/MEMORY.md",
"memory/global/feedback/foo.md",
]);
});
it("skips EXCLUDED_FILES even if present", async () => {
fs.writeFileSync(path.join(tmpDir, "settings.json"), "{}");
fs.writeFileSync(path.join(tmpDir, "auth.json"), "{}");
fs.writeFileSync(path.join(tmpDir, ".sync-manifest.json"), "{}");
const result = await walkLocalFiles(tmpDir);
expect(result).toEqual(["settings.json"]);
});
it("does NOT drop memory entries whose basename matches an excluded file", async () => {
fs.mkdirSync(path.join(tmpDir, "memory"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "memory", "auth.json"), "{}");
fs.writeFileSync(path.join(tmpDir, "memory", ".sync-manifest.json"), "{}");
const result = await walkLocalFiles(tmpDir);
expect(result.sort()).toEqual([
"memory/.sync-manifest.json",
"memory/auth.json",
]);
});
});
+70
View File
@@ -0,0 +1,70 @@
import * as fs from "node:fs";
import * as path from "node:path";
export const SYNCED_FILES: readonly string[] = [
"settings.json",
"k8s.json",
"websearch.json",
"custom-providers.json",
"hosts.json",
"local-models.json",
];
export const SYNCED_DIRS: readonly string[] = ["memory"];
export const EXCLUDED_FILES: readonly string[] = [
"auth.json",
".sync-manifest.json",
];
function normalizePrefix(prefix: string): string {
return prefix.replace(/\/+$/, "");
}
export function keyForRelPath(prefix: string, relPath: string): string {
const normalized = normalizePrefix(prefix);
const parts = normalized === "" ? ["agent", relPath] : [normalized, "agent", relPath];
return parts.join("/");
}
export function relPathForKey(prefix: string, key: string): string | undefined {
const normalized = normalizePrefix(prefix);
const expected = normalized === "" ? "agent/" : `${normalized}/agent/`;
if (!key.startsWith(expected)) return undefined;
return key.slice(expected.length);
}
async function walkDir(absDir: string, relDir: string, out: string[]): Promise<void> {
let entries: fs.Dirent[];
try {
entries = await fs.promises.readdir(absDir, { withFileTypes: true });
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
throw err;
}
for (const entry of entries) {
const childAbs = path.join(absDir, entry.name);
const childRel = relDir === "" ? entry.name : `${relDir}/${entry.name}`;
if (entry.isDirectory()) {
await walkDir(childAbs, childRel, out);
} else if (entry.isFile()) {
out.push(childRel);
}
}
}
export async function walkLocalFiles(agentDir: string): Promise<string[]> {
const found: string[] = [];
for (const file of SYNCED_FILES) {
try {
const stat = await fs.promises.stat(path.join(agentDir, file));
if (stat.isFile()) found.push(file);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
}
}
for (const dir of SYNCED_DIRS) {
await walkDir(path.join(agentDir, dir), dir, found);
}
return found.filter((rel) => !EXCLUDED_FILES.includes(rel));
}
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, it, vi } from "vitest";
import {
createS3Wrapper,
MTIME_META_KEY,
type S3Object,
} from "./s3.js";
import type { SyncConfig } from "./config.js";
const config: SyncConfig = {
accessKeyId: "ak",
secretAccessKey: "sk",
endpoint: "https://rustfs.lan",
bucket: "homelab",
region: "us-east-1",
prefix: "pi",
forcePathStyle: true,
};
function makeMockClient(send: (cmd: unknown) => Promise<unknown>) {
return { send: vi.fn(send) } as unknown as Parameters<typeof createS3Wrapper>[1];
}
describe("S3 wrapper", () => {
it("MTIME_META_KEY is pi-mtime", () => {
expect(MTIME_META_KEY).toBe("pi-mtime");
});
it("list paginates via continuation tokens and parses pi-mtime", async () => {
const objectsByKey: Record<string, { Metadata?: Record<string, string> }> = {
"pi/agent/a.json": { Metadata: { [MTIME_META_KEY]: "1500" } },
"pi/agent/b.json": { Metadata: {} },
};
let listCallCount = 0;
const client = makeMockClient(async (cmd) => {
const name = (cmd as { constructor: { name: string } }).constructor.name;
if (name === "HeadObjectCommand") {
const key = (cmd as { input: { Key: string } }).input.Key;
return objectsByKey[key] ?? {};
}
// ListObjectsV2Command — return two pages
listCallCount++;
if (listCallCount === 1) {
return {
Contents: [
{ Key: "pi/agent/a.json", Size: 10, LastModified: new Date(1000) },
],
NextContinuationToken: "t1",
IsTruncated: true,
};
}
return {
Contents: [
{ Key: "pi/agent/b.json", Size: 20, LastModified: new Date(3000) },
],
IsTruncated: false,
};
});
const wrapper = createS3Wrapper(config, client);
const result = await wrapper.list("pi/agent/");
expect(result.map((r) => r.key).sort()).toEqual([
"pi/agent/a.json",
"pi/agent/b.json",
]);
// a.json uses pi-mtime metadata (1500); b.json falls back to LastModified (3000).
const map = new Map<string, S3Object>(result.map((r) => [r.key, r]));
expect(map.get("pi/agent/a.json")?.mtime).toBe(1500);
expect(map.get("pi/agent/b.json")?.mtime).toBe(3000);
});
it("put attaches pi-mtime metadata", async () => {
let captured: unknown;
const client = makeMockClient(async (cmd) => {
captured = cmd;
return {};
});
const wrapper = createS3Wrapper(config, client);
await wrapper.put("pi/agent/x.json", Buffer.from("hello"), 12345);
expect((captured as { input: Record<string, unknown> }).input).toMatchObject({
Bucket: "homelab",
Key: "pi/agent/x.json",
Metadata: { [MTIME_META_KEY]: "12345" },
});
});
it("get returns body + mtime from metadata", async () => {
const client = makeMockClient(async () => ({
Body: {
transformToByteArray: async () =>
new Uint8Array(Buffer.from("hello")),
},
Metadata: { [MTIME_META_KEY]: "9999" },
LastModified: new Date(5000),
}));
const wrapper = createS3Wrapper(config, client);
const result = await wrapper.get("pi/agent/x.json");
expect(result.body.toString()).toBe("hello");
expect(result.mtime).toBe(9999);
});
it("get falls back to LastModified when pi-mtime metadata is absent", async () => {
const client = makeMockClient(async () => ({
Body: {
transformToByteArray: async () => new Uint8Array(Buffer.from("h")),
},
Metadata: {},
LastModified: new Date(5000),
}));
const wrapper = createS3Wrapper(config, client);
expect((await wrapper.get("pi/agent/x.json")).mtime).toBe(5000);
});
it("delete sends DeleteObjectCommand with bucket+key", async () => {
let captured: unknown;
const client = makeMockClient(async (cmd) => {
captured = cmd;
return {};
});
const wrapper = createS3Wrapper(config, client);
await wrapper.delete("pi/agent/x.json");
expect((captured as { input: Record<string, unknown> }).input).toMatchObject({
Bucket: "homelab",
Key: "pi/agent/x.json",
});
});
});
+119
View File
@@ -0,0 +1,119 @@
import {
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import type { SyncConfig } from "./config.js";
export const MTIME_META_KEY = "pi-mtime";
export interface S3Object {
key: string;
mtime: number;
size: number;
}
export interface S3Wrapper {
list(prefix: string): Promise<S3Object[]>;
get(key: string): Promise<{ body: Buffer; mtime: number }>;
put(key: string, body: Buffer, mtimeMs: number): Promise<void>;
delete(key: string): Promise<void>;
}
interface MinimalS3Client {
send(cmd: unknown): Promise<unknown>;
}
export function createS3Client(config: SyncConfig): S3Client {
return new S3Client({
region: config.region,
endpoint: config.endpoint,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
forcePathStyle: config.forcePathStyle,
});
}
function parseMtime(metadata: Record<string, string> | undefined, fallback: Date | undefined): number {
const raw = metadata?.[MTIME_META_KEY];
if (raw !== undefined) {
const n = Number(raw);
if (Number.isFinite(n)) return n;
}
return fallback ? fallback.getTime() : 0;
}
export function createS3Wrapper(
config: SyncConfig,
client: MinimalS3Client = createS3Client(config),
): S3Wrapper {
const bucket = config.bucket;
async function list(prefix: string): Promise<S3Object[]> {
const results: S3Object[] = [];
let token: string | undefined;
do {
const resp = (await client.send(
new ListObjectsV2Command({
Bucket: bucket,
Prefix: prefix,
ContinuationToken: token,
}),
)) as {
Contents?: { Key?: string; Size?: number; LastModified?: Date }[];
NextContinuationToken?: string;
IsTruncated?: boolean;
};
for (const obj of resp.Contents ?? []) {
if (!obj.Key) continue;
const head = (await client.send(
new HeadObjectCommand({ Bucket: bucket, Key: obj.Key }),
)) as { Metadata?: Record<string, string> };
results.push({
key: obj.Key,
mtime: parseMtime(head.Metadata, obj.LastModified),
size: obj.Size ?? 0,
});
}
token = resp.IsTruncated ? resp.NextContinuationToken : undefined;
} while (token);
return results;
}
async function get(key: string): Promise<{ body: Buffer; mtime: number }> {
const resp = (await client.send(
new GetObjectCommand({ Bucket: bucket, Key: key }),
)) as {
Body: { transformToByteArray(): Promise<Uint8Array> };
Metadata?: Record<string, string>;
LastModified?: Date;
};
const bytes = await resp.Body.transformToByteArray();
return {
body: Buffer.from(bytes),
mtime: parseMtime(resp.Metadata, resp.LastModified),
};
}
async function put(key: string, body: Buffer, mtimeMs: number): Promise<void> {
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: body,
Metadata: { [MTIME_META_KEY]: String(Math.floor(mtimeMs)) },
}),
);
}
async function deleteObject(key: string): Promise<void> {
await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
}
return { list, get, put, delete: deleteObject };
}
+273
View File
@@ -0,0 +1,273 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { computeDiff, computePrune, executeSync, executePrune } from "./sync.js";
import type { S3Wrapper } from "./s3.js";
import type { Manifest } from "./manifest.js";
describe("computeDiff", () => {
it("only-local → upload", () => {
const local = new Map([["a", { mtime: 100, size: 1 }]]);
const remote = new Map();
const actions = computeDiff(local, remote);
expect(actions).toEqual([
{ kind: "upload", relPath: "a", reason: "only-local", mtime: 100, size: 1 },
]);
});
it("only-remote → download", () => {
const local = new Map();
const remote = new Map([["a", { mtime: 100, size: 1 }]]);
expect(computeDiff(local, remote)).toEqual([
{ kind: "download", relPath: "a", reason: "only-remote", mtime: 100, size: 1 },
]);
});
it("equal mtimes within 1s tolerance → skip", () => {
const local = new Map([["a", { mtime: 1000, size: 1 }]]);
const remote = new Map([["a", { mtime: 1500, size: 1 }]]);
const actions = computeDiff(local, remote);
expect(actions).toEqual([{ kind: "skip", relPath: "a", reason: "tolerance" }]);
});
it("local newer beyond tolerance → upload", () => {
const local = new Map([["a", { mtime: 5000, size: 1 }]]);
const remote = new Map([["a", { mtime: 1000, size: 1 }]]);
expect(computeDiff(local, remote)).toEqual([
{ kind: "upload", relPath: "a", reason: "local-newer", mtime: 5000, size: 1 },
]);
});
it("remote newer beyond tolerance → download", () => {
const local = new Map([["a", { mtime: 1000, size: 1 }]]);
const remote = new Map([["a", { mtime: 5000, size: 1 }]]);
expect(computeDiff(local, remote)).toEqual([
{ kind: "download", relPath: "a", reason: "remote-newer", mtime: 5000, size: 1 },
]);
});
it("results are sorted by relPath for determinism", () => {
const local = new Map([["z", { mtime: 1, size: 1 }]]);
const remote = new Map([["a", { mtime: 1, size: 1 }]]);
const actions = computeDiff(local, remote);
expect(actions.map((a) => a.relPath)).toEqual(["a", "z"]);
});
});
describe("computePrune", () => {
const manifest: Manifest = {
version: 1,
entries: {
"agent/keep.json": { mtime: 1, size: 1 },
"agent/orphan.json": { mtime: 1, size: 1 },
},
};
it("returns delete-remote for manifest entries missing locally", () => {
const local = new Map([["keep.json", { mtime: 1, size: 1 }]]);
const actions = computePrune(local, manifest, "");
expect(actions).toEqual([
{ kind: "delete-remote", relPath: "orphan.json", key: "agent/orphan.json" },
]);
});
it("leaves alone manifest entries whose local file still exists", () => {
const local = new Map([
["keep.json", { mtime: 1, size: 1 }],
["orphan.json", { mtime: 1, size: 1 }],
]);
expect(computePrune(local, manifest, "")).toEqual([]);
});
it("ignores manifest entries that fall outside the configured prefix", () => {
const altManifest: Manifest = {
version: 1,
entries: { "other/agent/foo.json": { mtime: 1, size: 1 } },
};
const local = new Map();
expect(computePrune(local, altManifest, "pi")).toEqual([]);
});
});
// ─── executeSync / executePrune integration tests with mocked S3 + tmp dir ───
let tmpHome: string;
let originalHome: string | undefined;
function mkMockS3(): {
wrapper: S3Wrapper;
objects: Map<string, { body: Buffer; mtime: number; size: number }>;
} {
const objects = new Map<string, { body: Buffer; mtime: number; size: number }>();
const wrapper: S3Wrapper = {
list: vi.fn(async (prefix: string) => {
const out = [];
for (const [key, val] of objects) {
if (key.startsWith(prefix)) out.push({ key, mtime: val.mtime, size: val.size });
}
return out;
}),
get: vi.fn(async (key: string) => {
const obj = objects.get(key);
if (!obj) throw new Error(`NoSuchKey: ${key}`);
return { body: obj.body, mtime: obj.mtime };
}),
put: vi.fn(async (key: string, body: Buffer, mtimeMs: number) => {
objects.set(key, { body, mtime: mtimeMs, size: body.length });
}),
delete: vi.fn(async (key: string) => {
objects.delete(key);
}),
};
return { wrapper, objects };
}
beforeEach(() => {
originalHome = process.env.HOME;
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-sync-exec-"));
process.env.HOME = tmpHome;
fs.mkdirSync(path.join(tmpHome, ".pi", "agent"), { recursive: true });
});
afterEach(() => {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
fs.rmSync(tmpHome, { recursive: true, force: true });
});
describe("executeSync", () => {
function writeLocal(rel: string, body: string, mtime: number): void {
const abs = path.join(tmpHome, ".pi", "agent", rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, body);
fs.utimesSync(abs, mtime / 1000, mtime / 1000);
}
it("pull mode downloads only-remote files (atomic write) and updates manifest", async () => {
const { wrapper, objects } = mkMockS3();
objects.set("agent/settings.json", {
body: Buffer.from('{"x":1}'),
mtime: 1000,
size: 7,
});
const report = await executeSync({
mode: "pull",
s3: wrapper,
prefix: "",
});
expect(report.pulled).toBe(1);
expect(report.errors).toBe(0);
const settings = fs.readFileSync(
path.join(tmpHome, ".pi", "agent", "settings.json"),
"utf8",
);
expect(settings).toBe('{"x":1}');
// No leftover .tmp files
expect(
fs
.readdirSync(path.join(tmpHome, ".pi", "agent"))
.filter((f) => f.endsWith(".tmp")),
).toEqual([]);
});
it("push mode uploads only-local files", async () => {
const { wrapper, objects } = mkMockS3();
writeLocal("settings.json", '{"x":1}', 1000);
const report = await executeSync({ mode: "push", s3: wrapper, prefix: "" });
expect(report.pushed).toBe(1);
expect(objects.has("agent/settings.json")).toBe(true);
expect(objects.get("agent/settings.json")?.body.toString()).toBe('{"x":1}');
});
it("sync mode resolves conflicts by mtime", async () => {
const { wrapper, objects } = mkMockS3();
writeLocal("settings.json", "LOCAL", 5000);
objects.set("agent/settings.json", {
body: Buffer.from("REMOTE"),
mtime: 1000,
size: 6,
});
writeLocal("k8s.json", "LOCAL2", 1000);
objects.set("agent/k8s.json", {
body: Buffer.from("REMOTE2"),
mtime: 5000,
size: 7,
});
await executeSync({ mode: "sync", s3: wrapper, prefix: "" });
// settings.json: local newer → uploaded
expect(objects.get("agent/settings.json")?.body.toString()).toBe("LOCAL");
// k8s.json: remote newer → downloaded
expect(
fs.readFileSync(path.join(tmpHome, ".pi", "agent", "k8s.json"), "utf8"),
).toBe("REMOTE2");
});
it("partial pull failure: manifest only records successfully-downloaded entries", async () => {
const { wrapper, objects } = mkMockS3();
objects.set("agent/settings.json", { body: Buffer.from("S"), mtime: 1, size: 1 });
objects.set("agent/k8s.json", { body: Buffer.from("K"), mtime: 1, size: 1 });
(wrapper.get as ReturnType<typeof vi.fn>).mockImplementation(
async (key: string) => {
if (key === "agent/k8s.json") throw new Error("boom");
return { body: Buffer.from("S"), mtime: 1 };
},
);
const report = await executeSync({ mode: "pull", s3: wrapper, prefix: "" });
expect(report.errors).toBe(1);
const { readManifest } = await import("./manifest.js");
const m = readManifest();
expect(Object.keys(m.entries).sort()).toEqual(["agent/settings.json"]);
});
it("never touches auth.json or .sync-manifest.json", async () => {
const { wrapper, objects } = mkMockS3();
writeLocal("auth.json", "SECRET", 1000);
writeLocal("settings.json", "OK", 1000);
await executeSync({ mode: "push", s3: wrapper, prefix: "" });
expect(objects.has("agent/auth.json")).toBe(false);
expect(objects.has("agent/settings.json")).toBe(true);
});
});
describe("executePrune", () => {
function writeLocal(rel: string, body: string): void {
const abs = path.join(tmpHome, ".pi", "agent", rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, body);
}
it("deletes remote keys that were in manifest but missing locally", async () => {
const { wrapper, objects } = mkMockS3();
const { writeManifest } = await import("./manifest.js");
writeLocal("settings.json", "K");
writeManifest({
version: 1,
entries: {
"agent/settings.json": { mtime: 1, size: 1 },
"agent/k8s.json": { mtime: 1, size: 1 },
},
});
objects.set("agent/k8s.json", {
body: Buffer.from("X"),
mtime: 1,
size: 1,
});
const report = await executePrune({ s3: wrapper, prefix: "" });
expect(report.deleted).toBe(1);
expect(objects.has("agent/k8s.json")).toBe(false);
});
it("leaves S3-only-never-pulled keys alone", async () => {
const { wrapper, objects } = mkMockS3();
objects.set("agent/never-saw.json", {
body: Buffer.from("X"),
mtime: 1,
size: 1,
});
// Empty manifest — never pulled.
const report = await executePrune({ s3: wrapper, prefix: "" });
expect(report.deleted).toBe(0);
expect(objects.has("agent/never-saw.json")).toBe(true);
});
});
+229
View File
@@ -0,0 +1,229 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import {
EXCLUDED_FILES,
keyForRelPath,
relPathForKey,
walkLocalFiles,
} from "./paths.js";
import { readManifest, writeManifest, type Manifest } from "./manifest.js";
import type { S3Wrapper } from "./s3.js";
export interface FileInfo {
mtime: number;
size: number;
}
export type Action =
| { kind: "upload"; relPath: string; reason: "only-local" | "local-newer"; mtime: number; size: number }
| { kind: "download"; relPath: string; reason: "only-remote" | "remote-newer"; mtime: number; size: number }
| { kind: "skip"; relPath: string; reason: "tolerance" }
| { kind: "delete-remote"; relPath: string; key: string };
export type SyncMode = "pull" | "push" | "sync";
export interface SyncReport {
mode: SyncMode | "prune";
actions: { action: Action; status: "done" | "error"; error?: string }[];
pushed: number;
pulled: number;
skipped: number;
deleted: number;
errors: number;
}
const MTIME_TOLERANCE_MS = 1000;
export function computeDiff(
local: Map<string, FileInfo>,
remote: Map<string, FileInfo>,
): Action[] {
const allKeys = new Set([...local.keys(), ...remote.keys()]);
const sorted = [...allKeys].sort();
const actions: Action[] = [];
for (const relPath of sorted) {
const l = local.get(relPath);
const r = remote.get(relPath);
if (l && !r) {
actions.push({ kind: "upload", relPath, reason: "only-local", mtime: l.mtime, size: l.size });
} else if (!l && r) {
actions.push({ kind: "download", relPath, reason: "only-remote", mtime: r.mtime, size: r.size });
} else if (l && r) {
const delta = Math.abs(l.mtime - r.mtime);
if (delta <= MTIME_TOLERANCE_MS) {
actions.push({ kind: "skip", relPath, reason: "tolerance" });
} else if (l.mtime > r.mtime) {
actions.push({ kind: "upload", relPath, reason: "local-newer", mtime: l.mtime, size: l.size });
} else {
actions.push({ kind: "download", relPath, reason: "remote-newer", mtime: r.mtime, size: r.size });
}
}
}
return actions;
}
export function computePrune(
local: Map<string, FileInfo>,
manifest: Manifest,
prefix: string,
): Action[] {
const actions: Action[] = [];
for (const [key] of Object.entries(manifest.entries)) {
const relPath = relPathForKey(prefix, key);
if (relPath === undefined) continue;
if (local.has(relPath)) continue;
actions.push({ kind: "delete-remote", relPath, key });
}
return actions.sort((a, b) => a.relPath.localeCompare(b.relPath));
}
async function statLocal(agentDir: string, relPath: string): Promise<FileInfo | undefined> {
try {
const s = await fs.promises.stat(path.join(agentDir, relPath));
return { mtime: s.mtimeMs, size: s.size };
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined;
throw err;
}
}
async function buildLocalMap(agentDir: string): Promise<Map<string, FileInfo>> {
const files = await walkLocalFiles(agentDir);
const map = new Map<string, FileInfo>();
for (const rel of files) {
const info = await statLocal(agentDir, rel);
if (info) map.set(rel, info);
}
return map;
}
async function buildRemoteMap(s3: S3Wrapper, prefix: string): Promise<Map<string, FileInfo>> {
const objects = await s3.list(prefix === "" ? "agent/" : `${prefix}/agent/`);
const map = new Map<string, FileInfo>();
for (const obj of objects) {
const rel = relPathForKey(prefix, obj.key);
if (rel === undefined) continue;
const base = path.basename(rel);
if (EXCLUDED_FILES.includes(rel) || EXCLUDED_FILES.includes(base)) continue;
map.set(rel, { mtime: obj.mtime, size: obj.size });
}
return map;
}
async function atomicWrite(absPath: string, body: Buffer, mtimeMs: number): Promise<void> {
await fs.promises.mkdir(path.dirname(absPath), { recursive: true });
const tmp = `${absPath}.tmp`;
await fs.promises.writeFile(tmp, body);
const mtimeSec = mtimeMs / 1000;
await fs.promises.utimes(tmp, mtimeSec, mtimeSec);
await fs.promises.rename(tmp, absPath);
}
export async function executeSync(opts: {
mode: SyncMode;
s3: S3Wrapper;
prefix: string;
}): Promise<SyncReport> {
const { mode, s3, prefix } = opts;
const agentDir = getAgentDir();
const local = await buildLocalMap(agentDir);
const remote = await buildRemoteMap(s3, prefix);
const allActions = computeDiff(local, remote);
const filtered = allActions.filter((a) => {
if (mode === "pull") return a.kind === "download" || a.kind === "skip";
if (mode === "push") return a.kind === "upload" || a.kind === "skip";
return true;
});
const manifest = readManifest();
const report: SyncReport = {
mode,
actions: [],
pushed: 0,
pulled: 0,
skipped: 0,
deleted: 0,
errors: 0,
};
for (const action of filtered) {
try {
if (action.kind === "upload") {
const abs = path.join(agentDir, action.relPath);
const body = await fs.promises.readFile(abs);
const key = keyForRelPath(prefix, action.relPath);
await s3.put(key, body, action.mtime);
manifest.entries[key] = { mtime: action.mtime, size: action.size };
report.pushed++;
report.actions.push({ action, status: "done" });
} else if (action.kind === "download") {
const key = keyForRelPath(prefix, action.relPath);
const { body, mtime } = await s3.get(key);
const abs = path.join(agentDir, action.relPath);
await atomicWrite(abs, body, mtime);
manifest.entries[key] = { mtime, size: body.length };
report.pulled++;
report.actions.push({ action, status: "done" });
} else if (action.kind === "skip") {
const key = keyForRelPath(prefix, action.relPath);
const info = local.get(action.relPath);
if (info) manifest.entries[key] = { mtime: info.mtime, size: info.size };
report.skipped++;
report.actions.push({ action, status: "done" });
}
} catch (err) {
report.errors++;
report.actions.push({
action,
status: "error",
error: (err as Error).message,
});
}
}
writeManifest(manifest);
return report;
}
export async function executePrune(opts: {
s3: S3Wrapper;
prefix: string;
}): Promise<SyncReport> {
const { s3, prefix } = opts;
const agentDir = getAgentDir();
const local = await buildLocalMap(agentDir);
const manifest = readManifest();
const actions = computePrune(local, manifest, prefix);
const report: SyncReport = {
mode: "prune",
actions: [],
pushed: 0,
pulled: 0,
skipped: 0,
deleted: 0,
errors: 0,
};
for (const action of actions) {
if (action.kind !== "delete-remote") continue;
try {
await s3.delete(action.key);
delete manifest.entries[action.key];
report.deleted++;
report.actions.push({ action, status: "done" });
} catch (err) {
report.errors++;
report.actions.push({
action,
status: "error",
error: (err as Error).message,
});
}
}
writeManifest(manifest);
return report;
}
+331 -195
View File
@@ -8,6 +8,7 @@
"name": "pi-customizations",
"version": "0.1.0",
"dependencies": {
"@aws-sdk/client-s3": "3.1057.0",
"@mozilla/readability": "^0.5.0",
"linkedom": "^0.18.0",
"turndown": "^7.2.0",
@@ -55,7 +56,6 @@
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
"integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@aws-crypto/util": "^5.2.0",
"@aws-sdk/types": "^3.222.0",
@@ -65,12 +65,36 @@
"node": ">=16.0.0"
}
},
"node_modules/@aws-crypto/crc32c": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz",
"integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/util": "^5.2.0",
"@aws-sdk/types": "^3.222.0",
"tslib": "^2.6.2"
}
},
"node_modules/@aws-crypto/sha1-browser": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz",
"integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/supports-web-crypto": "^5.2.0",
"@aws-crypto/util": "^5.2.0",
"@aws-sdk/types": "^3.222.0",
"@aws-sdk/util-locate-window": "^3.0.0",
"@smithy/util-utf8": "^2.0.0",
"tslib": "^2.6.2"
}
},
"node_modules/@aws-crypto/sha256-browser": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz",
"integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@aws-crypto/sha256-js": "^5.2.0",
"@aws-crypto/supports-web-crypto": "^5.2.0",
@@ -86,7 +110,6 @@
"resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz",
"integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@aws-crypto/util": "^5.2.0",
"@aws-sdk/types": "^3.222.0",
@@ -101,7 +124,6 @@
"resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz",
"integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.6.2"
}
@@ -111,7 +133,6 @@
"resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz",
"integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@aws-sdk/types": "^3.222.0",
"@smithy/util-utf8": "^2.0.0",
@@ -152,18 +173,61 @@
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/core": {
"version": "3.974.9",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.9.tgz",
"integrity": "sha512-bXxosFunr+v/kqNb99r1NRkrVBha7CG036fRSpWGbC1A/e363XFQN6wcZMx7MYTdRr1tYwNnkrWX2xc1rT3BCQ==",
"node_modules/@aws-sdk/client-s3": {
"version": "3.1057.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1057.0.tgz",
"integrity": "sha512-4MV5+ph7WSLEqStKYdWf2EIHIvLpPzV8xN98jWSVJfUpp5j7T8dyN3AROPPsKWvCme8hbx1ybCjtK76ALCZUYg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@aws-sdk/types": "^3.973.8",
"@aws-sdk/xml-builder": "^3.972.23",
"@smithy/core": "^3.24.1",
"@smithy/signature-v4": "^5.4.1",
"@smithy/types": "^4.14.1",
"@aws-crypto/sha1-browser": "5.2.0",
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "^3.974.15",
"@aws-sdk/credential-provider-node": "^3.972.47",
"@aws-sdk/middleware-bucket-endpoint": "^3.972.17",
"@aws-sdk/middleware-expect-continue": "^3.972.14",
"@aws-sdk/middleware-flexible-checksums": "^3.974.23",
"@aws-sdk/middleware-location-constraint": "^3.972.11",
"@aws-sdk/middleware-sdk-s3": "^3.972.44",
"@aws-sdk/middleware-ssec": "^3.972.11",
"@aws-sdk/signature-v4-multi-region": "^3.996.30",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.5",
"@smithy/fetch-http-handler": "^5.4.5",
"@smithy/node-http-handler": "^4.7.5",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/core": {
"version": "3.974.15",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.15.tgz",
"integrity": "sha512-UpA0rTGW/tHGITcCqHisbuuEPraYg9GG+mWmXjY5+RxZBMLGe6aL9oe0ix50LztwAcPIkGZLH0yWdMIkCM10hw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.9",
"@aws-sdk/xml-builder": "^3.972.26",
"@aws/lambda-invoke-store": "^0.2.2",
"@smithy/core": "^3.24.5",
"@smithy/signature-v4": "^5.4.5",
"@smithy/types": "^4.14.2",
"bowser": "^2.11.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/crc64-nvme": {
"version": "3.972.9",
"resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.9.tgz",
"integrity": "sha512-P+QGozmXn2mZZI7sDgk+aUm+RTI61MPSFB+Ir2vjEjEbEsE4e7hYtzrDvAUxZy9ko81h53e11+F/GYlvwDkaOQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
@@ -171,16 +235,15 @@
}
},
"node_modules/@aws-sdk/credential-provider-env": {
"version": "3.972.35",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.35.tgz",
"integrity": "sha512-WkFQ8BedszVomhh/Zzs8WwnE/XBmTqZjoQVB8u/4zH6kZCjouXZpPpb93gD8m0EZmzAl7dxHE/y+yDpuKzNCMw==",
"version": "3.972.41",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.41.tgz",
"integrity": "sha512-n1EbJ98yvPWWdHZZv8bRBMqqDQJrtgtxyJ4xLy2Uqrh25BCOZQ7nnS1CsFXvuH8r0b0KVHDZEGEH5FxmEMP8jg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@aws-sdk/core": "^3.974.9",
"@aws-sdk/types": "^3.973.8",
"@smithy/core": "^3.24.1",
"@smithy/types": "^4.14.1",
"@aws-sdk/core": "^3.974.15",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.5",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
@@ -188,18 +251,17 @@
}
},
"node_modules/@aws-sdk/credential-provider-http": {
"version": "3.972.37",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.37.tgz",
"integrity": "sha512-ylx0ZJTU+2eNcvXQ69VNR3TVSYa/ibpvdK717/NxqR9aXRMn2QRWZaiI8aa5yY/fOWZ5mknSmxGaVxxtdwv3EA==",
"version": "3.972.43",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.43.tgz",
"integrity": "sha512-TT76RN1NkI9WoyZqCNxOw6/WBMF7pYOTJcXbMokNFU+euSG40Kaf/t/FhDACVZWP+43wEM6ZynIPIkzS1wR1iA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@aws-sdk/core": "^3.974.9",
"@aws-sdk/types": "^3.973.8",
"@smithy/core": "^3.24.1",
"@smithy/fetch-http-handler": "^5.4.1",
"@smithy/node-http-handler": "^4.7.1",
"@smithy/types": "^4.14.1",
"@aws-sdk/core": "^3.974.15",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.5",
"@smithy/fetch-http-handler": "^5.4.5",
"@smithy/node-http-handler": "^4.7.5",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
@@ -207,24 +269,23 @@
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.972.39",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.39.tgz",
"integrity": "sha512-QhRSrdkk+Gq0AFIylpiI0N6lcJqFYV9Jtr4Luz5FpYOYbjJSfyTG6iLhnK/UPIgN1Jnon8WAmSC//16XYGvwkA==",
"version": "3.972.46",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.46.tgz",
"integrity": "sha512-hvcgcwOiS0nb2XFb5Op1Pz/vYaWz5K8kKullziGpdNRuG0NwzRXseuPt2CoBqknHGaSPVesu1aOn2OcctEYdCA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@aws-sdk/core": "^3.974.9",
"@aws-sdk/credential-provider-env": "^3.972.35",
"@aws-sdk/credential-provider-http": "^3.972.37",
"@aws-sdk/credential-provider-login": "^3.972.39",
"@aws-sdk/credential-provider-process": "^3.972.35",
"@aws-sdk/credential-provider-sso": "^3.972.39",
"@aws-sdk/credential-provider-web-identity": "^3.972.39",
"@aws-sdk/nested-clients": "^3.997.7",
"@aws-sdk/types": "^3.973.8",
"@smithy/core": "^3.24.1",
"@smithy/credential-provider-imds": "^4.3.1",
"@smithy/types": "^4.14.1",
"@aws-sdk/core": "^3.974.15",
"@aws-sdk/credential-provider-env": "^3.972.41",
"@aws-sdk/credential-provider-http": "^3.972.43",
"@aws-sdk/credential-provider-login": "^3.972.45",
"@aws-sdk/credential-provider-process": "^3.972.41",
"@aws-sdk/credential-provider-sso": "^3.972.45",
"@aws-sdk/credential-provider-web-identity": "^3.972.45",
"@aws-sdk/nested-clients": "^3.997.13",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.5",
"@smithy/credential-provider-imds": "^4.3.6",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
@@ -232,17 +293,16 @@
}
},
"node_modules/@aws-sdk/credential-provider-login": {
"version": "3.972.39",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.39.tgz",
"integrity": "sha512-1hU0NtC04QbFIuoBuF4aQ2A97GsSE5/A0ZJpDijwexsBREIQ4KPRYl3v/FfKCPBYsaTeGjkOFx5nLhWHY24LOw==",
"version": "3.972.45",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.45.tgz",
"integrity": "sha512-MZQv4SNjByk1iOKmrqmzcUF/uCB05wjvEHyXKxmGQTUANTIVayX6HPUF0bzkWLvtnkH7sAn9kUCfkXbSpj9sDA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@aws-sdk/core": "^3.974.9",
"@aws-sdk/nested-clients": "^3.997.7",
"@aws-sdk/types": "^3.973.8",
"@smithy/core": "^3.24.1",
"@smithy/types": "^4.14.1",
"@aws-sdk/core": "^3.974.15",
"@aws-sdk/nested-clients": "^3.997.13",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.5",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
@@ -250,22 +310,21 @@
}
},
"node_modules/@aws-sdk/credential-provider-node": {
"version": "3.972.40",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.40.tgz",
"integrity": "sha512-ZgrQaGkpyTlVSCCsffzijVg+KgftTAWYvI5Otc36J/4jNiHb+7MmBiJIR0a5AHLvifC92PiYHt5pijP0dswd1w==",
"version": "3.972.47",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.47.tgz",
"integrity": "sha512-HrId+C0DWA5qDIyLG64/kjUB2RNtPypxmABnIctK+TA1P1kHlOYoE/Wf5T5tKOMKgb08P7k/zNyhvfJ3lh5Oag==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@aws-sdk/credential-provider-env": "^3.972.35",
"@aws-sdk/credential-provider-http": "^3.972.37",
"@aws-sdk/credential-provider-ini": "^3.972.39",
"@aws-sdk/credential-provider-process": "^3.972.35",
"@aws-sdk/credential-provider-sso": "^3.972.39",
"@aws-sdk/credential-provider-web-identity": "^3.972.39",
"@aws-sdk/types": "^3.973.8",
"@smithy/core": "^3.24.1",
"@smithy/credential-provider-imds": "^4.3.1",
"@smithy/types": "^4.14.1",
"@aws-sdk/credential-provider-env": "^3.972.41",
"@aws-sdk/credential-provider-http": "^3.972.43",
"@aws-sdk/credential-provider-ini": "^3.972.46",
"@aws-sdk/credential-provider-process": "^3.972.41",
"@aws-sdk/credential-provider-sso": "^3.972.45",
"@aws-sdk/credential-provider-web-identity": "^3.972.45",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.5",
"@smithy/credential-provider-imds": "^4.3.6",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
@@ -273,16 +332,15 @@
}
},
"node_modules/@aws-sdk/credential-provider-process": {
"version": "3.972.35",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.35.tgz",
"integrity": "sha512-hNj1rAwZWT1vfz54BwH8FUWxZuqStrM25Q5LEIwn2erHPMRVAjLlpZqEbCEEqS99eEEOhdeetnS0WeNa3iYeEQ==",
"version": "3.972.41",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.41.tgz",
"integrity": "sha512-7I/n1zkysouLOWvkEhjNEP4vMnD2v4kzzr3/3QBdrripEpn7ap1/I5DF3Hou1SUqkKWo1f3oPGMyFAA1FAMvsQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@aws-sdk/core": "^3.974.9",
"@aws-sdk/types": "^3.973.8",
"@smithy/core": "^3.24.1",
"@smithy/types": "^4.14.1",
"@aws-sdk/core": "^3.974.15",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.5",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
@@ -290,18 +348,34 @@
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.972.39",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.39.tgz",
"integrity": "sha512-mwIPNPldyCZkvHozb6E0X/vuQLN1UCjcA6MwUf1gaO7EwghCmuNZXatq0L3zptKFvPC4Nds7+WFUkifI1XmbSw==",
"version": "3.972.45",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.45.tgz",
"integrity": "sha512-oHgbz/eFD8IKiksqDsz9ZMU4A59BpQq4QwJedBnGD80ZqYcHPPHZBwjBnxLVkB7iRVVHWpDclR8yWdD2PkQIUA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@aws-sdk/core": "^3.974.9",
"@aws-sdk/nested-clients": "^3.997.7",
"@aws-sdk/token-providers": "3.1046.0",
"@aws-sdk/types": "^3.973.8",
"@smithy/core": "^3.24.1",
"@smithy/types": "^4.14.1",
"@aws-sdk/core": "^3.974.15",
"@aws-sdk/nested-clients": "^3.997.13",
"@aws-sdk/token-providers": "3.1056.0",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.5",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": {
"version": "3.1056.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1056.0.tgz",
"integrity": "sha512-81duvlltQlsfn5K+o8zILcystBRdbT1G2JJYVCML5NZHBz4CL/zf+sAemCtBh/uh6RQUMyInGeZLQ7/8igZhbA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.15",
"@aws-sdk/nested-clients": "^3.997.13",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.5",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
@@ -309,17 +383,16 @@
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.39",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.39.tgz",
"integrity": "sha512-b9HT8CnpyPVn1hU14Q7ihjwSPlRzToYmRYJxRd5jNHEZ43lrIhoLaTT8JmfQQt5j5M8rTX1iN1X8mvu0SM1dXA==",
"version": "3.972.45",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.45.tgz",
"integrity": "sha512-CDhzKdb2onv5bpnjn/acgdNmJOQthPDLsPizU7rZflsEcgMMp8Mlri+U5hdxf8ldvZJpvM3vLU6D56vfJm5AMQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@aws-sdk/core": "^3.974.9",
"@aws-sdk/nested-clients": "^3.997.7",
"@aws-sdk/types": "^3.973.8",
"@smithy/core": "^3.24.1",
"@smithy/types": "^4.14.1",
"@aws-sdk/core": "^3.974.15",
"@aws-sdk/nested-clients": "^3.997.13",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.5",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
@@ -342,6 +415,22 @@
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-bucket-endpoint": {
"version": "3.972.17",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.17.tgz",
"integrity": "sha512-lbDmWuHenc+kiwCNrxz4MyN6nkxCWyTXPIWuspJN0ibziu+8CXci7vI1bK9MAkwy8cwJOEXNu0gBM5S0uTGRIg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.15",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.5",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-eventstream": {
"version": "3.972.11",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.11.tgz",
@@ -358,6 +447,41 @@
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-expect-continue": {
"version": "3.972.14",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.14.tgz",
"integrity": "sha512-3TNFEVGO4sWZj9TEXOCZLzGEctXHnaO4fk2EQ8KVaboTbwHmEPEQrm17Xb9koImUIXEw0sgi2xtHjg7LuTS3rA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.5",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-flexible-checksums": {
"version": "3.974.23",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.23.tgz",
"integrity": "sha512-4nPKARo2lfKvQGUt2fPA5NlS/mEohckdxpuC9ecbjVfj7B7NFFYHeTg+Bf5BEQwdn3yRfUIzFiEkPp8Yuaw3wA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/crc32": "5.2.0",
"@aws-crypto/crc32c": "5.2.0",
"@aws-crypto/util": "5.2.0",
"@aws-sdk/core": "^3.974.15",
"@aws-sdk/crc64-nvme": "^3.972.9",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.5",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-host-header": {
"version": "3.972.11",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.11.tgz",
@@ -374,6 +498,20 @@
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-location-constraint": {
"version": "3.972.11",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.11.tgz",
"integrity": "sha512-hkfspNUP4criAH6ton6BGKgnm5dZx+7bUOy1YqlTfejDeUPAM23D81q/IX+hdlS3KUsfwGz5ADTqZWKBEUpf4A==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.9",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-logger": {
"version": "3.972.10",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.10.tgz",
@@ -406,6 +544,37 @@
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-sdk-s3": {
"version": "3.972.44",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.44.tgz",
"integrity": "sha512-8HQsRg1NpX8vR4vNl1E8pyLnqZroq9VSL2vZQVSgBqp6wv6365LzYD08/c9FFh/9FTg7YRc7aTtEmXF0ir/pqg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.15",
"@aws-sdk/signature-v4-multi-region": "^3.996.30",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.5",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-ssec": {
"version": "3.972.11",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.11.tgz",
"integrity": "sha512-7PQvGNhtveKlvVqNahqWx5yrwxP7ecwAoB1dYBf8eKwfo2tzzCbNnW+q2nO3N066ktQaB4iBQbDRWtizm+amoQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.9",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-user-agent": {
"version": "3.972.39",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.39.tgz",
@@ -444,29 +613,20 @@
}
},
"node_modules/@aws-sdk/nested-clients": {
"version": "3.997.7",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.7.tgz",
"integrity": "sha512-jT2AXOODobQfTYGC2SChMSnZ/voIcRV/LHlY1suyhY1bdgP/voKkhEg8Ci1jiGQ4lBiaso5BEAV3ZWWpPTfmYA==",
"version": "3.997.13",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.13.tgz",
"integrity": "sha512-2pA6eyb5nSo/ZD2cayhOTEMoGQYgspq0RI05GDLkzQ3ajZ6isS6waV6E92Am/hz4LIlLUTrbwPLurJ/fuiHvkg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "^3.974.9",
"@aws-sdk/middleware-host-header": "^3.972.11",
"@aws-sdk/middleware-logger": "^3.972.10",
"@aws-sdk/middleware-recursion-detection": "^3.972.12",
"@aws-sdk/middleware-user-agent": "^3.972.39",
"@aws-sdk/region-config-resolver": "^3.972.14",
"@aws-sdk/signature-v4-multi-region": "^3.996.26",
"@aws-sdk/types": "^3.973.8",
"@aws-sdk/util-endpoints": "^3.996.9",
"@aws-sdk/util-user-agent-browser": "^3.972.11",
"@aws-sdk/util-user-agent-node": "^3.973.25",
"@smithy/core": "^3.24.1",
"@smithy/fetch-http-handler": "^5.4.1",
"@smithy/node-http-handler": "^4.7.1",
"@smithy/types": "^4.14.1",
"@aws-sdk/core": "^3.974.15",
"@aws-sdk/signature-v4-multi-region": "^3.996.30",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.5",
"@smithy/fetch-http-handler": "^5.4.5",
"@smithy/node-http-handler": "^4.7.5",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
@@ -490,16 +650,14 @@
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.26",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.26.tgz",
"integrity": "sha512-2N62veqdMZBCwQUHsbhtnaovOFjOa5Dn3dAD1nRqFTUXR4QmirT3HZnfus/L1DS08Vm5CkoKmL0iMVt6YbqEag==",
"version": "3.996.30",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.30.tgz",
"integrity": "sha512-HULDLMVzkmTSEv6//7kx2kRevp/VYUpm8hJNNFbmhxDn0fUiGTxVcM9yg31TukvTq8nyOBDUN2gH0o5IRbKjdw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@aws-sdk/types": "^3.973.8",
"@smithy/core": "^3.24.1",
"@smithy/signature-v4": "^5.4.1",
"@smithy/types": "^4.14.1",
"@aws-sdk/types": "^3.973.9",
"@smithy/signature-v4": "^5.4.5",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
@@ -525,13 +683,12 @@
}
},
"node_modules/@aws-sdk/types": {
"version": "3.973.8",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz",
"integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==",
"version": "3.973.9",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.9.tgz",
"integrity": "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@smithy/types": "^4.14.1",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
@@ -559,7 +716,6 @@
"resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz",
"integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.6.2"
},
@@ -606,15 +762,13 @@
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.23",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.23.tgz",
"integrity": "sha512-A0YmgYFv+hTI9c17Ntvd2hSehm9bmJfkb+ggADBwVKA8H/3+Jx94SzR2qOB9bAA9WFeDqnfz9PKKQ+D+YAKomA==",
"version": "3.972.26",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.26.tgz",
"integrity": "sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@nodable/entities": "2.1.0",
"@smithy/types": "^4.14.1",
"fast-xml-parser": "5.7.2",
"@smithy/types": "^4.14.2",
"fast-xml-parser": "5.7.3",
"tslib": "^2.6.2"
},
"engines": {
@@ -626,7 +780,6 @@
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz",
"integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=18.0.0"
}
@@ -1458,17 +1611,16 @@
}
},
"node_modules/@nodable/entities": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
"integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.1.tgz",
"integrity": "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/nodable"
}
],
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
@@ -1941,14 +2093,13 @@
"peer": true
},
"node_modules/@smithy/core": {
"version": "3.24.2",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.2.tgz",
"integrity": "sha512-IKS7qX59fAGCYBmt5JChcDswQDupZqT2Yn2ZBA3UgTlsjRNNkQzZobbn95xoAAdtTyJmBiJB3Y02qR3rgy3Zog==",
"version": "3.24.5",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.5.tgz",
"integrity": "sha512-Kt8phUg45M15EjhYAbZ+fFikYneijLu9Liugz8ZsYz2i8j0hzGv27LWKpEHYRfvj+LyCOSijpcR/2i8RouV+cA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@aws-crypto/crc32": "5.2.0",
"@smithy/types": "^4.14.1",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
@@ -1956,14 +2107,13 @@
}
},
"node_modules/@smithy/credential-provider-imds": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.2.tgz",
"integrity": "sha512-iYr9ekBjmZ+FwkiHEopqGscBbl78X62cq3p5Dd0eC+gNd7fybNZFQQdDuOQjTVmFymleuA8YRWZnuXWZ8B3kKA==",
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.6.tgz",
"integrity": "sha512-tHhdiWZfG1ZIh2YcRfPJmY2gHcBmqbAzqm3ER4TIDFYsSEqTD5tICT7cgQ/kI8LRakxp12myOYyK68XPn7MnHw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@smithy/core": "^3.24.2",
"@smithy/types": "^4.14.1",
"@smithy/core": "^3.24.5",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
@@ -1971,14 +2121,13 @@
}
},
"node_modules/@smithy/fetch-http-handler": {
"version": "5.4.2",
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.2.tgz",
"integrity": "sha512-3wF40g8OOCA5BnwQUvwtzZqYBbWWftDjpAlWIUo6Yld3ZzJaMAKqg7MWQBPjE8oLaqvZQUE7tVGlZPsae6A4bQ==",
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.5.tgz",
"integrity": "sha512-SK3VMeH0fibgdTg2QeB+O4p7Yy/2E5HBOHJeC58FshkDdeuX8lOgO7PfjYfLyPLP1ch55j91cQqKBzDS0mRjSQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@smithy/core": "^3.24.2",
"@smithy/types": "^4.14.1",
"@smithy/core": "^3.24.5",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
@@ -1990,7 +2139,6 @@
"resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
"integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.6.2"
},
@@ -1999,14 +2147,13 @@
}
},
"node_modules/@smithy/node-http-handler": {
"version": "4.7.2",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.2.tgz",
"integrity": "sha512-EdksTZ8UXYxGUgQ4mpIKrHoaj9WVGsp66TpZuixLAz1Jex8YDLnS4RH9ktGED5aOpN0OJlEtrsC9IGt76go1eA==",
"version": "4.7.5",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.5.tgz",
"integrity": "sha512-3dA9TQ+ybRSZ/m0wnbZhiBy4Dezjgq1Ib/ZZrYTpJDBgpoLLU/SDzZc/g0x0MNAdOJe1wPcM+x2PBRmoOur+Sw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@smithy/core": "^3.24.2",
"@smithy/types": "^4.14.1",
"@smithy/core": "^3.24.5",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
@@ -2014,14 +2161,13 @@
}
},
"node_modules/@smithy/signature-v4": {
"version": "5.4.2",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.2.tgz",
"integrity": "sha512-1km1OjdLRFuITWpCPofjFqzZ+tbeWuB72ZhcYjbjkCxZ21tTPfIs4GUxRrelMyKMLxLghGD58RENnXorU/O8cw==",
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.5.tgz",
"integrity": "sha512-QBJKWGqIknH0dc9LWpfH1mkdokAx6iXYN3UcQ3eY6uIEyScuoQAhfl94ge7ozUy9WgFUdE8xsvwBjaYBbWmPNA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@smithy/core": "^3.24.2",
"@smithy/types": "^4.14.1",
"@smithy/core": "^3.24.5",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
@@ -2029,11 +2175,10 @@
}
},
"node_modules/@smithy/types": {
"version": "4.14.1",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz",
"integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==",
"version": "4.14.2",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz",
"integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.6.2"
},
@@ -2046,7 +2191,6 @@
"resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
"integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@smithy/is-array-buffer": "^2.2.0",
"tslib": "^2.6.2"
@@ -2060,7 +2204,6 @@
"resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
"integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@smithy/util-buffer-from": "^2.2.0",
"tslib": "^2.6.2"
@@ -2413,8 +2556,7 @@
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.6",
@@ -2954,16 +3096,15 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"path-expression-matcher": "^1.5.0",
"xml-naming": "^0.1.0"
}
},
"node_modules/fast-xml-parser": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz",
"integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==",
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz",
"integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==",
"funding": [
{
"type": "github",
@@ -2971,10 +3112,9 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@nodable/entities": "^2.1.0",
"fast-xml-builder": "^1.1.5",
"fast-xml-builder": "^1.1.7",
"path-expression-matcher": "^1.5.0",
"strnum": "^2.2.3"
},
@@ -3808,7 +3948,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=14.0.0"
}
@@ -4258,8 +4397,7 @@
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/strtok3": {
"version": "10.3.5",
@@ -4405,8 +4543,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"peer": true
"license": "0BSD"
},
"node_modules/turndown": {
"version": "7.2.4",
@@ -4777,7 +4914,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.0.0"
}
+1
View File
@@ -22,6 +22,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@aws-sdk/client-s3": "3.1057.0",
"@mozilla/readability": "^0.5.0",
"linkedom": "^0.18.0",
"turndown": "^7.2.0",