diff --git a/.lane/memory/apps/host/src/backup.ts/01M4ZYZJRBTSD6WJVT8HEH8XQ0-a-restored-copy-always-gets.md b/.lane/memory/apps/host/src/backup.ts/01M4ZYZJRBTSD6WJVT8HEH8XQ0-a-restored-copy-always-gets.md index 91ff4c7a..cdff7267 100644 --- a/.lane/memory/apps/host/src/backup.ts/01M4ZYZJRBTSD6WJVT8HEH8XQ0-a-restored-copy-always-gets.md +++ b/.lane/memory/apps/host/src/backup.ts/01M4ZYZJRBTSD6WJVT8HEH8XQ0-a-restored-copy-always-gets.md @@ -3,10 +3,10 @@ id: 01M4ZYZJRBTSD6WJVT8HEH8XQ0 anchor: function restore created: 2026-10-15T14:20:42Z norm: '1' -sig: 05c78f711a9a9228 -body_hash: dac1c9f5bf404d33 -raw_hash: 3f94de7e3f41e61b -vouched: 2026-10-22T21:13:56Z +sig: 34ffa326bc970eeb +body_hash: b7adc8d1cb030bd6 +raw_hash: 9cd4b31a0f2d0251 +vouched: 2026-10-22T22:15:45Z lines: 119-158 --- diff --git a/.lane/memory/apps/host/src/backup.ts/01M5J2ZT89CTF4ECFNG6YN408R-a-restored-store-s-metadatad.md b/.lane/memory/apps/host/src/backup.ts/01M5J2ZT89CTF4ECFNG6YN408R-a-restored-store-s-metadatad.md index b43ff7c0..08107673 100644 --- a/.lane/memory/apps/host/src/backup.ts/01M5J2ZT89CTF4ECFNG6YN408R-a-restored-store-s-metadatad.md +++ b/.lane/memory/apps/host/src/backup.ts/01M5J2ZT89CTF4ECFNG6YN408R-a-restored-store-s-metadatad.md @@ -3,10 +3,10 @@ id: 01M5J2ZT89CTF4ECFNG6YN408R anchor: function restore created: 2026-10-22T15:17:03Z norm: '1' -sig: 05c78f711a9a9228 -body_hash: dac1c9f5bf404d33 -raw_hash: 3f94de7e3f41e61b -vouched: 2026-10-22T21:13:56Z +sig: 34ffa326bc970eeb +body_hash: b7adc8d1cb030bd6 +raw_hash: 9cd4b31a0f2d0251 +vouched: 2026-10-22T22:15:45Z lines: 119-164 --- diff --git a/.lane/memory/apps/host/src/backup.ts/01M5JQD5HXE7X0AR39PYQVNYXR-a-copy-restored-into-another.md b/.lane/memory/apps/host/src/backup.ts/01M5JQD5HXE7X0AR39PYQVNYXR-a-copy-restored-into-another.md index 350fa21a..e39542e9 100644 --- a/.lane/memory/apps/host/src/backup.ts/01M5JQD5HXE7X0AR39PYQVNYXR-a-copy-restored-into-another.md +++ b/.lane/memory/apps/host/src/backup.ts/01M5JQD5HXE7X0AR39PYQVNYXR-a-copy-restored-into-another.md @@ -3,9 +3,10 @@ id: 01M5JQD5HXE7X0AR39PYQVNYXR anchor: function restore created: 2026-10-22T21:13:52Z norm: '1' -sig: 05c78f711a9a9228 -body_hash: dac1c9f5bf404d33 -raw_hash: 3f94de7e3f41e61b +sig: 34ffa326bc970eeb +body_hash: b7adc8d1cb030bd6 +raw_hash: 9cd4b31a0f2d0251 +vouched: 2026-10-22T22:15:45Z lines: 120-166 --- diff --git a/.lane/memory/apps/host/src/cli.ts/01M4YY5SXJFQK8VXT78X1E0KAE-every-command-but-serve-must.md b/.lane/memory/apps/host/src/cli.ts/01M4YY5SXJFQK8VXT78X1E0KAE-every-command-but-serve-must.md index 231e21f6..050d30e2 100644 --- a/.lane/memory/apps/host/src/cli.ts/01M4YY5SXJFQK8VXT78X1E0KAE-every-command-but-serve-must.md +++ b/.lane/memory/apps/host/src/cli.ts/01M4YY5SXJFQK8VXT78X1E0KAE-every-command-but-serve-must.md @@ -4,9 +4,9 @@ anchor: function main created: 2026-10-15T04:47:23Z norm: '1' sig: 7d5d3f63a3bddde4 -body_hash: 400f5a75900a8a04 -raw_hash: 5b856ae56acd38c6 -vouched: 2026-10-22T21:30:38Z +body_hash: f49f2a7319f14703 +raw_hash: 8fcaafdae8a2bc74 +vouched: 2026-10-22T22:15:45Z lines: 253-482 --- diff --git a/.lane/memory/apps/host/src/cli.ts/01M5J0BZWP8SCBK522YT2HMZDZ-ace-info-prints-agent-access.md b/.lane/memory/apps/host/src/cli.ts/01M5J0BZWP8SCBK522YT2HMZDZ-ace-info-prints-agent-access.md index 28d44ebd..f79929eb 100644 --- a/.lane/memory/apps/host/src/cli.ts/01M5J0BZWP8SCBK522YT2HMZDZ-ace-info-prints-agent-access.md +++ b/.lane/memory/apps/host/src/cli.ts/01M5J0BZWP8SCBK522YT2HMZDZ-ace-info-prints-agent-access.md @@ -4,9 +4,9 @@ anchor: function main created: 2026-10-22T14:31:16Z norm: '1' sig: 7d5d3f63a3bddde4 -body_hash: 400f5a75900a8a04 -raw_hash: 5b856ae56acd38c6 -vouched: 2026-10-22T21:30:38Z +body_hash: f49f2a7319f14703 +raw_hash: 8fcaafdae8a2bc74 +vouched: 2026-10-22T22:15:45Z lines: 332-614 --- diff --git a/apps/host/src/backup.ts b/apps/host/src/backup.ts index e07c2707..7d3e4ac8 100644 --- a/apps/host/src/backup.ts +++ b/apps/host/src/backup.ts @@ -9,7 +9,7 @@ import { rmSync, writeFileSync, } from "node:fs"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { DatabaseSync } from "node:sqlite"; import { openNodeSqliteDatabase } from "@earendil-works/pi-durable/storage/sqlite/node"; @@ -79,7 +79,18 @@ export async function backup(record: catalog.Listing, directory: string): Promis const hosted = record.hosted ? await exported(record) : undefined; const path = resolve(directory); // An existing destination must never be overwritten or removed by failed-backup cleanup. - mkdirSync(path, { mode: 0o700 }); + try { + mkdirSync(path, { mode: 0o700 }); + } catch (error) { + const { code } = error as NodeJS.ErrnoException; + if (code === "EEXIST") { + throw new Error(`${path} already exists. Back up into a new directory.`, { cause: error }); + } + if (code === "ENOENT") { + throw new Error(`${dirname(path)} doesn't exist. Create it first.`, { cause: error }); + } + throw error; + } try { const source = catalog.paths(record.id); save(join(path, "channel.json"), record); @@ -117,23 +128,38 @@ export async function backup(record: catalog.Listing, directory: string): Promis * A backup as a new local channel. The new ID and lane prefix keep it from ever running as the * original, which may still be live here or on its hosting service. */ -export async function restore(directory: string, dir?: string): Promise { +export async function restore( + directory: string, + options: { dir?: string; name?: string } = {}, +): Promise { const path = resolve(directory); + if (!existsSync(path)) throw new Error(`${path} doesn't exist.`); + if (!existsSync(join(path, "backup.json"))) { + throw new Error(`${path} isn't an Ace backup: it has no backup.json.`); + } const manifest = JSON.parse(readFileSync(join(path, "backup.json"), "utf8")) as { format: number; files: { path: string; sha256: string }[]; }; if (manifest.format !== 1) throw new Error(`Unsupported backup format ${manifest.format}`); for (const file of manifest.files) { + if (!existsSync(join(path, file.path))) { + throw new Error(`The backup is incomplete: ${file.path} is missing from ${path}.`); + } if (await checksum(join(path, file.path)) !== file.sha256) { throw new Error(`${file.path} does not match its checksum in backup.json`); } } const original = JSON.parse(readFileSync(join(path, "channel.json"), "utf8")) as catalog.Listing; + const draft = catalog.draft( + await project(options.dir || original.project), + original.model, + options.name, + ); const record: catalog.Listing = { - ...catalog.draft(await project(dir || original.project), original.model), - name: catalog.restored(original.name), - named: original.named !== false, + ...draft, + name: options.name ? draft.name : catalog.restored(original.name), + named: !!options.name || original.named !== false, summary: original.summary, }; const { base, storage } = catalog.paths(record.id); diff --git a/apps/host/src/cli.ts b/apps/host/src/cli.ts index ea616b97..b8705a54 100755 --- a/apps/host/src/cli.ts +++ b/apps/host/src/cli.ts @@ -60,7 +60,8 @@ const USAGE = `ace — channels on this host ace tabs [--port ] [--json] list open windows and their current channel's tabs ace tab rename rename one tab; an empty name restores its default ace backup snapshot a local or hosted channel's data into a new directory - ace restore [--project ] restore a backup as a new local channel + ace restore [--project ] [--name ] + restore a backup as a new local channel ace export save a channel's chats as an editable seed file ace import [--project ] [--name ] [--now] add a seed as a new local channel; --now moves its @@ -478,9 +479,9 @@ async function main() { } case "restore": { if (positionals.length !== 2) { - throw new Error("ace restore [--project ]"); + throw new Error("ace restore [--project ] [--name ]"); } - const record = await restore(ref!, flags.project); + const record = await restore(ref!, { dir: flags.project, name: flags.name }); return console.log(`${record.name}\t${record.id}`); } case "import": { diff --git a/apps/host/src/manage.ts b/apps/host/src/manage.ts index 80d0a5b3..e1aa406c 100644 --- a/apps/host/src/manage.ts +++ b/apps/host/src/manage.ts @@ -40,6 +40,7 @@ export async function defaultModel(): Promise { /** The Git top level containing `dir`, which needs a commit for lanes to start from. */ export async function project(dir: string): Promise { dir = resolve(dir === "~" ? homedir() : dir.startsWith("~/") ? homedir() + dir.slice(1) : dir); + if (!existsSync(dir)) throw new Error(`${dir} doesn't exist.`); const result = await git(dir, ["rev-parse", "--show-toplevel"]); if (result.status === null) throw new Error("Git is unavailable. Check Settings → Ace Helper."); if (result.status !== 0) { diff --git a/docs/data.md b/docs/data.md index 8cb4d206..81709d95 100644 --- a/docs/data.md +++ b/docs/data.md @@ -76,12 +76,12 @@ keeps the original working-directory paths; it is not a way to move lanes to ano ## Restore as a new channel -`ace restore [--project ]` verifies every file against `backup.json`, then -adds the backup as a new local channel on this host, in the recorded project or `--project`. A -hosted snapshot is imported and checked against its digest; a local `channel.sqlite` is copied. The -copy has a new ID and a new lane branch prefix, so it can never run as the original channel, which -may still be live here or on its service. Its pi metadata keeps the original name, so refer to it by -ID while both exist. The backup itself is left unchanged, and a failed restore removes only the new +`ace restore [--project ] [--name ]` verifies every file against +`backup.json`, then adds the backup as a new local channel on this host, in the recorded project or +`--project`. A hosted snapshot is imported and checked against its digest; a local `channel.sqlite` +is copied. The copy has a new ID and a new lane branch prefix, so it can never run as the original +channel, which may still be live here or on its service. It takes `--name`, or the original's name +with `-1`, `-2`, … added while that name is taken. The backup itself is left unchanged, and a failed restore removes only the new channel's directory. The restored chats keep the original working-directory and lane paths. If the backup was taken