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 99813183..94a67bf0 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 @@ -4,8 +4,9 @@ anchor: function restore created: 2026-10-15T14:20:42Z norm: '1' sig: 05c78f711a9a9228 -body_hash: 61b995cf9ff2906b -raw_hash: a42fcbbc15619cac +body_hash: 4373dd894fb82afc +raw_hash: 718d009589045a72 +vouched: 2026-10-22T15:17:06Z 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 new file mode 100644 index 00000000..205bc2ea --- /dev/null +++ b/.lane/memory/apps/host/src/backup.ts/01M5J2ZT89CTF4ECFNG6YN408R-a-restored-store-s-metadatad.md @@ -0,0 +1,12 @@ +--- +id: 01M5J2ZT89CTF4ECFNG6YN408R +anchor: function restore +created: 2026-10-22T15:17:03Z +norm: '1' +sig: 05c78f711a9a9228 +body_hash: 4373dd894fb82afc +raw_hash: 718d009589045a72 +lines: 119-164 +--- + +a restored store's MetadataDoc wins on open, so a new name must be committed there with a rename, not only in the catalog 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 514b6005..7d935659 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: 42c616dba5adb167 -raw_hash: 1a7c366637365036 -vouched: 2026-10-22T14:45:22Z +body_hash: e48607de6d665818 +raw_hash: a6282bc5158525e4 +vouched: 2026-10-22T15:17:06Z 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 2a98b5fe..38fecff7 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: 42c616dba5adb167 -raw_hash: 1a7c366637365036 -vouched: 2026-10-22T14:45:22Z +body_hash: e48607de6d665818 +raw_hash: a6282bc5158525e4 +vouched: 2026-10-22T15:17:07Z lines: 332-614 --- diff --git a/apps/host/src/backup.ts b/apps/host/src/backup.ts index 64957238..1f58ce32 100644 --- a/apps/host/src/backup.ts +++ b/apps/host/src/backup.ts @@ -17,7 +17,7 @@ import { openNodeSqliteDatabase } from "@earendil-works/pi-durable/storage/sqlit import { importSnapshot, type Snapshot } from "@ace/channel/transfer"; import * as catalog from "./catalog"; -import { hostedAuth } from "./client"; +import { hostedAuth, request } from "./client"; import { refusal } from "./http"; import { project } from "./manage"; @@ -131,7 +131,7 @@ export async function restore(directory: string, dir?: string): Promise { } /** `base`, or the first of `base-2`, `base-3`, … not in `names`, within the 63-character limit. */ -function unused(base: string, names: Set): string { +function unused(base: string, names: Set, first = 2): string { let name = base; - for (let count = 2; names.has(name); count++) { + for (let count = first; names.has(name); count++) { name = `${base.slice(0, 62 - String(count).length).replace(/-+$/, "")}-${count}`; } return name; @@ -180,6 +180,11 @@ export function claim(id: string, name: string, exact: boolean): string { }); } +/** A restored copy's name: its original's, or Finder-style `-1`, `-2`, … when taken. */ +export function restored(name: string): string { + return unused(name, taken(), 1); +} + /** * A fork's record, not yet saved, named for its source and numbered past names in use. `branches` * holds lane prefixes left in the project by deleted channels. The name is not deliberate, so the diff --git a/apps/host/src/cli.ts b/apps/host/src/cli.ts index 7bc74e80..1043ef23 100755 --- a/apps/host/src/cli.ts +++ b/apps/host/src/cli.ts @@ -29,12 +29,13 @@ import { type HostRequest, type Listing, type Person, + type Project, type ProjectAccess, projectId, TEAM, type WindowInfo, } from "./protocol"; -import { setModel } from "./settings"; +import { checkModel, setModel } from "./settings"; import { archive, fork, isRunning, kill, models, parseModel, project, remove } from "./manage"; import { move } from "./move"; @@ -203,17 +204,29 @@ async function isHostRunning(): Promise { } /** What a teammate can do in a channel from the app; everything else acts on this host's own. */ +const SWITCH = new Set(["on", "off"]); + const participant = new Set(["info", "say", "ask", "watch", "chat", "stop"]); /** What only a channel's owner can do, phrased for refusing a teammate's channel. */ -/** A project by its folder on this host, or by GitHub owner/name. */ +/** A project by its folder on this host, by GitHub owner/name, or by the name it was opened as. */ async function projectRef(value: string): Promise { - if (!existsSync(value)) { + let path = existsSync(value) ? value : undefined; + if (!path) { const repo = githubRepo(value); - if (!repo) throw new Error(`${value} is neither a folder here nor a GitHub owner/name`); - return projectId("", "", repo); + if (repo) return projectId("", "", repo); + const named = (await local({ op: "projects" })).filter((project) => + project.name.toLowerCase() === value.toLowerCase() + ); + if (named.length > 1) { + throw new Error(`More than one project is named ${value}; use its folder`); + } + if (!named[0]) { + throw new Error(`${value} is neither a project, a folder here, nor a GitHub owner/name`); + } + path = named[0].path; } - const { root, repo } = await projects.checkout(realpathSync(resolve(value))); + const { root, repo } = await projects.checkout(realpathSync(resolve(path))); const { host } = await local({ op: "hello" }); return projectId(host, root, repo); } @@ -365,7 +378,12 @@ async function main() { } if (flags.trace) filter.trace = flags.trace; if (flags.level) filter.level = flags.level; - if (flags.since) filter.since = since(flags.since); + if (flags.since) { + filter.since = since(flags.since); + if (Number.isNaN(filter.since)) { + throw new Error("--since takes a duration such as 10m, 2h or 1d, or a date"); + } + } if (flags.grep) filter.grep = flags.grep; if (flags.follow) return follow(filter, !!flags.json); print(filter, Number(flags.lines || 200), !!flags.json); @@ -375,6 +393,7 @@ async function main() { case "new": { const dir = await project(resolve(flags.project || ".")); const model = flags.model ? parseModel(flags.model) : undefined; + if (model) await checkModel(model); const record = catalog.create(dir, model, flags.name); return console.log(`${record.name}\t${record.id}`); } @@ -437,7 +456,11 @@ async function main() { const person = who === "team" ? TEAM : findPerson(access.people, who).person; const shared = ref === "share"; await local({ op: "project-share", project, person, shared }); - if (!shared) return; + if (!shared) { + return console.log( + `${person === TEAM ? "The team" : who} no longer sees it through a share.`, + ); + } const channels = await local({ op: "channels" }); const others = new Set( channels.filter((value) => channelProject(value) === project).map((value) => value.owner), @@ -509,6 +532,9 @@ async function main() { } } + if (!participant.has(command) && !owned[command]) { + throw new Error(`Unknown command ${command}\n\n${HELP}`); + } if (!ref) throw new Error(`ace ${command} needs a channel`); const text = rest.join(" "); if (participant.has(command)) { @@ -561,9 +587,7 @@ async function main() { session.close(); } } - const action = owned[command]; - if (!action) throw new Error(`Unknown command ${command}\n\n${HELP}`); - const record = await own(ref, action); + const record = await own(ref, owned[command]!); switch (command) { case "backup": if (rest.length !== 1) throw new Error("ace backup "); @@ -593,6 +617,9 @@ async function main() { ); } case "agents": { + if (rest.length !== 1 || !SWITCH.has(rest[0]!)) { + throw new Error("ace agents on|off"); + } const shared = rest[0] === "on"; await request(record.id, { op: "share", author: catalog.user, shared }); if (!shared) return; @@ -601,6 +628,9 @@ async function main() { ); } case "desktop": { + if (rest.length !== 1 || !SWITCH.has(rest[0]!)) { + throw new Error("ace desktop on|off"); + } const enabled = rest[0] === "on"; await request(record.id, { op: "desktop", author: catalog.user, enabled }); if (!enabled) return; diff --git a/apps/host/src/settings.ts b/apps/host/src/settings.ts index 88f6d8bf..9906f8f1 100644 --- a/apps/host/src/settings.ts +++ b/apps/host/src/settings.ts @@ -114,16 +114,17 @@ export async function removeKey(id: ProviderId): Promise { } } -export async function setModel(model: ModelRef | null): Promise { - if (model !== null) { - if (!isModel(model)) throw new Error("Choose a provider and model"); - const found = await models(); - if ( - !found.some((item) => item.provider === model.provider && item.modelId === model.modelId) - ) { - throw new Error("This model is unavailable. Add its provider key first."); - } +/** A model this host has credentials for, as the default and `ace new --model` both need. */ +export async function checkModel(model: ModelRef): Promise { + if (!isModel(model)) throw new Error("Choose a provider and model"); + const found = await models(); + if (!found.some((item) => item.provider === model.provider && item.modelId === model.modelId)) { + throw new Error("This model is unavailable. Add its provider key first."); } +} + +export async function setModel(model: ModelRef | null): Promise { + if (model !== null) await checkModel(model); savePreference(model); }