Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Birdclaw backup files are content-addressed by raw bytes in manifest.json.
*.jsonl text eol=lf
manifest.json text eol=lf
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4.1.9",
"esbuild": "^0.28.1",
"fflate": "0.8.2",
"jsdom": "^29.1.1",
"oxfmt": "^0.55.0",
"oxlint": "^1.70.0",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions src/lib/api-contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
liveDataSourcesResponseSchema,
networkMapResponseSchema,
profileHydrationResponseSchema,
queryEnvelopeSchema,
queryResponseSchema,
tweetMediaSchema,
tweetConversationResponseSchema,
Expand Down Expand Up @@ -92,6 +93,21 @@ describe("API contracts", () => {
});

it("validates operational status wire responses", () => {
expect(
queryEnvelopeSchema.parse({
accounts: [],
archives: [],
transport: {
installed: true,
availableTransport: "bearer",
statusText: "Bearer token configured; xurl status was not probed.",
rawStatus: "bearer-token",
},
stats: { home: 0, mentions: 0, dms: 0, needsReply: 0, inbox: 0 },
}),
).toMatchObject({
transport: { availableTransport: "bearer", rawStatus: "bearer-token" },
});
expect(
liveDataSourcesResponseSchema.parse({
generatedAt: "2026-06-18T00:00:00.000Z",
Expand Down
2 changes: 1 addition & 1 deletion src/lib/api-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ const archiveCandidateSchema: z.ZodType<ArchiveCandidate> = z.object({

const transportStatusSchema: z.ZodType<TransportStatus> = z.object({
installed: z.boolean().default(false),
availableTransport: z.enum(["xurl", "local"]).default("local"),
availableTransport: z.enum(["xurl", "bearer", "local"]).default("local"),
statusText: z.string(),
rawStatus: z.string().optional(),
});
Expand Down
9 changes: 7 additions & 2 deletions src/lib/archive-finder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,21 @@ function formatRelativeDate(date: Date): string {
return `${Math.floor(days / 365)} years ago`;
}

function normalizeArchivePath(filePath: string) {
return filePath.replaceAll(path.sep, path.posix.sep);
}

function getCandidateEffect(filePath: string) {
return Effect.gen(function* () {
const stats = yield* tryPromise(() => fs.stat(filePath));
if (!stats.isFile() || stats.size < 1024 * 1024) {
return null;
}

const normalizedPath = normalizeArchivePath(filePath);
return {
path: filePath,
name: path.basename(filePath),
path: normalizedPath,
name: path.posix.basename(normalizedPath),
size: stats.size,
sizeFormatted: formatFileSize(stats.size),
modifiedTime: stats.mtime.toISOString(),
Expand Down
44 changes: 35 additions & 9 deletions src/lib/archive-import.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
// @vitest-environment node
import { execFileSync } from "node:child_process";
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from "node:fs";
import { zipSync } from "fflate";
import path from "node:path";
import { Effect } from "effect";
import { describe, expect, it } from "vitest";
Expand All @@ -30,6 +31,31 @@ import {

const testHome = useTestHome({ prefix: "birdclaw-home-" });

function writeZipFromDirectory(
root: string,
archivePath: string,
entryRoot: string,
) {
const entries: Record<string, Uint8Array> = {};

function walk(directory: string, relativeDirectory: string) {
for (const name of readdirSync(directory)) {
const absolutePath = path.join(directory, name);
const relativePath = path.posix.join(relativeDirectory, name);
const stat = statSync(absolutePath);

if (stat.isDirectory()) {
walk(absolutePath, relativePath);
} else if (stat.isFile()) {
entries[relativePath] = new Uint8Array(readFileSync(absolutePath));
}
}
}

walk(path.join(root, entryRoot), entryRoot);
writeFileSync(archivePath, zipSync(entries, { level: 0 }));
}

function makeArchive({
following = [],
likeText = "liked archive item",
Expand Down Expand Up @@ -172,7 +198,7 @@ function makeArchive({
}

const archivePath = path.join(root, "archive.zip");
execFileSync("zip", ["-qr", archivePath, "sample"], { cwd: root });
writeZipFromDirectory(root, archivePath, "sample");
return archivePath;
}

Expand All @@ -185,7 +211,7 @@ function makeArchiveWithoutAccount() {
'window.YTD.tweets.part0 = [{ "tweet": { "id_str": "1", "created_at": "Tue Jun 03 19:32:20 +0000 2025", "full_text": "hello" } }]',
);
const archivePath = path.join(root, "archive.zip");
execFileSync("zip", ["-qr", archivePath, "sample"], { cwd: root });
writeZipFromDirectory(root, archivePath, "sample");
return archivePath;
}

Expand Down Expand Up @@ -229,7 +255,7 @@ function makeRootDataArchive() {
"root-media",
);
const archivePath = path.join(root, "archive.zip");
execFileSync("zip", ["-qr", archivePath, "data"], { cwd: root });
writeZipFromDirectory(root, archivePath, "data");
return archivePath;
}

Expand Down Expand Up @@ -320,7 +346,7 @@ function makeWeirdArchive({ followers = [] }: { followers?: string[] } = {}) {
}

const archivePath = path.join(root, "archive.zip");
execFileSync("zip", ["-qr", archivePath, "sample"], { cwd: root });
writeZipFromDirectory(root, archivePath, "sample");
return archivePath;
}

Expand Down Expand Up @@ -371,7 +397,7 @@ function makeFollowArchive({
}

const archivePath = path.join(root, "archive.zip");
execFileSync("zip", ["-qr", archivePath, "sample"], { cwd: root });
writeZipFromDirectory(root, archivePath, "sample");
return archivePath;
}

Expand Down Expand Up @@ -419,7 +445,7 @@ function makeFollowDmArchive(userId: string) {
);

const archivePath = path.join(root, "archive.zip");
execFileSync("zip", ["-qr", archivePath, "sample"], { cwd: root });
writeZipFromDirectory(root, archivePath, "sample");
return archivePath;
}

Expand Down Expand Up @@ -454,7 +480,7 @@ function makeMediaArchive() {
);

const archivePath = path.join(root, "archive.zip");
execFileSync("zip", ["-qr", archivePath, "sample"], { cwd: root });
writeZipFromDirectory(root, archivePath, "sample");
return archivePath;
}

Expand Down Expand Up @@ -538,7 +564,7 @@ function makeMediaVariantsArchive() {
);

const archivePath = path.join(root, "archive.zip");
execFileSync("zip", ["-qr", archivePath, "sample"], { cwd: root });
writeZipFromDirectory(root, archivePath, "sample");
return archivePath;
}

Expand Down
13 changes: 13 additions & 0 deletions src/lib/backup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,19 @@ describe("text backup", () => {
).toBe("1");
}, 20000);

it("fails closed instead of initializing a nested repo inside a parent worktree", async () => {
const parentPath = makeTempDir("birdclaw-parent-worktree-");
execFileSync("git", ["-C", parentPath, "init"]);
const repoPath = path.join(parentPath, "backup");
switchHome("birdclaw-nested-backup-");
seedBackupFixture();

await expect(syncBackup({ repoPath })).rejects.toThrow(
/inside an existing Git worktree/i,
);
expect(existsSync(path.join(repoPath, ".git"))).toBe(false);
}, 20000);

it("does not inherit commit signing for generated backup commits", async () => {
switchHome("birdclaw-sync-signing-src-");
seedBackupFixture();
Expand Down
61 changes: 57 additions & 4 deletions src/lib/backup.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Data, Effect } from "effect";
import type { Database } from "./sqlite";
Expand Down Expand Up @@ -30,6 +31,7 @@ const BACKUP_SCHEMA_VERSION = 2;
const MIN_SUPPORTED_BACKUP_SCHEMA_VERSION = 1;
const MANIFEST_PATH = "manifest.json";
const DATA_DIR = "data";
const GITATTRIBUTES_PATH = ".gitattributes";
const AUTO_SYNC_CACHE_KEY = "backup:auto-sync";
const DEFAULT_STALE_AFTER_SECONDS = 15 * 60;
let autoUpdateInFlight: Promise<BackupAutoUpdateResult> | null = null;
Expand Down Expand Up @@ -438,6 +440,28 @@ function readPreviousManifestEffect(
);
}

function ensureBackupGitattributesEffect(repoPath: string) {
return Effect.gen(function* () {
const attributesPath = yield* trySync(() =>
resolveBackupFilePath(repoPath, GITATTRIBUTES_PATH),
);
yield* assertNoSymlinkAncestorEffect(repoPath, attributesPath);
const content = [
"# Birdclaw backup files are content-addressed by raw bytes in manifest.json.",
"*.jsonl text eol=lf",
`${MANIFEST_PATH} text eol=lf`,
"",
].join("\n");
const current = yield* tryPromise(() =>
fs.readFile(attributesPath, "utf8"),
).pipe(Effect.option);
if (current._tag === "Some" && current.value === content) {
return;
}
yield* tryPromise(() => fs.writeFile(attributesPath, content, "utf8"));
});
}

function maybeCommitAndPushEffect({
repoPath,
message,
Expand Down Expand Up @@ -469,6 +493,7 @@ function maybeCommitAndPushEffect({
"-C",
repoPath,
"add",
GITATTRIBUTES_PATH,
"README.md",
MANIFEST_PATH,
DATA_DIR,
Expand Down Expand Up @@ -540,13 +565,37 @@ function maybeCommitAndPushEffect({
});
}

function getGitTopLevelEffect(repoPath: string) {
return gitEffect(["-C", repoPath, "rev-parse", "--show-toplevel"]).pipe(
Effect.map(({ stdout }) => path.resolve(stdout.trim())),
Effect.catchAll(() => Effect.succeed(undefined)),
);
}

function isGitRepoEffect(repoPath: string) {
return gitEffect(["-C", repoPath, "rev-parse", "--is-inside-work-tree"]).pipe(
Effect.as(true),
Effect.catchAll(() => Effect.succeed(false)),
return getGitTopLevelEffect(repoPath).pipe(
Effect.map((topLevel) => topLevel === path.resolve(repoPath)),
);
}

function failOnNestedGitWorktreeEffect(repoPath: string) {
return Effect.gen(function* () {
const topLevel = yield* getGitTopLevelEffect(repoPath);
const homeTopLevel = path.resolve(os.homedir());
if (
topLevel &&
topLevel !== path.resolve(repoPath) &&
topLevel !== homeTopLevel
) {
return yield* Effect.fail(
new Error(
`Backup repo path is inside an existing Git worktree (${topLevel}); choose the worktree root, an existing backup repo, or a path outside that worktree before Birdclaw initializes backup Git state.`,
),
);
}
});
}

function hasGitCommitsEffect(repoPath: string) {
return gitEffect(["-C", repoPath, "rev-parse", "--verify", "HEAD"]).pipe(
Effect.as(true),
Expand All @@ -564,9 +613,12 @@ function ensureBackupGitRepoEffect({
return Effect.gen(function* () {
if (!(yield* isGitRepoEffect(repoPath))) {
if (remote && !existsSync(repoPath)) {
yield* tryPromise(() => fs.mkdir(repoPath, { recursive: true }));
yield* failOnNestedGitWorktreeEffect(repoPath);
yield* gitEffect(["clone", remote, repoPath]);
} else {
yield* tryPromise(() => fs.mkdir(repoPath, { recursive: true }));
yield* failOnNestedGitWorktreeEffect(repoPath);
yield* gitEffect(["-C", repoPath, "init"]);
}
}
Expand Down Expand Up @@ -604,7 +656,7 @@ function ensureBackupGitRepoEffect({
repoPath,
"fetch",
"origin",
"main",
"main:refs/remotes/origin/main",
]).pipe(
Effect.flatMap(() =>
gitEffect(["-C", repoPath, "checkout", "-B", "main", "origin/main"]),
Expand Down Expand Up @@ -667,6 +719,7 @@ export function exportBackupEffect({
new Error("Backup repository path must be a real directory"),
);
}
yield* ensureBackupGitattributesEffect(resolvedRepoPath);
yield* ensureBackupReadmeEffect(resolvedRepoPath);

const shards = yield* trySync(() => buildShards(database));
Expand Down
6 changes: 5 additions & 1 deletion src/lib/launchd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,12 @@ function xmlEscape(value: string) {
.replaceAll(">", "&gt;");
}

function normalizeLaunchdValue(value: string) {
return value.replaceAll(path.sep, path.posix.sep);
}

function stringEntry(value: string) {
return `<string>${xmlEscape(value)}</string>`;
return `<string>${xmlEscape(normalizeLaunchdValue(value))}</string>`;
}

export function buildLaunchAgent({
Expand Down
2 changes: 1 addition & 1 deletion src/lib/profile-hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export function hydrateProfilesFromXEffect(): Effect.Effect<
> {
return Effect.gen(function* () {
const transport = yield* tryPromise(() => getTransportStatus());
if (transport.availableTransport !== "xurl") {
if (transport.availableTransport === "local") {
return {
ok: true,
hydratedProfiles: 0,
Expand Down
2 changes: 1 addition & 1 deletion src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ export interface DmQuery {

export interface TransportStatus {
installed: boolean;
availableTransport: "xurl" | "local";
availableTransport: "xurl" | "bearer" | "local";
statusText: string;
rawStatus?: string;
}
Expand Down
Loading