From 31bdca85645ba84c832e3d902c4f51b2a0dedfd1 Mon Sep 17 00:00:00 2001 From: ts-76 Date: Fri, 2 Oct 2026 10:57:37 +0900 Subject: [PATCH 1/9] fix: protect existing workspace and skill destinations --- README.md | 4 + docs/README.ja.md | 4 + src/workspace/safe-links.ts | 281 +++++++++++++++++++ src/workspace/sync-agent-skills.ts | 174 ++++++------ src/workspace/sync-workspace.ts | 83 +++++- test/agent-skills.test.ts | 12 +- test/command-boundaries.test.ts | 8 +- test/helpers.ts | 6 +- test/sync-protection.test.ts | 435 +++++++++++++++++++++++++++++ 9 files changed, 910 insertions(+), 97 deletions(-) create mode 100644 src/workspace/safe-links.ts create mode 100644 test/sync-protection.test.ts diff --git a/README.md b/README.md index 7ce1c9e..3d6a145 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,10 @@ Regenerate symlinks, copy configured resources, and regenerate the `.code-worksp gsec sync ``` +Sync checks the entire repository/skill link plan before changing links. It refuses destinations outside `workspaceRoot`, parent symlinks within the workspace, duplicate or overlapping link destinations, and existing real files/directories (including empty directories). Errors identify the protected path and reason. Use an actual directory for `workspaceRoot`; resolve a root symlink to its canonical directory first. + +A link already pointing to the configured source is safe to reuse, including a broken link whose source is currently missing. Other links can be replaced only when their current target matches the source recorded in `.ghq-sector/repo-links-manifest.json` or `agent-skills-manifest.json`. Unexpected links remain untouched. Stale skill links are removed only when both their recorded destination and actual target match; targets and real directories/files are never recursively deleted. Old path-only skill manifests cannot prove ownership, so their stale entries are left for manual review. Move a conflicting entry or update the config, then rerun Sync/Apply. This protection applies to managed links and their metadata; configured resources and hooks retain their explicit copy/command behavior. + ### `gsec apply` Apply the full config state: ensure missing repositories exist in `ghq`, sync the workspace, and copy the config file into the workspace root. diff --git a/docs/README.ja.md b/docs/README.ja.md index b470f8f..f6a74ef 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -156,6 +156,10 @@ gsec init --ghq-root ~/ghq --workspace-root ~/workspace/sector --yes gsec sync ``` +Sync は repository と skill のリンク計画全体を、リンク変更前に検査します。`workspaceRoot` 外の宛先、workspace 内の親 symlink、重複・包含関係にあるリンク宛先、実ファイル・実ディレクトリ(空を含む)との衝突を拒否し、保護したパスと理由を表示します。`workspaceRoot` は実ディレクトリを指定してください。root 自体が symlink の場合は、その実体のパスを指定します。 + +設定された source をすでに指すリンクは、source が存在しない壊れたリンクも含めて保持します。別のリンク先へ更新できるのは、現在のリンク先が `.ghq-sector/repo-links-manifest.json` または `agent-skills-manifest.json` に記録された source と一致する場合だけです。想定外のリンクは変更しません。不要になった skill は、記録された宛先と実際のリンク先が一致する symlink だけを削除し、リンク先の実体や実ファイル・ディレクトリを再帰削除しません。従来のパスだけの skill manifest では管理対象と確認できないため、不要なエントリーは手動確認用に保持します。衝突するエントリーの移動または設定の変更後に Sync / Apply を再実行してください。この保護の対象は管理リンクとその metadata です。設定済み resources のコピーと hooks のコマンド実行は引き続き設定どおりに行います。 + ### `gsec apply` config の完全な状態を反映します。`ghq` 内に不足している repository を揃え、workspace を sync し、config file を workspace root にコピーします。 diff --git a/src/workspace/safe-links.ts b/src/workspace/safe-links.ts new file mode 100644 index 0000000..af140fb --- /dev/null +++ b/src/workspace/safe-links.ts @@ -0,0 +1,281 @@ +import { constants } from "node:fs"; +import { + lstat, + mkdir, + open, + readFile, + readlink, + symlink, + unlink, +} from "node:fs/promises"; +import path from "node:path"; + +export interface ManagedLink { + destinationPath: string; + sourcePath: string; +} + +export class WorkspaceSyncConflictError extends Error { + readonly code = "WORKSPACE_SYNC_CONFLICT"; + constructor(readonly conflicts: { path: string; reason: string }[]) { + super( + `Workspace sync refused:\n${conflicts.map((entry) => `- ${entry.path}: ${entry.reason}`).join("\n")}`, + ); + this.name = "WorkspaceSyncConflictError"; + } +} + +function conflict(entryPath: string, reason: string): never { + throw new WorkspaceSyncConflictError([{ path: entryPath, reason }]); +} + +export async function statIfPresent(entryPath: string) { + try { + return await lstat(entryPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +// Reject all symlink parents below the chosen root, including aliases within it. +// Require an actual root directory too; callers can choose its canonical path. +export async function assertSafePath(root: string, destination: string) { + const resolvedRoot = path.resolve(root); + await assertDirectory(resolvedRoot, resolvedRoot); + const relative = path.relative(resolvedRoot, path.resolve(destination)); + if ( + !relative || + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + conflict(destination, "destination must be strictly inside workspace root"); + } + let parent = resolvedRoot; + for (const segment of relative.split(path.sep).slice(0, -1)) { + parent = path.join(parent, segment); + const stat = await statIfPresent(parent); + if (stat && !stat.isDirectory()) { + conflict( + parent, + stat.isSymbolicLink() + ? "symlink parent is not allowed" + : "parent is not a directory", + ); + } + } +} + +export async function assertDirectory(root: string, directory: string) { + if (path.resolve(directory) !== path.resolve(root)) + await assertSafePath(root, directory); + const stat = await statIfPresent(directory); + if (stat && !stat.isDirectory()) + conflict(directory, "expected an actual directory"); +} + +export async function assertMetadataFile(root: string, filename: string) { + await assertSafePath(root, filename); + const stat = await statIfPresent(filename); + if (stat && !stat.isFile()) + conflict(filename, "metadata must be an actual file"); +} + +export async function usesCaseInsensitivePaths(root: string) { + let ancestor = path.resolve(root); + while (!(await statIfPresent(ancestor))) ancestor = path.dirname(ancestor); + while (ancestor !== path.dirname(ancestor)) { + const basename = path.basename(ancestor); + const alternate = basename.replace(/[a-zA-Z]/, (letter) => + letter === letter.toUpperCase() + ? letter.toLowerCase() + : letter.toUpperCase(), + ); + if (alternate !== basename) { + const original = await statIfPresent(ancestor); + const changed = await statIfPresent( + path.join(path.dirname(ancestor), alternate), + ); + return ( + !!changed && + original?.ino === changed.ino && + original?.dev === changed.dev + ); + } + ancestor = path.dirname(ancestor); + } + return false; +} + +function destinationKey(destination: string, caseInsensitive: boolean) { + const resolved = path.resolve(destination); + return caseInsensitive ? resolved.normalize("NFD").toLowerCase() : resolved; +} + +export function assertDistinctLinks( + links: ManagedLink[], + caseInsensitive = false, +) { + const paths = links.map((link) => + destinationKey(link.destinationPath, caseInsensitive), + ); + for (let index = 0; index < paths.length; index++) { + const destination = paths[index]; + if (!destination) continue; + for (const other of paths.slice(index + 1)) { + if ( + destination === other || + destination.startsWith(`${other}${path.sep}`) || + other.startsWith(`${destination}${path.sep}`) + ) { + conflict(destination, `duplicate or overlapping destination: ${other}`); + } + } + } +} + +export function assertLinkDirectories( + links: ManagedLink[], + directories: string[], + caseInsensitive = false, +) { + for (const link of links) { + const destination = destinationKey(link.destinationPath, caseInsensitive); + for (const directory of directories.map((entry) => + destinationKey(entry, caseInsensitive), + )) { + if ( + destination === directory || + directory.startsWith(`${destination}${path.sep}`) + ) { + conflict( + destination, + `link overlaps a required directory: ${directory}`, + ); + } + } + } +} + +export async function readLinkManifest( + filename: string, +): Promise { + try { + const data = JSON.parse(await readFile(filename, "utf8")); + // Legacy arrays only recorded paths: they cannot establish link ownership. + if (data?.version !== 1 || !Array.isArray(data.links)) return []; + return data.links.filter( + (entry: ManagedLink) => + typeof entry?.destinationPath === "string" && + typeof entry?.sourcePath === "string", + ); + } catch (error) { + if ( + (error as NodeJS.ErrnoException).code === "ENOENT" || + error instanceof SyntaxError + ) + return []; + throw error; + } +} + +export async function assertLink( + root: string, + link: ManagedLink, + previous: ManagedLink[], +) { + await assertSafePath(root, link.destinationPath); + const stat = await statIfPresent(link.destinationPath); + if (!stat) return; + if (!stat.isSymbolicLink()) + conflict( + link.destinationPath, + stat.isDirectory() + ? "existing directory is preserved" + : "existing file is preserved", + ); + const target = path.resolve( + path.dirname(link.destinationPath), + await readlink(link.destinationPath), + ); + if (target === path.resolve(link.sourcePath)) return; + if ( + !previous.some( + (entry) => + path.resolve(entry.destinationPath) === + path.resolve(link.destinationPath) && + path.resolve(entry.sourcePath) === target, + ) + ) { + conflict(link.destinationPath, "unexpected symlink target is preserved"); + } +} + +export async function installLink( + root: string, + link: ManagedLink, + previous: ManagedLink[], +) { + // Recheck immediately before unlink; never use recursive removal on a link. + await assertLink(root, link, previous); + await mkdir(path.dirname(link.destinationPath), { recursive: true }); + const stat = await statIfPresent(link.destinationPath); + if (stat) { + await assertLink(root, link, previous); + const target = path.resolve( + path.dirname(link.destinationPath), + await readlink(link.destinationPath), + ); + if (target === path.resolve(link.sourcePath)) return; + await unlink(link.destinationPath); + } + await symlink(link.sourcePath, link.destinationPath, "dir"); +} + +export async function removeRecordedLink(root: string, link: ManagedLink) { + await assertSafePath(root, link.destinationPath); + const stat = await statIfPresent(link.destinationPath); + if (!stat?.isSymbolicLink()) return false; + const target = path.resolve( + path.dirname(link.destinationPath), + await readlink(link.destinationPath), + ); + if (target !== path.resolve(link.sourcePath)) return false; + await unlink(link.destinationPath); + return true; +} + +export async function writeLinkManifest( + root: string, + filename: string, + links: ManagedLink[], +) { + await writeMetadataFile( + root, + filename, + `${JSON.stringify({ version: 1, links }, null, 2)}\n`, + ); +} + +export async function writeMetadataFile( + root: string, + filename: string, + contents: string, +) { + await assertMetadataFile(root, filename); + await mkdir(path.dirname(filename), { recursive: true }); + await assertMetadataFile(root, filename); + const file = await open( + filename, + constants.O_WRONLY | + constants.O_CREAT | + constants.O_TRUNC | + constants.O_NOFOLLOW, + ); + try { + await file.writeFile(contents, "utf8"); + } finally { + await file.close(); + } +} diff --git a/src/workspace/sync-agent-skills.ts b/src/workspace/sync-agent-skills.ts index 3d3177a..d97d297 100644 --- a/src/workspace/sync-agent-skills.ts +++ b/src/workspace/sync-agent-skills.ts @@ -1,10 +1,26 @@ -import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { unlink } from "node:fs/promises"; import path from "node:path"; +import { warn } from "../shared/logger.js"; import type { AgentSkillProvider, DiscoveredAgentSkill, PlannedAgentSkills, } from "./agent-skills.js"; +import { + assertDirectory, + assertDistinctLinks, + assertLink, + assertLinkDirectories, + assertMetadataFile, + assertSafePath, + installLink, + readLinkManifest, + removeRecordedLink, + statIfPresent, + usesCaseInsensitivePaths, + writeLinkManifest, + writeMetadataFile, +} from "./safe-links.js"; export interface SyncAgentSkillsResult { linked: string[]; @@ -22,28 +38,65 @@ export interface SyncAgentSkillsResult { }; } -export async function syncAgentSkills( +export async function prepareAgentSkillsSync( workspaceRoot: string, plan: PlannedAgentSkills, -): Promise { +) { const reports = { json: path.join(workspaceRoot, ".ghq-sector", "agent-skills-report.json"), markdown: path.join(workspaceRoot, ".ghq-sector", "agent-skills-report.md"), }; + const manifestPath = path.join( + workspaceRoot, + ".ghq-sector", + "agent-skills-manifest.json", + ); + await assertDirectory(workspaceRoot, workspaceRoot); + for (const filename of [manifestPath, reports.json, reports.markdown]) { + await assertMetadataFile(workspaceRoot, filename); + } + const previous = await readLinkManifest(manifestPath); + const links = plan.enabled ? plan.selected : []; + const caseInsensitive = await usesCaseInsensitivePaths(workspaceRoot); + assertDistinctLinks(links, caseInsensitive); + assertLinkDirectories( + links, + [ + path.dirname(manifestPath), + ...links.map((link) => path.dirname(link.destinationPath)), + ], + caseInsensitive, + ); + for (const skill of links) await assertLink(workspaceRoot, skill, previous); + const desired = new Set( + links.map((link) => path.resolve(link.destinationPath)), + ); + const stale = plan.enabled + ? previous.filter( + (link) => + !desired.has(path.resolve(link.destinationPath)) && + isManagedSkillPath(workspaceRoot, link.destinationPath), + ) + : []; + // Validate stale parents before changing any current link. A manifest never + // authorizes traversal through a symlink parent, or deletion of a real entry. + for (const link of stale) + await assertSafePath(workspaceRoot, link.destinationPath); + return { reports, manifestPath, previous, stale }; +} +export async function syncAgentSkills( + workspaceRoot: string, + plan: PlannedAgentSkills, + prepared?: Awaited>, +): Promise { + const { reports, manifestPath, previous, stale } = + prepared ?? (await prepareAgentSkillsSync(workspaceRoot, plan)); if (!plan.enabled) { - await rm( - path.join(workspaceRoot, ".ghq-sector", "agent-skills-report.json"), - { - force: true, - }, - ); - await rm( - path.join(workspaceRoot, ".ghq-sector", "agent-skills-report.md"), - { - force: true, - }, - ); + for (const filename of [reports.json, reports.markdown]) { + await assertMetadataFile(workspaceRoot, filename); + if (await statIfPresent(filename)) await unlink(filename); + } return { linked: [], removed: [], @@ -53,55 +106,36 @@ export async function syncAgentSkills( removedCount: 0, duplicateCount: 0, warningCount: 0, - byProvider: { - agents: { linkedCount: 0 }, - claude: { linkedCount: 0 }, - }, + byProvider: { agents: { linkedCount: 0 }, claude: { linkedCount: 0 } }, }, }; } - - const desired = new Set(plan.selected.map((skill) => skill.destinationPath)); - const manifestPath = path.join( - workspaceRoot, - ".ghq-sector", - "agent-skills-manifest.json", - ); - const previous = await readPreviousManifest(manifestPath); const removed: string[] = []; - - for (const previousPath of previous) { - if (desired.has(previousPath)) { - continue; - } - if (!isManagedSkillPath(workspaceRoot, previousPath)) { - continue; - } - await rm(previousPath, { force: true, recursive: true }); - removed.push(previousPath); + for (const link of stale) { + if (await removeRecordedLink(workspaceRoot, link)) + removed.push(link.destinationPath); + else if (await statIfPresent(link.destinationPath)) + warn( + `preserve stale skill: ${link.destinationPath} (not a matching recorded symlink)`, + ); } - const linked: string[] = []; for (const skill of plan.selected) { - await ensureParentTree(skill.destinationPath); - await rm(skill.destinationPath, { force: true, recursive: true }); - await symlink(skill.sourcePath, skill.destinationPath, "dir"); + await installLink(workspaceRoot, skill, previous); linked.push(skill.destinationPath); } - await mkdir(path.dirname(manifestPath), { recursive: true }); - await writeManifest(manifestPath, linked); + await writeLinkManifest(workspaceRoot, manifestPath, plan.selected); - await mkdir(path.dirname(reports.json), { recursive: true }); - await writeFile( + await writeMetadataFile( + workspaceRoot, reports.json, `${JSON.stringify(toJsonReport(workspaceRoot, plan), null, 2)}\n`, - "utf8", ); - await writeFile( + await writeMetadataFile( + workspaceRoot, reports.markdown, toMarkdownReport(workspaceRoot, plan), - "utf8", ); return { @@ -129,41 +163,19 @@ export async function syncAgentSkills( }; } -async function readPreviousManifest(manifestPath: string): Promise { - try { - const raw = await readFile(manifestPath, "utf8"); - const parsed = JSON.parse(raw); - if (Array.isArray(parsed)) { - return parsed.filter((item: unknown) => typeof item === "string"); - } - return []; - } catch { - return []; - } -} - function isManagedSkillPath(workspaceRoot: string, entryPath: string): boolean { - const normalized = path.resolve(entryPath); - const root = path.resolve(workspaceRoot); - if (!normalized.startsWith(root)) { - return false; - } - const relative = normalized.slice(root.length); - return ( - relative.startsWith(`${path.sep}.agents${path.sep}skills${path.sep}`) || - relative.startsWith(`${path.sep}.claude${path.sep}skills${path.sep}`) + const relative = path.relative( + path.resolve(workspaceRoot), + path.resolve(entryPath), ); -} - -async function writeManifest( - manifestPath: string, - linked: string[], -): Promise { - await writeFile(manifestPath, `${JSON.stringify(linked, null, 2)}\n`, "utf8"); -} - -async function ensureParentTree(destinationPath: string) { - await mkdir(path.dirname(destinationPath), { recursive: true }); + return [".agents", ".claude"].some((provider) => { + const prefix = `${provider}${path.sep}skills${path.sep}`; + return ( + relative.startsWith(prefix) && + relative.slice(prefix.length).split(path.sep).length === 1 && + relative.slice(prefix.length) !== "" + ); + }); } function toJsonReport(workspaceRoot: string, plan: PlannedAgentSkills) { diff --git a/src/workspace/sync-workspace.ts b/src/workspace/sync-workspace.ts index f92f7e0..fd437b0 100644 --- a/src/workspace/sync-workspace.ts +++ b/src/workspace/sync-workspace.ts @@ -1,4 +1,4 @@ -import { access, mkdir, rm, symlink } from "node:fs/promises"; +import { access, mkdir } from "node:fs/promises"; import path from "node:path"; import type { GhqWsConfig } from "../config/schema.js"; import { runHooks } from "../hooks/run-hooks.js"; @@ -8,7 +8,21 @@ import { getRepoSourcePath, } from "../shared/repo-paths.js"; import { planAgentSkills } from "./agent-skills.js"; -import { syncAgentSkills } from "./sync-agent-skills.js"; +import { + assertDirectory, + assertDistinctLinks, + assertLink, + assertLinkDirectories, + assertMetadataFile, + installLink, + readLinkManifest, + usesCaseInsensitivePaths, + writeLinkManifest, +} from "./safe-links.js"; +import { + prepareAgentSkillsSync, + syncAgentSkills, +} from "./sync-agent-skills.js"; export interface SyncWorkspaceResult { workspaceRoot: string; @@ -38,11 +52,59 @@ export async function syncWorkspace( const linked: string[] = []; const skipped: string[] = []; + const links = config.repos.map((repo) => ({ + sourcePath: getRepoSourcePath(ghqRoot, repo), + destinationPath: getRepoDestinationPath(workspaceRoot, repo), + })); + const agentSkillPlan = await planAgentSkills(config); + const allLinks = [...links, ...agentSkillPlan.selected]; + const caseInsensitive = await usesCaseInsensitivePaths(workspaceRoot); + assertDistinctLinks( + [ + ...allLinks, + ...[ + "repo-links-manifest.json", + "agent-skills-manifest.json", + "agent-skills-report.json", + "agent-skills-report.md", + ].map((filename) => ({ + destinationPath: path.join(workspaceRoot, ".ghq-sector", filename), + sourcePath: "", + })), + ], + caseInsensitive, + ); + assertLinkDirectories( + allLinks, + [ + ...config.categories.map((category) => + path.join(workspaceRoot, category), + ), + ...allLinks.map((link) => path.dirname(link.destinationPath)), + path.join(workspaceRoot, ".ghq-sector"), + ], + caseInsensitive, + ); + await assertDirectory(workspaceRoot, workspaceRoot); + const manifestPath = path.join( + workspaceRoot, + ".ghq-sector", + "repo-links-manifest.json", + ); + await assertMetadataFile(workspaceRoot, manifestPath); + const previous = await readLinkManifest(manifestPath); + for (const category of config.categories) + await assertDirectory(workspaceRoot, path.join(workspaceRoot, category)); + for (const link of links) await assertLink(workspaceRoot, link, previous); + const preparedSkills = await prepareAgentSkillsSync( + workspaceRoot, + agentSkillPlan, + ); + assertDistinctLinks([...allLinks, ...preparedSkills.stale], caseInsensitive); + // All destinations (including missing sources and skills) are validated first. await mkdir(workspaceRoot, { recursive: true }); - - for (const category of config.categories) { + for (const category of config.categories) await mkdir(path.join(workspaceRoot, category), { recursive: true }); - } for (const repo of config.repos) { const sourcePath = getRepoSourcePath(ghqRoot, repo); @@ -56,8 +118,7 @@ export async function syncWorkspace( continue; } - await rm(destinationPath, { recursive: true, force: true }); - await symlink(sourcePath, destinationPath); + await installLink(workspaceRoot, { sourcePath, destinationPath }, previous); linked.push(destinationPath); await runHooks(config.hooks?.afterLink, { @@ -72,8 +133,12 @@ export async function syncWorkspace( }); } - const agentSkillPlan = await planAgentSkills(config); - const agentSkillResult = await syncAgentSkills(workspaceRoot, agentSkillPlan); + await writeLinkManifest(workspaceRoot, manifestPath, links); + const agentSkillResult = await syncAgentSkills( + workspaceRoot, + agentSkillPlan, + preparedSkills, + ); await runHooks(config.hooks?.afterSync, { ghqRoot, diff --git a/test/agent-skills.test.ts b/test/agent-skills.test.ts index 421cb99..657dfbb 100644 --- a/test/agent-skills.test.ts +++ b/test/agent-skills.test.ts @@ -212,12 +212,20 @@ describe("agent skills syncing", () => { await writeFile(unrelatedFile, "keep", "utf8"); await writeFile(unrelatedNestedFile, "keep", "utf8"); - // Write a previous manifest so syncAgentSkills knows the stale link is managed + // Record the expected source as well as destination to establish ownership const manifestDir = path.join(config.workspaceRoot, ".ghq-sector"); await mkdir(manifestDir, { recursive: true }); await writeFile( path.join(manifestDir, "agent-skills-manifest.json"), - JSON.stringify([staleManagedTarget]), + JSON.stringify({ + version: 1, + links: [ + { + destinationPath: staleManagedTarget, + sourcePath: selectedAgentsSource, + }, + ], + }), "utf8", ); diff --git a/test/command-boundaries.test.ts b/test/command-boundaries.test.ts index a2b50b7..4b05c33 100644 --- a/test/command-boundaries.test.ts +++ b/test/command-boundaries.test.ts @@ -1,4 +1,5 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import type { GhqWsConfig } from "../src/config/schema.js"; @@ -138,6 +139,7 @@ describe("init workflow", () => { it("uses runtime-expanded workspace roots for init filesystem operations", async () => { const root = await makeTempRoot(); + vi.spyOn(os, "homedir").mockReturnValue(root); const cwd = path.join(root, "config-home"); const copyResources = vi.fn(async () => []); const generateCodeWorkspace = vi.fn(async () => null); @@ -164,7 +166,7 @@ describe("init workflow", () => { const { runInit } = await importFresh< typeof import("../src/commands/init.js") >("../src/commands/init.js"); - const home = process.env.HOME ?? "/home/test-user"; + const home = root; await runInit({ ghqRoot: "~/ghq", @@ -671,7 +673,7 @@ describe("workspace operations", () => { await mkdir(sourcePath, { recursive: true }); await mkdir(path.dirname(stalePath), { recursive: true }); - await writeFile(stalePath, "stale file", "utf8"); + await symlink(sourcePath, stalePath); const { syncWorkspace } = await importFresh< typeof import("../src/workspace/sync-workspace.js") diff --git a/test/helpers.ts b/test/helpers.ts index ee2e57b..71d34f9 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, realpath, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, vi } from "vitest"; @@ -7,7 +7,9 @@ import type { GhqWsConfig } from "../src/config/schema.js"; const tempRoots: string[] = []; export async function makeTempRoot() { - const root = await mkdtemp(path.join(os.tmpdir(), "ghq-ws-test-")); + const root = await realpath( + await mkdtemp(path.join(os.tmpdir(), "ghq-ws-test-")), + ); tempRoots.push(root); return root; } diff --git a/test/sync-protection.test.ts b/test/sync-protection.test.ts new file mode 100644 index 0000000..ed04231 --- /dev/null +++ b/test/sync-protection.test.ts @@ -0,0 +1,435 @@ +import { spawnSync } from "node:child_process"; +import { + lstat, + mkdir, + readFile, + readlink, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { planAgentSkills } from "../src/workspace/agent-skills.js"; +import { usesCaseInsensitivePaths } from "../src/workspace/safe-links.js"; +import { syncAgentSkills } from "../src/workspace/sync-agent-skills.js"; +import { syncWorkspace } from "../src/workspace/sync-workspace.js"; +import { createConfig, makeTempRoot } from "./helpers.js"; + +async function fixture() { + const root = await makeTempRoot(); + const config = createConfig(root); + config.repos = config.repos.slice(0, 1); + const source = path.join(config.ghqRoot, "github.com", "ts-76", "life"); + const destination = path.join(config.workspaceRoot, "projects", "life"); + await mkdir(source, { recursive: true }); + await writeFile(path.join(source, "keep.txt"), "source contents"); + await mkdir(path.dirname(destination), { recursive: true }); + return { root, config, source, destination }; +} + +describe("workspace destination protection", () => { + it("rejects case aliases before linking on case-insensitive volumes", async () => { + const { config, destination } = await fixture(); + if (!(await usesCaseInsensitivePaths(config.workspaceRoot))) return; + const first = config.repos[0]; + if (!first) throw new Error("expected fixture repo"); + config.repos.push({ + ...first, + owner: "another", + name: "LIFE", + category: "PROJECTS", + }); + await expect(syncWorkspace(config)).rejects.toThrow( + "duplicate or overlapping", + ); + expect(await lstat(destination).catch(() => null)).toBeNull(); + }); + it.each([ + "file", + "empty directory", + "nonempty directory", + ])("preserves an existing %s and rejects the entire plan", async (kind) => { + const { root, config, destination } = await fixture(); + if (kind === "file") await writeFile(destination, "user contents"); + else { + await mkdir(destination); + if (kind === "nonempty directory") + await writeFile(path.join(destination, "keep.txt"), "user contents"); + } + config.repos.unshift({ + provider: "github.com", + owner: "ts-76", + name: "other", + category: "projects", + }); + await mkdir(path.join(config.ghqRoot, "github.com", "ts-76", "other")); + await expect(syncWorkspace(config)).rejects.toThrow( + `${destination}: existing ${kind === "file" ? "file" : "directory"} is preserved`, + ); + expect( + await lstat(path.join(config.workspaceRoot, "projects", "other")).catch( + () => null, + ), + ).toBeNull(); + if (kind !== "empty directory") + expect( + await readFile( + kind === "file" ? destination : path.join(destination, "keep.txt"), + "utf8", + ), + ).toBe("user contents"); + expect((await lstat(root)).isDirectory()).toBe(true); + }); + + it("keeps expected links, recreates removed links, updates recorded links, and preserves their targets", async () => { + const { config, source, destination } = await fixture(); + await symlink(source, destination); + await syncWorkspace(config); + await syncWorkspace(config); + expect(await readlink(destination)).toBe(source); + await rm(destination); + await syncWorkspace(config); + const repo = config.repos[0]; + if (!repo) throw new Error("expected fixture repo"); + repo.owner = "new-owner"; + const nextSource = path.join( + config.ghqRoot, + "github.com", + "new-owner", + "life", + ); + await mkdir(nextSource, { recursive: true }); + await syncWorkspace(config); + expect(await readlink(destination)).toBe(nextSource); + expect(await readFile(path.join(source, "keep.txt"), "utf8")).toBe( + "source contents", + ); + }); + + it("preserves a broken expected link until its source becomes available", async () => { + const { config, source, destination } = await fixture(); + await rm(source, { recursive: true }); + await symlink(source, destination); + expect((await syncWorkspace(config)).skipped).toContain(source); + expect(await readlink(destination)).toBe(source); + await mkdir(source); + await syncWorkspace(config); + expect(await readlink(destination)).toBe(source); + }); + + it.each([ + false, + true, + ])("preserves an unexpected symlink (broken=%s)", async (broken) => { + const { root, config, destination } = await fixture(); + const other = path.join(root, "other"); + if (!broken) await mkdir(other); + await symlink(other, destination); + await expect(syncWorkspace(config)).rejects.toThrow( + "unexpected symlink target is preserved", + ); + expect(await readlink(destination)).toBe(other); + }); + + it.each([ + "escape", + "duplicate", + "parent link", + "overlap", + ])("rejects %s before creating another link", async (kind) => { + const { root, config, destination } = await fixture(); + const original = config.repos[0]; + if (!original) throw new Error("expected fixture repo"); + const repo = { ...original }; + if (kind === "escape") repo.category = "../../outside"; + if (kind === "duplicate") repo.owner = "another"; + if (kind === "overlap") repo.name = "life/nested"; + if (kind === "parent link") { + repo.category = "alias"; + const outside = path.join(root, "outside"); + await mkdir(outside); + await symlink(outside, path.join(config.workspaceRoot, "alias")); + } + config.repos.push(repo); + await expect(syncWorkspace(config)).rejects.toThrow( + /inside workspace root|duplicate or overlapping|symlink parent/, + ); + expect(await lstat(destination).catch(() => null)).toBeNull(); + expect( + await lstat(path.join(root, "outside", "life")).catch(() => null), + ).toBeNull(); + }); +}); + +async function skillsFixture() { + const entry = await fixture(); + entry.config.agentSkills = { enabled: true, providers: ["agents"] }; + const sourcePath = path.join(entry.source, ".agents", "skills", "example"); + await mkdir(sourcePath, { recursive: true }); + await writeFile( + path.join(sourcePath, "SKILL.md"), + "---\nname: Example\n---\nKeep source\n", + ); + const plan = await planAgentSkills(entry.config); + const skill = plan.selected[0]; + if (!skill) throw new Error("expected fixture skill"); + const destinationPath = skill.destinationPath; + await mkdir(path.dirname(destinationPath), { recursive: true }); + return { ...entry, plan, sourcePath, destinationPath }; +} + +describe("skill destination protection", () => { + it("does not allow stale cleanup to unlink a current repo destination", async () => { + const { config, source } = await skillsFixture(); + const manifest = path.join( + config.workspaceRoot, + ".ghq-sector", + "agent-skills-manifest.json", + ); + await mkdir(path.dirname(manifest)); + const first = config.repos[0]; + if (!first) throw new Error("expected fixture repo"); + config.repos = [{ ...first, category: ".agents/skills" }]; + config.agentSkills = { enabled: true, providers: ["claude"] }; + const currentDestination = path.join( + config.workspaceRoot, + ".agents", + "skills", + "life", + ); + await symlink(source, currentDestination); + await writeFile( + manifest, + JSON.stringify({ + version: 1, + links: [{ destinationPath: currentDestination, sourcePath: source }], + }), + ); + await expect(syncWorkspace(config)).rejects.toThrow( + "duplicate or overlapping", + ); + expect(await readlink(currentDestination)).toBe(source); + }); + + it("rejects duplicate skill destinations and paths outside the root before repo linking", async () => { + const { config, plan, destination, root } = await skillsFixture(); + const skill = plan.selected[0]; + if (!skill) throw new Error("expected fixture skill"); + await expect( + syncAgentSkills(config.workspaceRoot, { + ...plan, + selected: [skill, { ...skill }], + }), + ).rejects.toThrow("duplicate or overlapping"); + await expect( + syncAgentSkills(config.workspaceRoot, { + ...plan, + selected: [{ ...skill, destinationPath: path.join(root, "escape") }], + }), + ).rejects.toThrow("inside workspace root"); + expect(await lstat(destination).catch(() => null)).toBeNull(); + }); + + it("protects metadata leaf symlinks and report directories when skills are disabled", async () => { + const { root, config } = await fixture(); + const metadata = path.join(config.workspaceRoot, ".ghq-sector"); + await mkdir(metadata); + const outside = path.join(root, "outside.txt"); + await writeFile(outside, "user contents"); + const report = path.join(metadata, "agent-skills-report.json"); + await symlink(outside, report); + await expect(syncWorkspace(config)).rejects.toThrow( + "metadata must be an actual file", + ); + expect(await readFile(outside, "utf8")).toBe("user contents"); + await rm(report); + await mkdir(report); + await expect(syncWorkspace(config)).rejects.toThrow( + "metadata must be an actual file", + ); + expect((await lstat(report)).isDirectory()).toBe(true); + }); + + it("rejects a repo link occupying generated metadata or a required category", async () => { + const { config, destination } = await fixture(); + config.repos.push({ + provider: "github.com", + owner: "missing", + category: ".ghq-sector", + name: "agent-skills-report.json", + }); + await expect(syncWorkspace(config)).rejects.toThrow( + "duplicate or overlapping", + ); + config.repos.pop(); + config.categories.push("projects/life/nested"); + await expect(syncWorkspace(config)).rejects.toThrow( + "link overlaps a required directory", + ); + expect(await lstat(destination).catch(() => null)).toBeNull(); + }); + + it.each([ + "file", + "directory", + "unexpected link", + ])("preserves a selected skill %s and prevents repo changes", async (kind) => { + const { root, config, sourcePath, destinationPath, destination } = + await skillsFixture(); + if (kind === "file") await writeFile(destinationPath, "user contents"); + if (kind === "directory") await mkdir(destinationPath); + if (kind === "unexpected link") + await symlink(path.join(root, "missing"), destinationPath); + await expect(syncWorkspace(config)).rejects.toThrow( + /existing|unexpected symlink/, + ); + expect(await lstat(destination).catch(() => null)).toBeNull(); + expect(await readFile(path.join(sourcePath, "SKILL.md"), "utf8")).toContain( + "Keep source", + ); + }); + + it("updates recorded skills and removes only stale links whose recorded targets still match", async () => { + const { config, plan, sourcePath, destinationPath, root } = + await skillsFixture(); + await syncAgentSkills(config.workspaceRoot, plan); + const nextSource = path.join(root, "next-skill"); + await mkdir(nextSource); + const skill = plan.selected[0]; + if (!skill) throw new Error("expected fixture skill"); + skill.sourcePath = nextSource; + await syncAgentSkills(config.workspaceRoot, plan); + expect(await readlink(destinationPath)).toBe(nextSource); + const stalePlan = { ...plan, selected: [] }; + expect( + (await syncAgentSkills(config.workspaceRoot, stalePlan)).removed, + ).toEqual([destinationPath]); + expect(await readFile(path.join(sourcePath, "SKILL.md"), "utf8")).toContain( + "Keep source", + ); + await syncAgentSkills(config.workspaceRoot, stalePlan); + expect((await lstat(nextSource)).isDirectory()).toBe(true); + }); + + it.each([ + "legacy", + "file", + "directory", + "different target", + "outside", + ])("does not delete stale %s entries based on a manifest alone", async (kind) => { + const { config, plan, sourcePath, destinationPath, root } = + await skillsFixture(); + const manifest = path.join( + config.workspaceRoot, + ".ghq-sector", + "agent-skills-manifest.json", + ); + await mkdir(path.dirname(manifest)); + const stalePath = + kind === "outside" ? path.join(root, "outside.txt") : destinationPath; + if (kind === "file" || kind === "outside") + await writeFile(stalePath, "user contents"); + else if (kind === "directory") await mkdir(stalePath); + else + await symlink( + kind === "different target" ? path.join(root, "missing") : sourcePath, + stalePath, + ); + await writeFile( + manifest, + JSON.stringify( + kind === "legacy" + ? [stalePath] + : { version: 1, links: [{ destinationPath: stalePath, sourcePath }] }, + ), + ); + expect( + (await syncAgentSkills(config.workspaceRoot, { ...plan, selected: [] })) + .removed, + ).toEqual([]); + expect(await lstat(stalePath)).toBeTruthy(); + }); + + it("refuses stale and report paths under a parent symlink without changing outside files", async () => { + const { config, plan, sourcePath, destinationPath, root } = + await skillsFixture(); + await syncAgentSkills(config.workspaceRoot, plan); + await rm(path.join(config.workspaceRoot, ".agents"), { recursive: true }); + const outside = path.join(root, "outside"); + await mkdir(path.join(outside, "skills"), { recursive: true }); + await symlink( + sourcePath, + path.join(outside, "skills", path.basename(destinationPath)), + ); + await symlink(outside, path.join(config.workspaceRoot, ".agents")); + await expect( + syncAgentSkills(config.workspaceRoot, { ...plan, selected: [] }), + ).rejects.toThrow("symlink parent"); + expect( + await readlink( + path.join(outside, "skills", path.basename(destinationPath)), + ), + ).toBe(sourcePath); + await rm(path.join(config.workspaceRoot, ".ghq-sector"), { + recursive: true, + }); + await symlink(outside, path.join(config.workspaceRoot, ".ghq-sector")); + await expect(syncWorkspace(config)).rejects.toThrow("symlink parent"); + expect( + await lstat(path.join(outside, "repo-links-manifest.json")).catch( + () => null, + ), + ).toBeNull(); + }); +}); + +describe("isolated CLI sync/apply", () => { + it.each([ + "sync", + "apply", + ])("%s preserves collisions and succeeds safely after the user moves them", async (command) => { + const { root, config, destination, source } = await fixture(); + await writeFile( + path.join(root, "ghq-sector.config.json"), + JSON.stringify(config), + ); + // Stub ghq root within the fixture so machine discovery cannot select user repos. + const bin = path.join(root, "bin"); + await mkdir(bin); + await writeFile( + path.join(bin, "ghq"), + `#!/bin/sh\nif [ "$1" = root ]; then printf '%s\\n' '${config.ghqRoot}'; else exit 99; fi\n`, + { mode: 0o755 }, + ); + const cliPath = path.resolve("src/cli/main.ts"); + const tsxPath = path.resolve("node_modules/tsx/dist/loader.mjs"); + const invoke = () => + spawnSync(process.execPath, ["--import", tsxPath, cliPath, command], { + cwd: root, + env: { + ...process.env, + PATH: `${bin}${path.delimiter}${process.env.PATH}`, + }, + encoding: "utf8", + }); + await writeFile(destination, "user contents"); + const failed = invoke(); + expect(failed.status).not.toBe(0); + expect(failed.stderr).toContain( + `${destination}: existing file is preserved`, + ); + expect(await readFile(destination, "utf8")).toBe("user contents"); + await rm(destination); + for (let count = 0; count < 2; count++) { + const result = invoke(); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + expect(await readlink(destination)).toBe(source); + } + expect(await readFile(path.join(source, "keep.txt"), "utf8")).toBe( + "source contents", + ); + }); +}); From b2758f93c0862eb6828f1523d5c0660a41004c95 Mon Sep 17 00:00:00 2001 From: ts-76 Date: Fri, 2 Oct 2026 11:00:51 +0900 Subject: [PATCH 2/9] fix: protect local editor request and update boundaries --- README.md | 8 +- docs/README.ja.md | 8 +- skills/ghq-sector-cli/SKILL.md | 2 +- src/cli/main.ts | 2 +- src/commands/apply-progress.ts | 28 ++ src/commands/apply.ts | 62 ++-- src/commands/edit.ts | 385 ++++++++++++++++++++--- src/commands/sync.ts | 10 + test/command-boundaries.test.ts | 12 +- test/edit-server.test.ts | 533 ++++++++++++++++++++++++++++++++ 10 files changed, 983 insertions(+), 67 deletions(-) create mode 100644 src/commands/apply-progress.ts create mode 100644 test/edit-server.test.ts diff --git a/README.md b/README.md index 3d6a145..552e6fb 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,7 @@ Start the local config editor UI. The editor is built with [visual-json](https:/ ```bash gsec edit gsec edit --config ./ghq-sector.config.json --no-open -gsec edit --host 0.0.0.0 --port 4173 +gsec edit --host localhost --port 4173 ``` Options: @@ -240,6 +240,12 @@ Options: - `--port `: port to bind the editor server to - `--no-open`: do not open a browser automatically +The editor binds only to `127.0.0.1`, `::1`, or `localhost`; other hosts (including `0.0.0.0`) are rejected. Open the exact address printed by the command. All API requests require that Host and the same Origin. A browser's same-origin fetch without Origin is accepted; explicit local clients without Origin must send `X-Ghq-Sector-Request: 1`. Cross-site requests are rejected, including reads. This is a local editor, not an authenticated shared service. + +Save, Preview, Apply, and draft repo proposals accept UTF-8 `application/json` with a 1 MiB body limit. Invalid JSON/config returns 400, oversized bodies 413, and other content types 415. Repo proposals accept `{config: currentDraft, repo: optionalRepo}` and return a draft without writing the config. During Save/Apply, competing API requests for that config return 409 busy; retry only after completion. An exclusive `.editor-lock` lease protects editor instances across processes, including different ports; external CLI tools or manual file edits do not participate. After an abnormal exit, first confirm the recorded PID is no longer running, then remove only that stale lease file before retrying. The editor never removes an existing lease automatically. + +An Apply failure returns fixed completed stage names and a failed stage, without underlying exception details. `configSaved` indicates whether Save completed; the failed stage may have made partial changes. Completed changes are not rolled back. Inspect the workspace and run Doctor before retrying hooks or Apply. + ## Config file `gsec init` creates `ghq-sector.config.json`. diff --git a/docs/README.ja.md b/docs/README.ja.md index f6a74ef..450dfe8 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -216,7 +216,7 @@ gsec doctor ```bash gsec edit gsec edit --config ./ghq-sector.config.json --no-open -gsec edit --host 0.0.0.0 --port 4173 +gsec edit --host localhost --port 4173 ``` オプション: @@ -226,6 +226,12 @@ gsec edit --host 0.0.0.0 --port 4173 - `--port `: editor server の bind port - `--no-open`: ブラウザを自動で開かない +editor の bind は `127.0.0.1` / `::1` / `localhost` のみです。`0.0.0.0` などは起動前に拒否します。表示された URL をそのまま開いてください。全 API で Host と同じ Origin を検証します。Origin がないブラウザの same-origin fetch は許可し、それ以外のローカルクライアントには `X-Ghq-Sector-Request: 1` を要求します。読み取りも cross-site request は拒否します。 + +Save / Preview / Apply / repo ドラフト提案は UTF-8 の `application/json`、最大 1 MiB です。不正 JSON・schema は 400、サイズ超過は 413、Content-Type 不一致は 415 を返します。repo 提案は `{config: currentDraft, repo: optionalRepo}` を受け、ファイル保存せずドラフトを返します。Save / Apply 中は同じ設定への競合 API に 409 busy を返します。設定に隣接する `.editor-lock` を排他的に作成し、別プロセス・別ポートの編集サーバー間でも保護します。外部 CLI や手動ファイル編集は含みません。異常終了で残った lease は、記録された PID が停止済みか確認してから、その lease ファイルだけを手動で削除してください。既存 lease を自動削除しません。 + +Apply 途中失敗は内部例外の詳細を伏せ、完了した stage と失敗 stage、`configSaved` を返します。失敗 stage は一部を変更済みの場合があり、完了した操作を巻き戻しません。workspace と Doctor の結果を確認してから hooks / Apply を再実行してください。 + ## Config file `gsec init` はデフォルトで `ghq-sector.config.json` を生成します。 diff --git a/skills/ghq-sector-cli/SKILL.md b/skills/ghq-sector-cli/SKILL.md index dd10470..c7ce888 100644 --- a/skills/ghq-sector-cli/SKILL.md +++ b/skills/ghq-sector-cli/SKILL.md @@ -94,7 +94,7 @@ Starts a local web UI built with visual-json. ```bash gsec edit gsec edit --config ./ghq-sector.config.json --no-open -gsec edit --host 0.0.0.0 --port 4173 +gsec edit --host localhost --port 4173 ``` **Options:** diff --git a/src/cli/main.ts b/src/cli/main.ts index f92fb04..51e22d0 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -66,7 +66,7 @@ cli.command("doctor", "check environment and config").action(async () => { cli .command("edit", "open config editor UI") .option("--config ", "path to config file or directory containing it") - .option("--host ", "host to bind editor server", { + .option("--host ", "loopback host: 127.0.0.1, ::1, or localhost", { default: "127.0.0.1", }) .option("--port ", "port to bind editor server", { default: "4173" }) diff --git a/src/commands/apply-progress.ts b/src/commands/apply-progress.ts new file mode 100644 index 0000000..ac8de59 --- /dev/null +++ b/src/commands/apply-progress.ts @@ -0,0 +1,28 @@ +export type ApplyStage = + | "prepare" + | "repos" + | "links" + | "resources" + | "code-workspace" + | "agents-summary" + | "config-copy"; + +export type ApplyProgressReporter = ( + stage: ApplyStage, + completed: boolean, +) => void; + +export interface ApplyProgress { + completed: ApplyStage[]; + failedStage: ApplyStage; + failedStageMayHaveChanges: boolean; +} + +export class ApplyError extends Error { + constructor( + readonly progress: ApplyProgress, + cause: unknown, + ) { + super(`Apply failed during ${progress.failedStage}`, { cause }); + } +} diff --git a/src/commands/apply.ts b/src/commands/apply.ts index 98f0041..a325542 100644 --- a/src/commands/apply.ts +++ b/src/commands/apply.ts @@ -2,8 +2,11 @@ import { copyConfigToWorkspace } from "../config/copy-config-to-workspace.js"; import { loadConfig } from "../config/load-config.js"; import { getRuntimePaths } from "../config/machine-paths.js"; import { ensureRepos } from "../ghq/ensure-repos.js"; +import { ApplyError, type ApplyStage } from "./apply-progress.js"; import { runSync } from "./sync.js"; +export { ApplyError } from "./apply-progress.js"; + export interface ApplyResult { configPath: string; workspaceRoot: string; @@ -31,24 +34,45 @@ export interface ApplyResult { } export async function runApply(cwd = process.cwd()): Promise { - const loaded = await loadConfig(cwd); - const runtimePaths = await getRuntimePaths(loaded.config); - const runtimeConfig = { - ...loaded.config, - ghqRoot: runtimePaths.resolvedGhqRoot, - workspaceRoot: runtimePaths.resolvedWorkspaceRoot, - }; - const ensuredRepos = await ensureRepos(runtimeConfig); - const syncResult = await runSync(cwd, runtimeConfig); - const copiedConfigPath = await copyConfigToWorkspace( - loaded.path, - runtimeConfig, - ); - - return { - ...syncResult, - copiedConfigPath, - fetchedRepos: ensuredRepos.fetched, - alreadyPresentRepos: ensuredRepos.alreadyPresent, + const completed: ApplyStage[] = []; + let stage: ApplyStage = "prepare"; + const report = (nextStage: ApplyStage, done: boolean) => { + stage = nextStage; + if (done) completed.push(nextStage); }; + try { + const loaded = await loadConfig(cwd); + const runtimePaths = await getRuntimePaths(loaded.config); + const runtimeConfig = { + ...loaded.config, + ghqRoot: runtimePaths.resolvedGhqRoot, + workspaceRoot: runtimePaths.resolvedWorkspaceRoot, + }; + report("prepare", true); + report("repos", false); + const ensuredRepos = await ensureRepos(runtimeConfig); + report("repos", true); + const syncResult = await runSync(cwd, runtimeConfig, report); + report("config-copy", false); + const copiedConfigPath = await copyConfigToWorkspace( + loaded.path, + runtimeConfig, + ); + report("config-copy", true); + return { + ...syncResult, + copiedConfigPath, + fetchedRepos: ensuredRepos.fetched, + alreadyPresentRepos: ensuredRepos.alreadyPresent, + }; + } catch (cause) { + throw new ApplyError( + { + completed, + failedStage: stage, + failedStageMayHaveChanges: stage !== "prepare", + }, + cause, + ); + } } diff --git a/src/commands/edit.ts b/src/commands/edit.ts index dd9d9b6..3d46972 100644 --- a/src/commands/edit.ts +++ b/src/commands/edit.ts @@ -1,5 +1,12 @@ import { spawn } from "node:child_process"; -import { access, readFile } from "node:fs/promises"; +import { + access, + lstat, + open, + readFile, + realpath, + unlink, +} from "node:fs/promises"; import { createServer, type IncomingMessage, @@ -9,6 +16,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { buildJsonSchema } from "../config/build-json-schema.js"; import { loadConfig } from "../config/load-config.js"; +import { normalizePortableConfig } from "../config/machine-paths.js"; import { saveConfig } from "../config/save-config.js"; import type { GhqWsConfig } from "../config/schema.js"; import { parseConfig } from "../config/validate-config.js"; @@ -19,7 +27,7 @@ import { } from "../shared/gh.js"; import { info, success } from "../shared/logger.js"; import { planWorkspace } from "../workspace/plan-workspace.js"; -import { runApply } from "./apply.js"; +import { ApplyError, runApply } from "./apply.js"; import { runDoctor } from "./doctor.js"; interface EditServer { @@ -61,6 +69,8 @@ export interface EditOptions { } export async function runEdit(options: EditOptions) { + const host = options.host ?? "127.0.0.1"; + assertLoopbackHost(host); const loaded = await loadConfig( options.config ? path.resolve(process.cwd(), options.config) @@ -68,7 +78,6 @@ export async function runEdit(options: EditOptions) { ); const uiDistRoot = await ensureUiBuild(); - const host = options.host ?? "127.0.0.1"; const port = options.port ?? 4173; const server = await createEditServer({ host, @@ -77,7 +86,7 @@ export async function runEdit(options: EditOptions) { configPath: loaded.path, }); - const editorUrl = `http://${host}:${server.port}`; + const editorUrl = editorOrigin(host, server.port); success(`edit server: ${editorUrl}`); info(`config path: ${loaded.path}`); info("press Ctrl+C to stop"); @@ -98,21 +107,41 @@ function createRepoTemplate(config: GhqWsConfig) { }; } -async function createEditServer(options: { +export async function createEditServer(options: { host: string; port: number; uiDistRoot: string; configPath: string; }): Promise { + assertLoopbackHost(options.host); + const configPath = await realpath(options.configPath); + let origin = ""; const httpServer = createServer(async (request, response) => { try { - await handleRequest(request, response, options); + await handleRequest(request, response, { + ...options, + configPath, + origin, + }); } catch (error) { - console.error("Unhandled request error", error); - sendJson(response, 500, { ok: false, message: "Internal server error" }); + if (error instanceof RequestError) { + sendJson(response, error.status, { + ok: false, + code: error.code, + message: error.message, + }); + } else { + sendJson(response, 500, { + ok: false, + code: "INTERNAL_ERROR", + message: "Editor operation failed", + }); + } } }); + httpServer.requestTimeout = 30_000; + httpServer.headersTimeout = 10_000; await new Promise((resolve, reject) => { httpServer.once("error", reject); httpServer.listen(options.port, options.host, () => { @@ -126,6 +155,7 @@ async function createEditServer(options: { throw new Error("failed to determine editor server port"); } + origin = editorOrigin(options.host, address.port); return { port: address.port, stop() { @@ -138,10 +168,37 @@ async function createEditServer(options: { async function handleRequest( request: IncomingMessage, response: ServerResponse, - options: { uiDistRoot: string; configPath: string }, + options: { uiDistRoot: string; configPath: string; origin: string }, ) { const method = request.method ?? "GET"; - const url = new URL(request.url ?? "/", "http://127.0.0.1"); + if (request.headers.host !== new URL(options.origin).host) { + throw new RequestError( + 403, + "INVALID_HOST", + "Use the editor's displayed local address", + ); + } + const url = new URL(request.url ?? "/", options.origin); + if (url.origin !== options.origin) { + throw new RequestError( + 403, + "INVALID_HOST", + "Use the editor's displayed local address", + ); + } + if (url.pathname.startsWith("/api/")) { + validateApiOrigin(request, options.origin); + if ( + activeUpdates.has(options.configPath) || + (await fileExists(`${options.configPath}.editor-lock`)) + ) { + throw new RequestError( + 409, + "EDITOR_BUSY", + "An update is in progress; retry after it completes", + ); + } + } const configDir = path.dirname(options.configPath); if (method === "GET" && url.pathname === "/api/config") { @@ -159,15 +216,20 @@ async function handleRequest( if (method === "PUT" && url.pathname === "/api/config") { const payload = await readJsonBody(request); - const config = parseConfig(payload); - await saveConfig(options.configPath, config); - sendJson(response, 200, { ok: true }); + const config = parseApiConfig(payload); + await withConfigUpdate(options.configPath, async () => { + await saveConfig(options.configPath, config); + }); + sendJson(response, 200, { + ok: true, + value: normalizePortableConfig(config), + }); return; } if (method === "POST" && url.pathname === "/api/preview") { const payload = await readJsonBody(request); - const config = parseConfig(payload); + const config = parseApiConfig(payload); const result = await planWorkspace(config, configDir); sendJson(response, 200, { ok: true, result }); return; @@ -175,10 +237,49 @@ async function handleRequest( if (method === "POST" && url.pathname === "/api/apply") { const payload = await readJsonBody(request); - const config = parseConfig(payload); - await saveConfig(options.configPath, config); - const result = await runApply(options.configPath); - sendJson(response, 200, { ok: true, message: "workspace applied", result }); + const config = parseApiConfig(payload); + let reply: { status: number; body: unknown } | undefined; + await withConfigUpdate(options.configPath, async () => { + let configSaved = false; + try { + await saveConfig(options.configPath, config); + configSaved = true; + const result = await runApply(options.configPath); + reply = { + status: 200, + body: { + ok: true, + message: "workspace applied", + result, + value: normalizePortableConfig(config), + }, + }; + } catch (error) { + const progress = + error instanceof ApplyError + ? error.progress + : { + completed: [], + failedStage: configSaved ? "prepare" : "config-save", + failedStageMayHaveChanges: !configSaved, + }; + const conflict = + error instanceof ApplyError && + error.cause instanceof Error && + "code" in error.cause && + error.cause.code === "WORKSPACE_SYNC_CONFLICT"; + reply = { + status: conflict ? 409 : 500, + body: { + ok: false, + code: conflict ? "WORKSPACE_SYNC_CONFLICT" : "APPLY_FAILED", + message: "Apply stopped; completed changes were not rolled back.", + progress: { configSaved, ...progress }, + }, + }; + } + }); + if (reply) sendJson(response, reply.status, reply.body); return; } @@ -239,22 +340,25 @@ async function handleRequest( } if (method === "POST" && url.pathname === "/api/repos") { - const payload = (await readJsonBody(request)) as { - repo?: Record; - }; - const current = await loadConfig(options.configPath); - const currentValue = current.config as unknown as Record; - const nextRepos = Array.isArray(currentValue.repos) - ? [...currentValue.repos] - : []; - nextRepos.push(payload.repo ?? createRepoTemplate(current.config)); - - const nextConfig = parseConfig({ - ...currentValue, - repos: nextRepos, + const payload = await readJsonBody(request); + if ( + !payload || + typeof payload !== "object" || + Array.isArray(payload) || + !("config" in payload) + ) { + throw new RequestError( + 400, + "INVALID_CONFIG", + "Provide the current draft config", + ); + } + const draft = parseApiConfig(payload.config); + const repo = "repo" in payload ? payload.repo : createRepoTemplate(draft); + const nextConfig = parseApiConfig({ + ...draft, + repos: [...draft.repos, repo], }); - - await saveConfig(current.path, nextConfig); sendJson(response, 200, { ok: true, value: nextConfig }); return; } @@ -267,17 +371,190 @@ async function handleRequest( await serveUiAsset(response, options.uiDistRoot, url.pathname); } -async function readJsonBody(request: IncomingMessage): Promise { - const chunks: Buffer[] = []; - for await (const chunk of request) { - chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); +export const MAX_EDITOR_BODY_BYTES = 1024 * 1024; +const activeUpdates = new Set(); + +class RequestError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + ) { + super(message); + } +} + +function assertLoopbackHost(host: string) { + if (!["127.0.0.1", "::1", "localhost"].includes(host)) { + throw new Error( + "The editor only supports loopback hosts: 127.0.0.1, ::1, or localhost", + ); + } +} + +function editorOrigin(host: string, port: number) { + return `http://${host === "::1" ? "[::1]" : host}:${port}`; +} + +function validateApiOrigin(request: IncomingMessage, origin: string) { + const supplied = request.headers.origin; + const site = request.headers["sec-fetch-site"]; + if ( + (supplied !== undefined && supplied !== origin) || + (site !== undefined && site !== "same-origin" && site !== "none") + ) { + throw new RequestError( + 403, + "INVALID_ORIGIN", + "Use the local editor UI for this request", + ); + } + if ( + supplied === undefined && + site !== "same-origin" && + request.headers["x-ghq-sector-request"] !== "1" + ) { + throw new RequestError( + 403, + "ORIGIN_REQUIRED", + "Local clients without Origin must send X-Ghq-Sector-Request: 1", + ); } +} - if (chunks.length === 0) { - return {}; +function parseApiConfig(payload: unknown) { + try { + return parseConfig(payload); + } catch { + throw new RequestError( + 400, + "INVALID_CONFIG", + "Config does not match the schema or category rules", + ); } +} - return JSON.parse(Buffer.concat(chunks).toString("utf8")); +async function withConfigUpdate( + configPath: string, + operation: () => Promise, +) { + const busy = () => + new RequestError( + 409, + "EDITOR_BUSY", + "An update is in progress; retry after it completes. A stopped editor may have left an editor-lock file.", + ); + if (activeUpdates.has(configPath)) throw busy(); + activeUpdates.add(configPath); + const lockPath = `${configPath}.editor-lock`; + let lease: Awaited> | undefined; + try { + try { + lease = await open(lockPath, "wx", 0o600); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "EEXIST") + throw busy(); + throw error; + } + await lease.writeFile( + JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }), + ); + await operation(); + } finally { + try { + if (lease) { + // Never remove a replacement entry; stale leases require explicit recovery. + const owned = await lease.stat(); + await lease.close(); + const current = await lstat(lockPath).catch(() => undefined); + if (current?.ino === owned.ino && current.dev === owned.dev) { + await unlink(lockPath).catch(() => + info( + "Editor lease cleanup failed; inspect the editor-lock file before retrying", + ), + ); + } + } + } finally { + activeUpdates.delete(configPath); + } + } +} + +async function readJsonBody(request: IncomingMessage): Promise { + if ( + !/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test( + request.headers["content-type"] ?? "", + ) || + (request.headers["content-encoding"] !== undefined && + request.headers["content-encoding"] !== "identity") + ) { + request.resume(); + throw new RequestError( + 415, + "JSON_REQUIRED", + "Send application/json encoded as UTF-8", + ); + } + const declaredLength = Number(request.headers["content-length"] ?? 0); + if (declaredLength > MAX_EDITOR_BODY_BYTES) { + request.resume(); + throw new RequestError( + 413, + "BODY_TOO_LARGE", + "JSON body exceeds the 1 MiB limit", + ); + } + const body = await new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + const cleanup = () => { + request.off("data", onData); + request.off("end", onEnd); + request.off("error", onError); + request.off("aborted", onAborted); + }; + const onError = () => { + cleanup(); + reject( + new RequestError(400, "INVALID_BODY", "Request body could not be read"), + ); + }; + const onAborted = onError; + const onEnd = () => { + cleanup(); + resolve(Buffer.concat(chunks)); + }; + const onData = (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_EDITOR_BODY_BYTES) { + cleanup(); + request.resume(); + reject( + new RequestError( + 413, + "BODY_TOO_LARGE", + "JSON body exceeds the 1 MiB limit", + ), + ); + } else { + chunks.push(chunk); + } + }; + request.on("data", onData); + request.on("end", onEnd); + request.on("error", onError); + request.on("aborted", onAborted); + }); + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body)); + } catch { + throw new RequestError( + 400, + "INVALID_JSON", + "Body must contain valid UTF-8 JSON", + ); + } } async function serveUiAsset( @@ -286,11 +563,35 @@ async function serveUiAsset( pathname: string, ) { const normalizedPath = pathname === "/" ? "/index.html" : pathname; - const filePath = path.join(uiDistRoot, normalizedPath.replace(/^\//, "")); + let decodedPath: string; + try { + decodedPath = decodeURIComponent(normalizedPath); + } catch { + throw new RequestError(400, "INVALID_PATH", "Invalid asset path"); + } + const filePath = path.resolve(uiDistRoot, `.${decodedPath}`); + const relativePath = path.relative(uiDistRoot, filePath); + if ( + relativePath === ".." || + relativePath.startsWith(`..${path.sep}`) || + path.isAbsolute(relativePath) + ) { + throw new RequestError(403, "INVALID_PATH", "Invalid asset path"); + } const assetPath = (await fileExists(filePath)) ? filePath : path.join(uiDistRoot, "index.html"); - const content = await readFile(assetPath); + const resolvedAsset = await realpath(assetPath); + const resolvedRoot = await realpath(uiDistRoot); + const relativeAsset = path.relative(resolvedRoot, resolvedAsset); + if ( + relativeAsset === ".." || + relativeAsset.startsWith(`..${path.sep}`) || + path.isAbsolute(relativeAsset) + ) { + throw new RequestError(403, "INVALID_PATH", "Invalid asset path"); + } + const content = await readFile(resolvedAsset); response.statusCode = 200; response.setHeader( "content-type", diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 34eb1d0..960d6f9 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -7,6 +7,7 @@ import { info, success, warn } from "../shared/logger.js"; import { generateAgentsMd } from "../workspace/generate-agents-md.js"; import { generateCodeWorkspace } from "../workspace/generate-code-workspace.js"; import { syncWorkspace } from "../workspace/sync-workspace.js"; +import type { ApplyProgressReporter } from "./apply-progress.js"; export interface RunSyncResult { configPath: string; @@ -34,19 +35,28 @@ export interface RunSyncResult { export async function runSync( cwd = process.cwd(), runtimeConfig?: GhqWsConfig, + report?: ApplyProgressReporter, ): Promise { const loaded = await loadConfig(cwd); info(`loaded config: ${loaded.path}`); const config = runtimeConfig ?? (await resolveConfigForCurrentMachine(loaded.config)); + report?.("links", false); const result = await syncWorkspace(config); + report?.("links", true); + report?.("resources", false); const copiedResources = await copyResources( config, path.dirname(loaded.path), ); + report?.("resources", true); + report?.("code-workspace", false); const codeWorkspacePath = await generateCodeWorkspace(config); + report?.("code-workspace", true); + report?.("agents-summary", false); const agentsMdPath = await generateAgentsMd(config); + report?.("agents-summary", true); success(`synced workspace: ${result.workspaceRoot}`); success(`linked repos: ${result.linked.length}`); diff --git a/test/command-boundaries.test.ts b/test/command-boundaries.test.ts index 4b05c33..4660130 100644 --- a/test/command-boundaries.test.ts +++ b/test/command-boundaries.test.ts @@ -246,7 +246,11 @@ describe("apply workflow", () => { expect(loadConfig).toHaveBeenCalledWith("/tmp/project"); expect(ensureRepos).toHaveBeenCalledWith(runtimeConfig); - expect(runSync).toHaveBeenCalledWith("/tmp/project", runtimeConfig); + expect(runSync).toHaveBeenCalledWith( + "/tmp/project", + runtimeConfig, + expect.any(Function), + ); expect(copyConfigToWorkspace).toHaveBeenCalledWith( "/tmp/project/ghq-sector.config.json", runtimeConfig, @@ -310,7 +314,11 @@ describe("apply workflow", () => { await runApply(directConfigPath); expect(loadConfig).toHaveBeenCalledWith(directConfigPath); - expect(runSync).toHaveBeenCalledWith(directConfigPath, config); + expect(runSync).toHaveBeenCalledWith( + directConfigPath, + config, + expect.any(Function), + ); }); it("ghq gets only missing repos in ensureRepos and runs clone hooks around them", async () => { diff --git a/test/edit-server.test.ts b/test/edit-server.test.ts new file mode 100644 index 0000000..3256637 --- /dev/null +++ b/test/edit-server.test.ts @@ -0,0 +1,533 @@ +import { spawn } from "node:child_process"; +import { + access, + appendFile, + mkdir, + readFile, + symlink, + writeFile, +} from "node:fs/promises"; +import { request as httpRequest } from "node:http"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createConfig, makeTempRoot } from "./helpers.js"; + +const servers: { stop(): void }[] = []; +afterEach(() => { + for (const server of servers.splice(0)) server.stop(); +}); + +async function fixture(host = "127.0.0.1") { + const root = await makeTempRoot(); + const config = { + ...createConfig(root), + repos: [], + agentSkills: { enabled: false }, + }; + const configPath = path.join(root, "ghq-sector.config.json"); + const uiDistRoot = path.join(root, "ui"); + await mkdir(uiDistRoot); + await writeFile(path.join(uiDistRoot, "index.html"), "local editor"); + await writeFile(configPath, JSON.stringify(config)); + vi.doMock("../src/shared/ghq.js", () => ({ + hasGhq: vi.fn(async () => false), + getGhqRoot: vi.fn(async () => { + throw new Error("isolated fixture"); + }), + })); + vi.doMock("../src/shared/gh.js", () => ({ + hasGh: vi.fn(async () => false), + listGhOwnerCandidates: vi.fn(async () => []), + listGhRepositories: vi.fn(async () => []), + })); + const { createEditServer, MAX_EDITOR_BODY_BYTES } = await import( + "../src/commands/edit.js" + ); + const options = { host, port: 0, configPath, uiDistRoot }; + const server = await createEditServer(options); + servers.push(server); + const origin = `http://${host === "::1" ? "[::1]" : host}:${server.port}`; + return { + root, + config, + configPath, + options, + server, + origin, + createEditServer, + MAX_EDITOR_BODY_BYTES, + }; +} + +function request( + origin: string, + route: string, + options: { + method?: string; + headers?: Record; + body?: string | Buffer; + chunks?: string[]; + } = {}, +) { + return new Promise<{ + status: number; + text: string; + body: Record; + }>((resolve, reject) => { + const req = httpRequest( + `${origin}${route}`, + { + method: options.method ?? "GET", + agent: false, + headers: { + "X-Ghq-Sector-Request": "1", + Connection: "keep-alive", + ...options.headers, + }, + }, + (response) => { + const chunks: Buffer[] = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => { + const text = Buffer.concat(chunks).toString(); + let body = {}; + try { + body = JSON.parse(text); + } catch { + /* static asset */ + } + resolve({ status: response.statusCode ?? 0, text, body }); + }); + }, + ); + req.on("error", reject); + for (const chunk of options.chunks ?? []) req.write(chunk); + req.end(options.body); + }); +} + +function json(config: unknown, method = "PUT") { + return { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(config), + }; +} + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +describe("local editor HTTP boundaries", () => { + it.each([ + "127.0.0.1", + "localhost", + "::1", + ])("supports legitimate reads, Save, Preview and Apply on %s", async (host) => { + const f = await fixture(host); + expect( + ( + await request(f.origin, "/api/config", { + headers: { Origin: f.origin }, + }) + ).status, + ).toBe(200); + const draft = { ...f.config, categories: ["draft"] }; + expect((await request(f.origin, "/api/config", json(draft))).status).toBe( + 200, + ); + expect( + (await request(f.origin, "/api/preview", json(draft, "POST"))).status, + ).toBe(200); + expect( + (await request(f.origin, "/api/apply", json(draft, "POST"))).status, + ).toBe(200); + expect(JSON.parse(await readFile(f.configPath, "utf8")).categories).toEqual( + ["draft"], + ); + expect( + await readFile(path.join(f.config.workspaceRoot, "AGENTS.md"), "utf8"), + ).toContain("ghq-sector"); + }); + + it("rejects public binds before loading config or building UI", async () => { + const { runEdit, createEditServer } = await import( + "../src/commands/edit.js" + ); + for (const host of ["0.0.0.0", "::", "example.com", "192.168.1.10"]) { + await expect( + runEdit({ host, config: "/missing", open: false }), + ).rejects.toThrow("loopback"); + await expect( + createEditServer({ + host, + port: 0, + configPath: "/missing", + uiDistRoot: "/missing", + }), + ).rejects.toThrow("loopback"); + } + }); + + it("rejects invalid Host and foreign Origin before reads or writes", async () => { + const f = await fixture(); + const original = await readFile(f.configPath, "utf8"); + for (const headers of [ + { Host: "example.com" }, + { Origin: "https://example.com" }, + { Origin: "null" }, + { Origin: f.origin, "Sec-Fetch-Site": "cross-site" }, + ]) { + for (const [route, options] of [ + ["/api/config", {}], + ["/api/doctor", {}], + ["/api/repo-template", {}], + ["/api/gh/repos", {}], + ["/api/config", json(f.config)], + ["/api/preview", json(f.config, "POST")], + ["/api/apply", json(f.config, "POST")], + ] as const) { + expect( + ( + await request(f.origin, route, { + ...options, + headers: { ...options.headers, ...headers }, + }) + ).status, + ).toBe(403); + } + } + expect(await readFile(f.configPath, "utf8")).toBe(original); + expect( + (await request(f.origin, "/", { headers: { Host: "example.com" } })) + .status, + ).toBe(403); + }); + + it("requires an explicit local header for clients without Origin unless browser same-origin", async () => { + const f = await fixture(); + expect( + ( + await request(f.origin, "/api/config", { + headers: { "X-Ghq-Sector-Request": "" }, + }) + ).status, + ).toBe(403); + expect( + ( + await request(f.origin, "/api/config", { + headers: { + "X-Ghq-Sector-Request": "", + "Sec-Fetch-Site": "same-origin", + }, + }) + ).status, + ).toBe(200); + expect( + ( + await request(f.origin, "/api/config", { + headers: { "X-Ghq-Sector-Request": "", Origin: f.origin }, + }) + ).status, + ).toBe(200); + }); + + it.each([ + ["invalid JSON", "{", "application/json", 400], + ["empty JSON", "", "application/json", 400], + ["schema mismatch", '{"ghqRoot":42}', "application/json", 400], + ["form body", "a=b", "application/x-www-form-urlencoded", 415], + ["plain body", "{}", "text/plain", 415], + ])("returns controlled errors for %s without changing config", async (_label, body, type, status) => { + const f = await fixture(); + const original = await readFile(f.configPath, "utf8"); + for (const [route, method] of [ + ["/api/config", "PUT"], + ["/api/preview", "POST"], + ["/api/apply", "POST"], + ["/api/repos", "POST"], + ]) { + const result = await request(f.origin, route, { + method, + body: String(body), + headers: { "Content-Type": String(type) }, + }); + expect(result.status).toBe(status); + expect(result.body.ok).toBe(false); + } + expect(await readFile(f.configPath, "utf8")).toBe(original); + }); + + it("rejects malformed UTF-8 and missing Content-Type", async () => { + const f = await fixture(); + expect( + ( + await request(f.origin, "/api/config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: Buffer.from([0x22, 0xff, 0x22]), + }) + ).status, + ).toBe(400); + expect( + (await request(f.origin, "/api/config", { method: "PUT", body: "{}" })) + .status, + ).toBe(415); + }); + + it("rejects oversized fixed and chunked bodies and unsupported encoding", async () => { + const f = await fixture(); + const original = await readFile(f.configPath, "utf8"); + expect( + ( + await request(f.origin, "/api/config", { + ...json(f.config), + body: " ".repeat(f.MAX_EDITOR_BODY_BYTES + 1), + headers: { + "Content-Type": "application/json", + "Content-Length": String(f.MAX_EDITOR_BODY_BYTES + 1), + }, + }) + ).status, + ).toBe(413); + expect( + ( + await request(f.origin, "/api/apply", { + method: "POST", + headers: { "Content-Type": "application/json" }, + chunks: Array.from({ length: 17 }, () => " ".repeat(65536)), + }) + ).status, + ).toBe(413); + expect( + ( + await request(f.origin, "/api/config", { + ...json(f.config), + headers: { + "Content-Type": "application/json", + "Content-Encoding": "gzip", + }, + }) + ).status, + ).toBe(415); + expect(await readFile(f.configPath, "utf8")).toBe(original); + }); + + it("returns repo proposals from the caller's draft without persisting", async () => { + const f = await fixture(); + const original = await readFile(f.configPath, "utf8"); + const draft = { + ...f.config, + categories: ["unsaved"], + defaults: { category: "unsaved", owner: "draft-owner" }, + }; + const result = await request( + f.origin, + "/api/repos", + json({ config: draft }, "POST"), + ); + expect(result.status).toBe(200); + expect(result.body.value).toMatchObject({ + categories: ["unsaved"], + repos: [{ owner: "draft-owner", category: "unsaved" }], + }); + expect( + (await request(f.origin, "/api/repos", json({ repo: {} }, "POST"))) + .status, + ).toBe(400); + expect(await readFile(f.configPath, "utf8")).toBe(original); + }); + + it("rejects concurrent Apply and Save across editor instances and releases the lease", async () => { + const entered = deferred(); + const release = deferred(); + const runHooks = vi.fn(async () => { + entered.resolve(); + await release.promise; + return []; + }); + vi.doMock("../src/hooks/run-hooks.js", () => ({ runHooks })); + const f = await fixture(); + const second = await f.createEditServer(f.options); + servers.push(second); + const secondOrigin = `http://127.0.0.1:${second.port}`; + const apply = request(f.origin, "/api/apply", json(f.config, "POST")); + try { + await entered.promise; + expect( + (await request(secondOrigin, "/api/apply", json(f.config, "POST"))) + .status, + ).toBe(409); + expect( + ( + await request( + secondOrigin, + "/api/config", + json({ ...f.config, categories: ["racing"] }), + ) + ).status, + ).toBe(409); + expect((await request(f.origin, "/api/config")).status).toBe(409); + expect( + JSON.parse(await readFile(f.configPath, "utf8")).categories, + ).toEqual(f.config.categories); + expect(runHooks).toHaveBeenCalledTimes(1); + } finally { + release.resolve(); + } + expect((await apply).status).toBe(200); + expect( + (await request(secondOrigin, "/api/config", json(f.config))).status, + ).toBe(200); + }); + + it("shares the update lease across separate editor processes", async () => { + const f = await fixture(); + const enteredPath = path.join(f.root, "hook-entered"); + const releasePath = path.join(f.root, "hook-release"); + const hookScript = path.join(f.root, "hook.mjs"); + const serverScript = path.join(f.root, "server.mjs"); + await writeFile( + hookScript, + ` + import { appendFile, access } from 'node:fs/promises'; + await appendFile(${JSON.stringify(enteredPath)}, 'once\\n'); + const deadline = Date.now() + 10000; + while (true) { + try { await access(${JSON.stringify(releasePath)}); break; } catch {} + if (Date.now() > deadline) throw new Error('fixture hook timed out'); + await new Promise(resolve => setTimeout(resolve, 10)); + } + `, + ); + await writeFile( + serverScript, + ` + import { createEditServer } from ${JSON.stringify(path.resolve("src/commands/edit.ts"))}; + const server = await createEditServer(${JSON.stringify(f.options)}); + console.log(JSON.stringify({ port: server.port })); + `, + ); + const child = spawn( + process.execPath, + [ + "--import", + path.resolve("node_modules/tsx/dist/loader.mjs"), + serverScript, + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + const stopped = new Promise((resolve) => + child.once("close", () => resolve()), + ); + let diagnostics = ""; + child.stderr.on("data", (data) => { + diagnostics += data; + }); + const childOrigin = await new Promise((resolve, reject) => { + let output = ""; + child.stdout.on("data", (data) => { + output += data; + const firstLine = output.split("\n")[0]; + try { + resolve(`http://127.0.0.1:${JSON.parse(firstLine).port}`); + } catch { + /* wait for complete line */ + } + }); + child.once("error", reject); + child.once("exit", () => + reject(new Error(`fixture editor stopped: ${diagnostics}`)), + ); + }); + const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; + const draft = { + ...f.config, + hooks: { + afterSync: [`exec ${quote(process.execPath)} ${quote(hookScript)}`], + }, + }; + const apply = request(childOrigin, "/api/apply", json(draft, "POST")); + try { + await vi + .waitFor(async () => { + await access(enteredPath); + }) + .catch(async () => { + throw new Error(JSON.stringify(await apply) + diagnostics); + }); + expect( + (await request(f.origin, "/api/apply", json(draft, "POST"))).status, + ).toBe(409); + expect( + (await request(f.origin, "/api/config", json(f.config))).status, + ).toBe(409); + expect(await readFile(enteredPath, "utf8")).toBe("once\n"); + await writeFile(releasePath, "release"); + expect((await apply).status).toBe(200); + expect( + (await request(f.origin, "/api/config", json(f.config))).status, + ).toBe(200); + } finally { + await appendFile(releasePath, "release"); + await apply; + child.kill("SIGTERM"); + await stopped; + } + }); + + it("keeps pre-existing leases untouched and reports busy", async () => { + const f = await fixture(); + const lockPath = `${f.configPath}.editor-lock`; + await writeFile(lockPath, "stale lease marker"); + expect( + (await request(f.origin, "/api/config", json(f.config))).status, + ).toBe(409); + expect(await readFile(lockPath, "utf8")).toBe("stale lease marker"); + expect(JSON.parse(await readFile(f.configPath, "utf8"))).toEqual(f.config); + await expect(access(f.config.workspaceRoot)).rejects.toThrow(); + }); + + it("reports completed phases and sanitizes an actual Apply failure", async () => { + const f = await fixture(); + const secret = "private-failure-secret"; + const draft = { + ...f.config, + resources: [{ from: path.join(f.root, secret), to: "copied" }], + }; + const result = await request(f.origin, "/api/apply", json(draft, "POST")); + expect(result.status).toBe(500); + expect(result.body.progress).toEqual({ + configSaved: true, + completed: ["prepare", "repos", "links"], + failedStage: "resources", + failedStageMayHaveChanges: true, + }); + expect(result.text).not.toContain(secret); + expect(result.text).not.toContain(f.root); + expect(result.text).not.toContain("ENOENT"); + expect(JSON.parse(await readFile(f.configPath, "utf8")).resources).toEqual( + draft.resources, + ); + expect( + (await request(f.origin, "/api/config", json(f.config))).status, + ).toBe(200); + }); + + it("never serves assets through a symlink outside the UI directory", async () => { + const f = await fixture(); + await writeFile(path.join(f.root, "secret.txt"), "asset-secret"); + await symlink( + path.join(f.root, "secret.txt"), + path.join(f.options.uiDistRoot, "leak.txt"), + ); + const result = await request(f.origin, "/leak.txt"); + expect(result.status).toBe(403); + expect(result.text).not.toContain("asset-secret"); + expect((await request(f.origin, "/..%2Fsecret.txt")).status).toBe(403); + }); +}); From f9217a5b30816ca5ad2ffe8b6708836d31bce03f Mon Sep 17 00:00:00 2001 From: ts-76 Date: Fri, 2 Oct 2026 11:03:25 +0900 Subject: [PATCH 3/9] test: cover protected workspace conflict responses --- test/edit-server.test.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/test/edit-server.test.ts b/test/edit-server.test.ts index 3256637..32549b5 100644 --- a/test/edit-server.test.ts +++ b/test/edit-server.test.ts @@ -518,6 +518,39 @@ describe("local editor HTTP boundaries", () => { ).toBe(200); }); + it("reports a workspace conflict safely and preserves protected files", async () => { + const f = await fixture(); + const repo = createConfig(f.root).repos[0]; + const draft = { ...f.config, repos: [repo] }; + const source = path.join( + f.config.ghqRoot, + repo.provider, + repo.owner, + repo.name, + ); + const destination = path.join( + f.config.workspaceRoot, + repo.category, + repo.name, + ); + await mkdir(source, { recursive: true }); + await mkdir(path.dirname(destination), { recursive: true }); + await writeFile(destination, "protected-private-contents"); + const result = await request(f.origin, "/api/apply", json(draft, "POST")); + expect(result.status).toBe(409); + expect(result.body.code).toBe("WORKSPACE_SYNC_CONFLICT"); + expect(result.body.progress).toMatchObject({ + configSaved: true, + completed: ["prepare", "repos"], + failedStage: "links", + }); + expect(result.text).not.toContain(f.root); + expect(result.text).not.toContain("protected-private-contents"); + expect(await readFile(destination, "utf8")).toBe( + "protected-private-contents", + ); + }); + it("never serves assets through a symlink outside the UI directory", async () => { const f = await fixture(); await writeFile(path.join(f.root, "secret.txt"), "asset-secret"); From 639124da4821f8f48f9542b0b7b63397b13e797a Mon Sep 17 00:00:00 2001 From: ts-76 Date: Fri, 2 Oct 2026 10:59:25 +0900 Subject: [PATCH 4/9] fix(ui): preserve JSON drafts across editor actions --- package.json | 2 +- ui/bun.lock | 140 ++++++++++++ ui/package.json | 7 +- ui/src/App.svelte | 455 ++++++++++++++++++++------------------ ui/test/App.test.ts | 452 +++++++++++++++++++++++++++++++++++++ ui/test/JsonEditor.svelte | 4 + ui/vitest.config.ts | 20 ++ 7 files changed, 862 insertions(+), 218 deletions(-) create mode 100644 ui/test/App.test.ts create mode 100644 ui/test/JsonEditor.svelte create mode 100644 ui/vitest.config.ts diff --git a/package.json b/package.json index 2ddf312..8c4eaf2 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "scripts": { "cli": "tsx src/cli/main.ts", "lint": "bunx @biomejs/biome ci .", - "test": "vitest run --maxWorkers 1", + "test": "vitest run --maxWorkers 1 --exclude \"ui/**\" && bun run --cwd ui test", "typecheck": "tsc --noEmit", "build-ui": "bun install --cwd ui && bun run --cwd ui build", "sync-ui-dist": "mkdir -p dist/ui-dist && rm -rf dist/ui-dist/assets && cp -R ui/dist/* dist/ui-dist/", diff --git a/ui/bun.lock b/ui/bun.lock index d9f64cd..9064476 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -12,19 +12,43 @@ "devDependencies": { "@socketsecurity/bun-security-scanner": "^1.1.2", "@sveltejs/vite-plugin-svelte": "^7.0.0", + "jsdom": "^27.0.0", "svelte": "^5.55.5", "typescript": "^6.0.3", "vite": "^8.0.10", + "vitest": "^4.1.5", }, }, }, "packages": { + "@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="], + + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@4.1.2", "", { "dependencies": { "@csstools/css-calc": "^3.0.0", "@csstools/css-color-parser": "^4.0.1", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.2.5" } }, "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg=="], + + "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.8.1", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.6" } }, "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ=="], + + "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], + + "@csstools/color-helpers": ["@csstools/color-helpers@6.1.2", "", {}, "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow=="], + + "@csstools/css-calc": ["@csstools/css-calc@3.4.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.1", "@csstools/css-tokenizer": "^4.0.2" } }, "sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@4.2.4", "", { "dependencies": { "@csstools/color-helpers": "^6.1.2", "@csstools/css-calc": "^3.4.1" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.1", "@csstools/css-tokenizer": "^4.0.2" } }, "sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.1", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.2" } }, "sha512-ShL8BqPfbKJrJiKFH0xBbN0i7Nrh9HXYRuF+pzyj93R/BL2YAsUxJeqANErzqM+0JGl7vjHp+3OIgd/DL69YIA=="], + + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.14", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.2", "", {}, "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw=="], + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@exodus/bytes": ["@exodus/bytes@1.16.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -75,12 +99,18 @@ "@socketsecurity/bun-security-scanner": ["@socketsecurity/bun-security-scanner@1.1.2", "", {}, "sha512-TdsAg6SMolubyZ6HfIjLWlANfHvhV6i7pdWof4OQ33zPEwXJm2ilA755levHMR618MKq22+06Ag8efiVKowxqA=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="], "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.0.0", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], @@ -91,30 +121,82 @@ "@visual-json/svelte": ["@visual-json/svelte@0.4.0", "", { "dependencies": { "@jsrob/svelte-portal": "^0.2.1", "@visual-json/core": "0.4.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-Qy3aS94H6z62wlmpUM5XjZcrGi/P5vZmzZ8a1YfzE+30ZoB8n8Jo7XwLQFqDdjAfWOnu6xvdTmefjnWLQbY8bw=="], + "@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="], + + "@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="], + + "@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="], + + "@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + "bidi-js": ["bidi-js@1.1.0", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg=="], + + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + + "cssstyle": ["cssstyle@5.3.7", "", { "dependencies": { "@asamuzakjp/css-color": "^4.1.1", "@csstools/css-syntax-patches-for-csstree": "^1.0.21", "css-tree": "^3.1.0", "lru-cache": "^11.2.4" } }, "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ=="], + + "data-urls": ["data-urls@6.0.1", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^15.1.0" } }, "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "devalue": ["devalue@5.6.4", "", {}, "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="], + "entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="], + + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], "esrap": ["esrap@2.2.4", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@typescript-eslint/types": "^8.2.0" } }, "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + "jsdom": ["jsdom@27.4.0", "", { "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", "@exodus/bytes": "^1.6.0", "cssstyle": "^5.3.4", "data-urls": "^6.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.0", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^15.1.0", "ws": "^8.18.3", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -141,28 +223,66 @@ "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + "lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="], + "lucide-svelte": ["lucide-svelte@1.0.1", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5.0.0-next.42" } }, "sha512-WvzZgk0pqzgda+AErLvgWxHkfg/+GgUwqKMRHvzt0IqyMdmyEDzDCk3Z+Wo/3y753oIgx8u9Q4eUbWkghFa8Jg=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "postcss": ["postcss@8.5.12", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "rolldown": ["rolldown@1.0.0-rc.17", "", { "dependencies": { "@oxc-project/types": "=0.127.0", "@rolldown/pluginutils": "1.0.0-rc.17" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-x64": "1.0.0-rc.17", "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA=="], + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], + "svelte": ["svelte@5.55.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw=="], + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], + + "tldts": ["tldts@7.4.16", "", { "dependencies": { "tldts-core": "^7.4.16" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg=="], + + "tldts-core": ["tldts-core@7.4.16", "", {}, "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA=="], + + "tough-cookie": ["tough-cookie@6.0.2", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA=="], + + "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], @@ -171,6 +291,26 @@ "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + "vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="], + + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + + "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "whatwg-url": ["whatwg-url@15.1.0", "", { "dependencies": { "tr46": "^6.0.0", "webidl-conversions": "^8.0.0" } }, "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="], + + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], + + "data-urls/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], } } diff --git a/ui/package.json b/ui/package.json index 8e4166c..208faae 100644 --- a/ui/package.json +++ b/ui/package.json @@ -4,14 +4,17 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build" + "build": "vite build", + "test": "vitest run" }, "devDependencies": { "@socketsecurity/bun-security-scanner": "^1.1.2", "@sveltejs/vite-plugin-svelte": "^7.0.0", + "jsdom": "^27.0.0", "svelte": "^5.55.5", "typescript": "^6.0.3", - "vite": "^8.0.10" + "vite": "^8.0.10", + "vitest": "^4.1.5" }, "dependencies": { "@visual-json/core": "^0.4.0", diff --git a/ui/src/App.svelte b/ui/src/App.svelte index e6fedc4..6d71b86 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -186,17 +186,36 @@ let saving = $state(false); let previewLoading = $state(false); let applying = $state(false); let doctorLoading = $state(false); -let addingRepo = $state(false); + let ghReposLoading = $state(false); let errorMessage = $state(""); let successMessage = $state(""); let previewResult = $state(null); let applyResult = $state(null); +let appliedSnapshot = $state(""); +let previewSnapshot = $state(""); let doctorResult = $state(null); let rawValue = $state(""); let currentTab = $state("visual"); let originalSnapshot = $state(""); +let draftError = $derived.by(() => { + if (format !== "json") + return "YAML editing is not supported. Use the CLI to edit this file."; + try { + JSON.parse(rawValue); + return ""; + } catch (error) { + return error instanceof Error ? error.message : "Invalid JSON"; + } +}); +let draftValid = $derived(format === "json" && !draftError); let unsavedChanges = $derived(serializeCurrentValue() !== originalSnapshot); +// A revision identifies an edit; the epoch identifies a loaded file/baseline. +let draftRevision = 0; +let loadEpoch = 0; +let previewRequest = 0; +let doctorRequest = 0; +let ghRequest = 0; let ghRepos = $state([]); let ghAccounts = $state([]); let ghAvailable = $state(false); @@ -222,20 +241,9 @@ function extractCategories( : []; } -function getCurrentCategories() { - if (currentTab === "raw") { - try { - const parsed = parseRawValue(); - return extractCategories(parsed as JsonValue); - } catch { - return extractCategories(value); - } - } - - return extractCategories(value); -} - -const categories = $derived(getCurrentCategories()); +const categories = $derived( + draftValid ? extractCategories(JSON.parse(rawValue)) : [], +); const filteredGhRepos = $derived.by(() => { const query = ghRepoFilter.trim().toLowerCase(); if (!query) { @@ -259,7 +267,7 @@ onMount(async () => { }); $effect(() => { - if (loading || saving || applying || addingRepo) { + if (loading || saving || applying) { return; } @@ -268,13 +276,7 @@ $effect(() => { return; } - if (currentTab === "raw") { - try { - parseRawValue(); - } catch { - return; - } - } + if (!draftValid) return; if (previewTimer) { clearTimeout(previewTimer); @@ -297,29 +299,70 @@ function serializeConfig(source: JsonValue) { } function serializeCurrentValue() { - if (currentTab === "raw") { - try { - return serializeConfig(parseRawValue() as JsonValue); - } catch { - return rawValue; - } + try { + return serializeConfig(JSON.parse(rawValue)); + } catch { + return rawValue; } - - return serializeConfig(value); } function getCurrentPayload() { - return (currentTab === "raw" ? parseRawValue() : value) as JsonValue; + if (!draftValid) throw new Error(draftError || "JSON editing is unavailable"); + return JSON.parse(rawValue) as JsonValue; } function getConfigObject() { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; + const current = getCurrentPayload(); + if (!current || typeof current !== "object" || Array.isArray(current)) { + throw new Error("Config must be a JSON object"); + } + return current as Record; +} + +function editRaw(text: string) { + rawValue = text; + draftRevision++; + previewRequest++; + previewLoading = false; + previewResult = null; + previewSnapshot = ""; + applyResult = null; + successMessage = ""; + try { + value = JSON.parse(text); + } catch { + /* Preserve invalid text without falling back. */ + } +} + +function editVisual(next: JsonValue) { + value = next; + editRaw(serializeConfig(next)); +} + +function switchTab(tab: EditorTab) { + if (tab === "visual" && !draftValid) return; + currentTab = tab; +} + +async function responsePayload(response: Response) { + const payload = await response.json(); + if (!response.ok) { + throw new Error( + typeof payload.message === "string" + ? payload.message + : `Request failed (${response.status})`, + ); + } + return payload; } function getRepoTemplate() { - const config = getConfigObject(); + const payload = getCurrentPayload(); + const config = + payload && typeof payload === "object" && !Array.isArray(payload) + ? (payload as Record) + : {}; const defaults = config.defaults && typeof config.defaults === "object" ? (config.defaults as Record) @@ -339,6 +382,7 @@ function getRepoTemplate() { } function getSelectedRepoPreview(): RepoPreview { + if (!draftValid) return null; const selected = ghRepos.find( (repo) => repo.nameWithOwner === ghSelectedRepo, ); @@ -348,148 +392,171 @@ function getSelectedRepoPreview(): RepoPreview { return { ...selected, - category: ghSelectedCategory || getRepoTemplate().category, + category: categories.includes(ghSelectedCategory) + ? ghSelectedCategory + : getRepoTemplate().category, }; } async function load() { + const epoch = ++loadEpoch; + const revision = draftRevision; + previewRequest++; loading = true; errorMessage = ""; - try { - const response = await fetch("/api/config"); - if (!response.ok) { - throw new Error(await response.text()); - } - - const payload = await response.json(); + const payload = await responsePayload(await fetch("/api/config")); + if (epoch !== loadEpoch || revision !== draftRevision) return false; schema = payload.schema ?? null; value = payload.value; configPath = payload.path; format = payload.format; rawValue = payload.raw; - originalSnapshot = serializeConfig(payload.value as JsonValue); + originalSnapshot = + format === "json" ? serializeConfig(payload.value) : payload.raw; + currentTab = format === "json" ? currentTab : "raw"; + draftRevision++; + previewResult = null; + previewSnapshot = ""; + applyResult = null; + return true; } catch (error) { - errorMessage = - error instanceof Error ? error.message : "failed to load config"; + if (epoch === loadEpoch) + errorMessage = + error instanceof Error ? error.message : "failed to load config"; + return false; } finally { - loading = false; + if (epoch === loadEpoch) loading = false; } } async function reload() { + if (unsavedChanges && !window.confirm("Discard unsaved changes and reload?")) + return; successMessage = ""; - await load(); - await loadDoctor(); - await loadGhRepos(); - await previewWorkspace(); + if (!(await load())) return; + await Promise.all([ + loadDoctor(), + loadGhRepos(), + previewWorkspace({ silent: true }), + ]); successMessage = `reloaded ${configPath}`; } async function save() { + if (!draftValid || loading || saving || applying) return; + const payload = getCurrentPayload(); + const snapshot = serializeConfig(payload); + const epoch = loadEpoch; + const revision = draftRevision; saving = true; successMessage = ""; errorMessage = ""; - try { - const payload = currentTab === "raw" ? parseRawValue() : value; - const response = await fetch("/api/config", { - method: "PUT", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - throw new Error(await response.text()); + const result = await responsePayload( + await fetch("/api/config", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }), + ); + // The server saved this snapshot, even if a newer edit is already visible. + if (epoch === loadEpoch) { + originalSnapshot = + result.value === undefined ? snapshot : serializeConfig(result.value); + if (revision === draftRevision) successMessage = `saved ${configPath}`; } - - await load(); - await loadGhRepos(); - await previewWorkspace({ silent: true }); - successMessage = `saved ${configPath}`; } catch (error) { - errorMessage = - error instanceof Error ? error.message : "failed to save config"; + if (epoch === loadEpoch && revision === draftRevision) + errorMessage = + error instanceof Error ? error.message : "failed to save config"; } finally { saving = false; } } async function previewWorkspace(options?: { silent?: boolean }) { + if (!draftValid || loading || saving || applying) return; + const payload = getCurrentPayload(); + const request = ++previewRequest; + const epoch = loadEpoch; + const revision = draftRevision; previewLoading = true; if (!options?.silent) { successMessage = ""; errorMessage = ""; } - try { - const response = await fetch("/api/preview", { - method: "POST", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify(getCurrentPayload()), - }); - - if (!response.ok) { - throw new Error(await response.text()); - } - - const payload = await response.json(); - previewResult = payload.result ?? null; - if (!options?.silent) { - successMessage = "workspace plan updated"; - } + const result = await responsePayload( + await fetch("/api/preview", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }), + ); + if ( + request !== previewRequest || + epoch !== loadEpoch || + revision !== draftRevision + ) + return; + previewResult = result.result ?? null; + previewSnapshot = serializeConfig(payload); + if (!options?.silent) successMessage = "workspace plan updated"; } catch (error) { - if (!options?.silent) { + if ( + request === previewRequest && + epoch === loadEpoch && + revision === draftRevision && + !options?.silent + ) { errorMessage = error instanceof Error ? error.message : "failed to preview workspace"; } } finally { - previewLoading = false; + if (request === previewRequest) previewLoading = false; } } async function applyWorkspace() { + if (!draftValid || loading || saving || applying) return; + const payload = getCurrentPayload(); + const snapshot = serializeConfig(payload); + const epoch = loadEpoch; + const revision = draftRevision; applying = true; + previewRequest++; + previewLoading = false; successMessage = ""; errorMessage = ""; - try { - const response = await fetch("/api/apply", { - method: "POST", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify(getCurrentPayload()), - }); - - if (!response.ok) { - throw new Error(await response.text()); - } - - const payload = await response.json(); - applyResult = (payload.result ?? null) as ApplyResult | null; - await load(); - await loadDoctor(); - await loadGhRepos(); - await previewWorkspace({ silent: true }); - if (applyResult) { - successMessage = `applied workspace / fetched ${applyResult.fetchedRepos.length} / linked ${applyResult.linkedCount} repos / linked ${applyResult.agentSkills.linkedCount} agent skills / copied config`; - } else { - successMessage = payload.message ?? `applied ${configPath}`; - } + const result = await responsePayload( + await fetch("/api/apply", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }), + ); + if (epoch !== loadEpoch) return; + const acknowledged = + result.value === undefined ? snapshot : serializeConfig(result.value); + originalSnapshot = acknowledged; + appliedSnapshot = acknowledged; + if (revision !== draftRevision) return; + applyResult = result.result ?? null; + successMessage = result.message ?? `applied ${configPath}`; } catch (error) { - errorMessage = - error instanceof Error ? error.message : "failed to apply workspace"; + if (epoch === loadEpoch && revision === draftRevision) + errorMessage = + error instanceof Error ? error.message : "failed to apply workspace"; } finally { applying = false; } } async function loadDoctor() { + const request = ++doctorRequest; + const epoch = loadEpoch; doctorLoading = true; try { @@ -498,47 +565,41 @@ async function loadDoctor() { throw new Error(await response.text()); } const payload = await response.json(); + if (request !== doctorRequest || epoch !== loadEpoch) return; doctorResult = payload.result ?? null; } catch (error) { - errorMessage = - error instanceof Error ? error.message : "failed to load doctor result"; + if (request === doctorRequest && epoch === loadEpoch) + errorMessage = + error instanceof Error ? error.message : "failed to load doctor result"; } finally { - doctorLoading = false; + if (request === doctorRequest) doctorLoading = false; } } -async function addRepoTemplate() { - addingRepo = true; - successMessage = ""; - errorMessage = ""; - +function appendRepo(repo: Record, message: string) { + if (!draftValid || loading || saving || applying) return; try { - const response = await fetch("/api/repos", { - method: "POST", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify({ - repo: getRepoTemplate(), - }), - }); - - if (!response.ok) { - throw new Error(await response.text()); - } - - await load(); - await previewWorkspace(); - successMessage = "added repo template"; + const config = getConfigObject(); + if (config.repos !== undefined && !Array.isArray(config.repos)) + throw new Error("repos must be an array"); + editVisual({ + ...config, + repos: [...((config.repos as JsonValue[]) ?? []), repo], + } as JsonValue); + successMessage = message; } catch (error) { errorMessage = - error instanceof Error ? error.message : "failed to add repo template"; - } finally { - addingRepo = false; + error instanceof Error ? error.message : "failed to add repo"; } } +function addRepoTemplate() { + appendRepo(getRepoTemplate(), "added repo template to draft"); +} + async function loadGhRepos(ownerOverride?: string) { + const request = ++ghRequest; + const epoch = loadEpoch; ghReposLoading = true; ghSelectedRepo = ""; ghRepoFilter = ""; @@ -552,6 +613,7 @@ async function loadGhRepos(ownerOverride?: string) { } const payload = (await response.json()) as GhReposPayload; + if (request !== ghRequest || epoch !== loadEpoch) return; ghAvailable = payload.available; ghAccounts = payload.accounts ?? []; ghRepos = payload.repositories ?? []; @@ -559,6 +621,7 @@ async function loadGhRepos(ownerOverride?: string) { ghSelectedRepo = ""; ghSelectedCategory = ghSelectedCategory || categories[0] || ""; } catch (error) { + if (request !== ghRequest || epoch !== loadEpoch) return; ghAvailable = false; ghAccounts = []; ghRepos = []; @@ -567,62 +630,22 @@ async function loadGhRepos(ownerOverride?: string) { errorMessage = error instanceof Error ? error.message : "failed to load gh repositories"; } finally { - ghReposLoading = false; + if (request === ghRequest) ghReposLoading = false; } } -async function addSelectedGhRepo() { +function addSelectedGhRepo() { const selected = selectedRepoPreview; - if (!selected) { - return; - } - - addingRepo = true; - successMessage = ""; - errorMessage = ""; - - try { - const response = await fetch("/api/repos", { - method: "POST", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify({ - repo: { - provider: selected.provider, - owner: selected.owner, - name: selected.name, - category: selected.category, - }, - }), - }); - - if (!response.ok) { - throw new Error(await response.text()); - } - - await load(); - await previewWorkspace(); - successMessage = `added ${selected.nameWithOwner}`; - } catch (error) { - errorMessage = - error instanceof Error ? error.message : "failed to add selected repo"; - } finally { - addingRepo = false; - } -} - -function parseRawValue() { - if (format === "json") { - return JSON.parse(rawValue); - } - - const trimmed = rawValue.trim(); - if (!trimmed) { - return {}; - } - - return JSON.parse(JSON.stringify(value)); + if (!selected) return; + appendRepo( + { + provider: selected.provider, + owner: selected.owner, + name: selected.name, + category: selected.category, + }, + `added ${selected.nameWithOwner} to draft`, + ); } @@ -638,18 +661,22 @@ function parseRawValue() { {format} {unsavedChanges ? 'unsaved' : 'saved'} - - - + {#if draftError && !loading} + + {/if} + {#if errorMessage}

{errorMessage}

{/if} @@ -664,8 +691,8 @@ function parseRawValue() {

{configPath ? configPath.split('/').at(-1) : 'ghq-sector config'}

- - + +
@@ -673,13 +700,11 @@ function parseRawValue() { {#if loading}
Loading config...
{:else if currentTab === 'raw'} - + {:else if schema} { - value = nextValue; - }} + onchange={editVisual} {schema} height="min(78vh, 920px)" class="json-editor" @@ -697,10 +722,10 @@ function parseRawValue() {

Workspace plan

- -
@@ -799,7 +824,7 @@ function parseRawValue() { Doctor {doctorResult?.ok ? 'Healthy' : 'Warn'} - @@ -827,7 +852,7 @@ function parseRawValue() { Repo presets {ghAvailable ? `${ghRepos.length} repos` : 'template only'} - @@ -845,9 +870,9 @@ function parseRawValue() { type="button" class="ghost-button icon-only-button repo-template-button" onclick={addRepoTemplate} - disabled={loading || saving || addingRepo} - aria-label={addingRepo ? 'Adding empty object' : 'Add empty object'} - data-tooltip={addingRepo ? 'Adding empty object' : 'Add empty object'} + disabled={!draftValid || loading || saving || applying} + aria-label={'Add empty object'} + data-tooltip={'Add empty object'} > @@ -929,9 +954,9 @@ function parseRawValue() {
- {:else}

gh is not available. Only adding an empty repo template is enabled.

diff --git a/ui/test/App.test.ts b/ui/test/App.test.ts new file mode 100644 index 0000000..4946ec1 --- /dev/null +++ b/ui/test/App.test.ts @@ -0,0 +1,452 @@ +import { flushSync, mount, tick, unmount } from "svelte"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import App from "../src/App.svelte"; + +vi.mock("@visual-json/svelte", async () => ({ + JsonEditor: (await import("./JsonEditor.svelte")).default, +})); + +const original = { + categories: ["work"], + defaults: { owner: "original" }, + repos: [], +}; +const preview = { + summary: { totalRepos: 0 }, + repoLinks: [], + resources: [], + codeWorkspace: { enabled: false, folders: [] }, + agentSkills: { selected: [], duplicateGroups: [], warnings: [], summary: {} }, +}; +const ghPayload = { + available: true, + owner: "alice", + accounts: [{ login: "alice", active: true }], + repositories: [ + { + provider: "github.com", + owner: "alice", + name: "new", + nameWithOwner: "alice/new", + url: "https://github.com/alice/new", + isPrivate: false, + }, + ], +}; +let instance: ReturnType; +let serverValue: unknown; +let configFormat: "json" | "yaml"; +let ghAvailable: boolean; +let writes: { url: string; body: unknown }[]; +let putHandler: (() => Promise) | undefined; +let applyHandler: (() => Promise) | undefined; +let previewHandler: (() => Promise) | undefined; +let configHandler: (() => Promise) | undefined; +let configReads: number; + +function response(body: unknown, status = 200) { + return Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ); +} +function deferred() { + let resolve!: (response: Response) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { + promise, + resolve: (body: unknown, status = 200) => + resolve(new Response(JSON.stringify(body), { status })), + }; +} +async function settle() { + for (let i = 0; i < 12; i++) { + await Promise.resolve(); + await tick(); + } +} +function button(label: string) { + const found = Array.from( + document.querySelectorAll("button"), + ).find( + (element) => + element.getAttribute("aria-label") === label || + element.textContent?.trim() === label, + ); + if (!found) throw new Error(`Missing button ${label}`); + return found; +} +async function click(label: string) { + button(label).click(); + await settle(); +} +async function input(label: string, value: string) { + const element = document.querySelector< + HTMLInputElement | HTMLTextAreaElement + >(`[aria-label="${label}"]`); + if (!element) throw new Error(`Missing input ${label}`); + element.value = value; + element.dispatchEvent(new Event("input", { bubbles: true })); + await settle(); +} +function raw() { + return ( + document.querySelector( + '[aria-label="Raw JSON config"]', + ) as HTMLTextAreaElement + ).value; +} +function dirty() { + return document.querySelector(".badges")?.textContent?.includes("unsaved"); +} +async function start() { + instance = mount(App, { target: document.body }); + flushSync(); + await settle(); +} + +beforeEach(() => { + serverValue = structuredClone(original); + configFormat = "json"; + ghAvailable = false; + writes = []; + configReads = 0; + putHandler = undefined; + applyHandler = undefined; + previewHandler = undefined; + configHandler = undefined; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, options?: RequestInit) => { + if (options?.method) { + const body = JSON.parse(options.body as string); + writes.push({ url, body }); + if (url === "/api/config") { + if (putHandler) return putHandler(); + serverValue = body; + return response({ ok: true }); + } + if (url === "/api/apply") { + if (applyHandler) return applyHandler(); + serverValue = body; + return response({ ok: true, message: "workspace applied" }); + } + if (url === "/api/preview") + return previewHandler + ? previewHandler() + : response({ result: preview }); + throw new Error(`Unexpected write ${url}`); + } + if (url === "/api/config") { + configReads++; + return configHandler + ? configHandler() + : response({ + path: "/fixture/config.json", + format: configFormat, + schema: {}, + value: serverValue, + raw: + configFormat === "yaml" + ? "categories: [work]\nrepos: []\n" + : JSON.stringify(serverValue, null, 2), + }); + } + if (url === "/api/doctor") return response({ result: null }); + if (url.startsWith("/api/gh/repos")) + return response( + ghAvailable + ? ghPayload + : { available: false, repositories: [], accounts: [] }, + ); + throw new Error(`Unexpected request ${url}`); + }), + ); + vi.stubGlobal( + "confirm", + vi.fn(() => true), + ); +}); +afterEach(async () => { + if (instance) await unmount(instance); + document.body.innerHTML = ""; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("JSON draft retention", () => { + it("round trips Visual / Raw without losing either edit", async () => { + await start(); + const visualEdit = { + ...original, + categories: ["visual"], + defaults: { owner: "draft" }, + }; + await input("Visual JSON test editor", JSON.stringify(visualEdit)); + await click("Raw"); + expect(JSON.parse(raw())).toEqual(visualEdit); + const rawEdit = { ...visualEdit, categories: ["raw"] }; + await input("Raw JSON config", JSON.stringify(rawEdit)); + await click("Visual"); + expect( + JSON.parse( + ( + document.querySelector( + '[aria-label="Visual JSON test editor"]', + ) as HTMLTextAreaElement + ).value, + ), + ).toEqual(rawEdit); + await click("Raw"); + expect(JSON.parse(raw())).toEqual(rawEdit); + }); + + it("retains invalid text and blocks visual, save, preview, apply and repo additions", async () => { + await start(); + await click("Raw"); + writes = []; + await input("Raw JSON config", '{"categories": ['); + for (const label of [ + "Visual", + "Save config", + "Refresh workspace plan", + "Apply workspace", + "Add empty object", + ]) + expect(button(label).disabled).toBe(true); + await click("Visual"); + await click("Save config"); + await click("Apply workspace"); + await new Promise((done) => setTimeout(done, 300)); + await settle(); + expect(raw()).toBe('{"categories": ['); + expect(writes).toEqual([]); + expect(dirty()).toBe(true); + }); + + it("adds empty repos only to the current draft, keeping categories/defaults", async () => { + await start(); + await click("Raw"); + const draft = { + ...original, + categories: ["draft"], + defaults: { owner: "edited", category: "draft" }, + }; + await input("Raw JSON config", JSON.stringify(draft)); + await click("Add empty object"); + expect(JSON.parse(raw())).toEqual({ + ...draft, + repos: [ + { + provider: "github.com", + owner: "edited", + name: "", + category: "draft", + }, + ], + }); + expect(serverValue).toEqual(original); + expect( + writes.some( + (write) => write.url === "/api/repos" || write.url === "/api/config", + ), + ).toBe(false); + }); + + it("adds a GitHub preset locally without discarding raw changes", async () => { + ghAvailable = true; + await start(); + await click("Raw"); + const draft = { + ...original, + categories: ["draft"], + defaults: { owner: "edited" }, + }; + await input("Raw JSON config", JSON.stringify(draft)); + const repository = Array.from(document.querySelectorAll("select")).find( + (select) => select.querySelector('option[value="alice/new"]'), + ); + if (!repository) throw new Error("Missing repository select"); + for (const option of repository.options) + option.selected = option.value === "alice/new"; + repository.dispatchEvent(new Event("change", { bubbles: true })); + await settle(); + await click("Add selected repo"); + const result = JSON.parse(raw()); + expect(result.categories).toEqual(draft.categories); + expect(result.defaults).toEqual(draft.defaults); + expect(result.repos[0].name).toBe("new"); + expect(result.repos[0].category).toBe("draft"); + expect(serverValue).toEqual(original); + expect( + writes.some( + (write) => write.url === "/api/repos" || write.url === "/api/config", + ), + ).toBe(false); + }); + + it("retains invalid raw after a GitHub repo is selected without rendering the old draft", async () => { + ghAvailable = true; + await start(); + await click("Raw"); + const repository = Array.from(document.querySelectorAll("select")).find( + (select) => select.querySelector('option[value="alice/new"]'), + ); + if (!repository) throw new Error("Missing repository select"); + for (const option of repository.options) + option.selected = option.value === "alice/new"; + repository.dispatchEvent(new Event("change", { bubbles: true })); + await settle(); + await input("Raw JSON config", "{invalid"); + expect(raw()).toBe("{invalid"); + expect(button("Add selected repo").disabled).toBe(true); + await input("Raw JSON config", "null"); + expect(raw()).toBe("null"); + await click("Add selected repo"); + expect(raw()).toBe("null"); + }); + + it("uses the same valid snapshot for preview, save and apply", async () => { + await start(); + await click("Raw"); + writes = []; + const draft = { ...original, defaults: { owner: "shared" } }; + await input("Raw JSON config", JSON.stringify(draft)); + await click("Refresh workspace plan"); + await click("Save config"); + await click("Apply workspace"); + for (const url of ["/api/preview", "/api/config", "/api/apply"]) + expect(writes.find((write) => write.url === url)?.body).toEqual(draft); + expect(dirty()).toBe(false); + expect(configReads).toBe(1); + }); + + it("records the server-normalized saved baseline without rewriting the visible draft", async () => { + await start(); + await click("Raw"); + const draft = { ...original, extra: "keep visible" }; + await input("Raw JSON config", JSON.stringify(draft)); + putHandler = () => response({ ok: true, value: original }); + await click("Save config"); + expect(JSON.parse(raw())).toEqual(draft); + expect(dirty()).toBe(true); + await input("Raw JSON config", JSON.stringify(original)); + expect(dirty()).toBe(false); + }); + + it("keeps baseline and draft on save/apply errors", async () => { + await start(); + await click("Raw"); + const draft = { ...original, categories: ["draft"] }; + await input("Raw JSON config", JSON.stringify(draft)); + putHandler = () => response({ message: "write failed" }, 500); + await click("Save config"); + expect(dirty()).toBe(true); + expect(JSON.parse(raw())).toEqual(draft); + expect(document.body.textContent).toContain("write failed"); + applyHandler = () => + response( + { message: "Apply stopped; completed changes were not rolled back." }, + 500, + ); + await click("Apply workspace"); + expect(dirty()).toBe(true); + expect(JSON.parse(raw())).toEqual(draft); + expect(configReads).toBe(1); + }); + + it("prevents repeated saves and preserves newer edits when a save returns late", async () => { + await start(); + await click("Raw"); + const first = { ...original, categories: ["first"] }; + const second = { ...original, categories: ["second"] }; + await input("Raw JSON config", JSON.stringify(first)); + const pending = deferred(); + putHandler = () => pending.promise; + await click("Save config"); + button("Saving config").click(); + await settle(); + await input("Raw JSON config", JSON.stringify(second)); + pending.resolve({ ok: true }); + await settle(); + expect(JSON.parse(raw())).toEqual(second); + expect(dirty()).toBe(true); + expect(configReads).toBe(1); + expect(writes.filter((write) => write.url === "/api/config")).toHaveLength( + 1, + ); + // The persisted baseline is the acknowledged snapshot, rather than the newer draft. + await input("Raw JSON config", JSON.stringify(first)); + expect(dirty()).toBe(false); + }); + + it("keeps newer edits after delayed apply and ignores stale previews", async () => { + await start(); + await click("Raw"); + const pendingPreview = deferred(); + previewHandler = () => pendingPreview.promise; + await click("Refresh workspace plan"); + const first = { ...original, categories: ["first"] }; + await input("Raw JSON config", JSON.stringify(first)); + const pendingApply = deferred(); + applyHandler = () => pendingApply.promise; + await click("Apply workspace"); + const second = { ...original, categories: ["second"] }; + await input("Raw JSON config", JSON.stringify(second)); + pendingPreview.resolve({ + result: { ...preview, summary: { totalRepos: 999 } }, + }); + pendingApply.resolve({ ok: true, message: "old apply completed" }); + await settle(); + expect(JSON.parse(raw())).toEqual(second); + expect(dirty()).toBe(true); + expect(document.body.textContent).not.toContain("999"); + expect(document.body.textContent).not.toContain("old apply completed"); + }); + + it("only discards on confirmed reload and reports read failures without losing the draft", async () => { + await start(); + await click("Raw"); + const draft = { ...original, categories: ["draft"] }; + await input("Raw JSON config", JSON.stringify(draft)); + vi.mocked(window.confirm).mockReturnValue(false); + await click("Reload"); + expect(configReads).toBe(1); + expect(JSON.parse(raw())).toEqual(draft); + vi.mocked(window.confirm).mockReturnValue(true); + configHandler = () => response({ message: "read failed" }, 500); + await click("Reload"); + expect(JSON.parse(raw())).toEqual(draft); + expect(dirty()).toBe(true); + expect(document.body.textContent).toContain("read failed"); + configHandler = undefined; + await click("Reload"); + expect(JSON.parse(raw())).toEqual(original); + expect(dirty()).toBe(false); + }); + + it("shows YAML read-only and does not offer implicit conversion or writes", async () => { + configFormat = "yaml"; + await start(); + expect(raw()).toContain("categories: [work]"); + expect( + ( + document.querySelector( + '[aria-label="Raw JSON config"]', + ) as HTMLTextAreaElement + ).readOnly, + ).toBe(true); + for (const label of [ + "Visual", + "Save config", + "Apply workspace", + "Add empty object", + ]) + expect(button(label).disabled).toBe(true); + expect(writes).toEqual([]); + }); +}); diff --git a/ui/test/JsonEditor.svelte b/ui/test/JsonEditor.svelte new file mode 100644 index 0000000..b8c0f32 --- /dev/null +++ b/ui/test/JsonEditor.svelte @@ -0,0 +1,4 @@ + + diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts new file mode 100644 index 0000000..792f9b3 --- /dev/null +++ b/ui/vitest.config.ts @@ -0,0 +1,20 @@ +import { fileURLToPath } from "node:url"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [svelte()], + resolve: { + conditions: ["browser"], + alias: { + "@internal/ui": fileURLToPath( + new URL("./src/visual-json-internal.ts", import.meta.url), + ), + }, + }, + test: { + environment: "jsdom", + include: ["test/**/*.test.ts"], + maxWorkers: 1, + }, +}); From 33639e619b7be65bb9fb68bba21aec61ca5c37c5 Mon Sep 17 00:00:00 2001 From: ts-76 Date: Sat, 3 Oct 2026 18:42:45 +0900 Subject: [PATCH 5/9] fix(ui): reset repo insertion status messages --- ui/src/App.svelte | 2 ++ ui/test/App.test.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 6d71b86..cdd229a 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -578,6 +578,8 @@ async function loadDoctor() { function appendRepo(repo: Record, message: string) { if (!draftValid || loading || saving || applying) return; + successMessage = ""; + errorMessage = ""; try { const config = getConfigObject(); if (config.repos !== undefined && !Array.isArray(config.repos)) diff --git a/ui/test/App.test.ts b/ui/test/App.test.ts index 4946ec1..083f97c 100644 --- a/ui/test/App.test.ts +++ b/ui/test/App.test.ts @@ -42,6 +42,7 @@ let putHandler: (() => Promise) | undefined; let applyHandler: (() => Promise) | undefined; let previewHandler: (() => Promise) | undefined; let configHandler: (() => Promise) | undefined; +let doctorHandler: (() => Promise) | undefined; let configReads: number; function response(body: unknown, status = 200) { @@ -119,6 +120,7 @@ beforeEach(() => { applyHandler = undefined; previewHandler = undefined; configHandler = undefined; + doctorHandler = undefined; vi.stubGlobal( "fetch", vi.fn(async (url: string, options?: RequestInit) => { @@ -156,7 +158,8 @@ beforeEach(() => { : JSON.stringify(serverValue, null, 2), }); } - if (url === "/api/doctor") return response({ result: null }); + if (url === "/api/doctor") + return doctorHandler ? doctorHandler() : response({ result: null }); if (url.startsWith("/api/gh/repos")) return response( ghAvailable @@ -257,6 +260,47 @@ describe("JSON draft retention", () => { ).toBe(false); }); + it("clears a failed save error when a repo is successfully appended", async () => { + await start(); + await click("Raw"); + putHandler = () => response({ message: "write failed" }, 500); + await click("Save config"); + expect(document.querySelector(".status.error")?.textContent).toBe( + "write failed", + ); + await click("Add empty object"); + expect(document.querySelector(".status.error")).toBeNull(); + expect(document.querySelector(".status.success")?.textContent).toBe( + "added repo template to draft", + ); + expect(JSON.parse(raw()).repos).toHaveLength(1); + expect(serverValue).toEqual(original); + }); + + it("clears the previous operation's success when appending to an invalid config fails", async () => { + await start(); + await click("Raw"); + const pendingDoctor = deferred(); + doctorHandler = () => pendingDoctor.promise; + await click("Reload"); + // The file is loaded while Reload still awaits its diagnostics. A new valid + // JSON draft can have an invalid repos field without being saved to disk. + const draft = { ...original, repos: "invalid repos" }; + await input("Raw JSON config", JSON.stringify(draft)); + pendingDoctor.resolve({ result: null }); + await settle(); + expect(document.querySelector(".status.success")?.textContent).toContain( + "reloaded", + ); + await click("Add empty object"); + expect(document.querySelector(".status.success")).toBeNull(); + expect(document.querySelector(".status.error")?.textContent).toBe( + "repos must be an array", + ); + expect(JSON.parse(raw())).toEqual(draft); + expect(serverValue).toEqual(original); + }); + it("adds a GitHub preset locally without discarding raw changes", async () => { ghAvailable = true; await start(); From 365d0762aad05d935cc65492747a46bd96ff5b10 Mon Sep 17 00:00:00 2001 From: ts-76 Date: Sat, 3 Oct 2026 18:45:34 +0900 Subject: [PATCH 6/9] fix: address editor API review and framing protection --- README.md | 2 +- docs/README.ja.md | 2 +- src/commands/edit.ts | 2 ++ src/commands/sync.ts | 2 +- test/command-boundaries.test.ts | 33 +++++++++++++++++++ test/edit-server.test.ts | 58 ++++++++++++++++++++++++++++++++- ui/src/App.svelte | 43 +++++------------------- ui/src/repo-proposal.ts | 23 +++++++++++++ 8 files changed, 127 insertions(+), 38 deletions(-) create mode 100644 ui/src/repo-proposal.ts diff --git a/README.md b/README.md index 552e6fb..4c5bf0d 100644 --- a/README.md +++ b/README.md @@ -240,7 +240,7 @@ Options: - `--port `: port to bind the editor server to - `--no-open`: do not open a browser automatically -The editor binds only to `127.0.0.1`, `::1`, or `localhost`; other hosts (including `0.0.0.0`) are rejected. Open the exact address printed by the command. All API requests require that Host and the same Origin. A browser's same-origin fetch without Origin is accepted; explicit local clients without Origin must send `X-Ghq-Sector-Request: 1`. Cross-site requests are rejected, including reads. This is a local editor, not an authenticated shared service. +The editor binds only to `127.0.0.1`, `::1`, or `localhost`; other hosts (including `0.0.0.0`) are rejected. Open the exact address printed by the command. All API requests require that Host and the same Origin. A browser's same-origin fetch without Origin is accepted; explicit local clients without Origin must send `X-Ghq-Sector-Request: 1`. Cross-site requests are rejected, including reads. Responses also reject framing with CSP `frame-ancestors 'none'` and `X-Frame-Options: DENY`. This is a local editor, not an authenticated shared service. Save, Preview, Apply, and draft repo proposals accept UTF-8 `application/json` with a 1 MiB body limit. Invalid JSON/config returns 400, oversized bodies 413, and other content types 415. Repo proposals accept `{config: currentDraft, repo: optionalRepo}` and return a draft without writing the config. During Save/Apply, competing API requests for that config return 409 busy; retry only after completion. An exclusive `.editor-lock` lease protects editor instances across processes, including different ports; external CLI tools or manual file edits do not participate. After an abnormal exit, first confirm the recorded PID is no longer running, then remove only that stale lease file before retrying. The editor never removes an existing lease automatically. diff --git a/docs/README.ja.md b/docs/README.ja.md index 450dfe8..975117c 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -226,7 +226,7 @@ gsec edit --host localhost --port 4173 - `--port `: editor server の bind port - `--no-open`: ブラウザを自動で開かない -editor の bind は `127.0.0.1` / `::1` / `localhost` のみです。`0.0.0.0` などは起動前に拒否します。表示された URL をそのまま開いてください。全 API で Host と同じ Origin を検証します。Origin がないブラウザの same-origin fetch は許可し、それ以外のローカルクライアントには `X-Ghq-Sector-Request: 1` を要求します。読み取りも cross-site request は拒否します。 +editor の bind は `127.0.0.1` / `::1` / `localhost` のみです。`0.0.0.0` などは起動前に拒否します。表示された URL をそのまま開いてください。全 API で Host と同じ Origin を検証します。Origin がないブラウザの same-origin fetch は許可し、それ以外のローカルクライアントには `X-Ghq-Sector-Request: 1` を要求します。読み取りも cross-site request は拒否します。全応答に CSP `frame-ancestors 'none'` と `X-Frame-Options: DENY` を付け、フレーム埋め込みを拒否します。 Save / Preview / Apply / repo ドラフト提案は UTF-8 の `application/json`、最大 1 MiB です。不正 JSON・schema は 400、サイズ超過は 413、Content-Type 不一致は 415 を返します。repo 提案は `{config: currentDraft, repo: optionalRepo}` を受け、ファイル保存せずドラフトを返します。Save / Apply 中は同じ設定への競合 API に 409 busy を返します。設定に隣接する `.editor-lock` を排他的に作成し、別プロセス・別ポートの編集サーバー間でも保護します。外部 CLI や手動ファイル編集は含みません。異常終了で残った lease は、記録された PID が停止済みか確認してから、その lease ファイルだけを手動で削除してください。既存 lease を自動削除しません。 diff --git a/src/commands/edit.ts b/src/commands/edit.ts index 3d46972..af87cd5 100644 --- a/src/commands/edit.ts +++ b/src/commands/edit.ts @@ -117,6 +117,8 @@ export async function createEditServer(options: { const configPath = await realpath(options.configPath); let origin = ""; const httpServer = createServer(async (request, response) => { + response.setHeader("content-security-policy", "frame-ancestors 'none'"); + response.setHeader("x-frame-options", "DENY"); try { await handleRequest(request, response, { ...options, diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 960d6f9..8a08c6a 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -37,12 +37,12 @@ export async function runSync( runtimeConfig?: GhqWsConfig, report?: ApplyProgressReporter, ): Promise { + report?.("links", false); const loaded = await loadConfig(cwd); info(`loaded config: ${loaded.path}`); const config = runtimeConfig ?? (await resolveConfigForCurrentMachine(loaded.config)); - report?.("links", false); const result = await syncWorkspace(config); report?.("links", true); report?.("resources", false); diff --git a/test/command-boundaries.test.ts b/test/command-boundaries.test.ts index 4660130..08590f8 100644 --- a/test/command-boundaries.test.ts +++ b/test/command-boundaries.test.ts @@ -321,6 +321,39 @@ describe("apply workflow", () => { ); }); + it("reports links failure when the second config load fails after repos completed", async () => { + const root = await makeTempRoot(); + const config = createConfig(root); + const configPath = path.join(root, "config.json"); + const loadConfig = vi + .fn() + .mockResolvedValueOnce({ path: configPath, config }) + .mockRejectedValueOnce(new Error("external edit made config unreadable")); + vi.doMock("../src/config/load-config.js", () => ({ loadConfig })); + vi.doMock("../src/config/machine-paths.js", () => ({ + getRuntimePaths: vi.fn(async () => ({ + resolvedGhqRoot: config.ghqRoot, + resolvedWorkspaceRoot: config.workspaceRoot, + })), + })); + const ensureRepos = vi.fn(async () => ({ + fetched: [], + alreadyPresent: [], + })); + vi.doMock("../src/ghq/ensure-repos.js", () => ({ ensureRepos })); + const { runApply } = await importFresh< + typeof import("../src/commands/apply.js") + >("../src/commands/apply.js"); + await expect(runApply(configPath)).rejects.toMatchObject({ + progress: { + completed: ["prepare", "repos"], + failedStage: "links", + }, + }); + expect(loadConfig).toHaveBeenCalledTimes(2); + expect(ensureRepos).toHaveBeenCalledTimes(1); + }); + it("ghq gets only missing repos in ensureRepos and runs clone hooks around them", async () => { const root = await makeTempRoot(); const config = createConfig(root); diff --git a/test/edit-server.test.ts b/test/edit-server.test.ts index 32549b5..35b5194 100644 --- a/test/edit-server.test.ts +++ b/test/edit-server.test.ts @@ -10,6 +10,7 @@ import { import { request as httpRequest } from "node:http"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { requestRepoProposal } from "../ui/src/repo-proposal.js"; import { createConfig, makeTempRoot } from "./helpers.js"; const servers: { stop(): void }[] = []; @@ -72,6 +73,7 @@ function request( return new Promise<{ status: number; text: string; + headers: Record; body: Record; }>((resolve, reject) => { const req = httpRequest( @@ -96,7 +98,12 @@ function request( } catch { /* static asset */ } - resolve({ status: response.statusCode ?? 0, text, body }); + resolve({ + status: response.statusCode ?? 0, + text, + headers: response.headers, + body, + }); }); }, ); @@ -154,6 +161,55 @@ describe("local editor HTTP boundaries", () => { ).toContain("ghq-sector"); }); + it("prevents UI framing on assets and API success/error responses", async () => { + const f = await fixture(); + for (const [route, options] of [ + ["/", {}], + ["/api/config", {}], + ["/api/config", { headers: { Host: "foreign.test" } }], + ] as const) { + const result = await request(f.origin, route, options); + expect(result.headers["content-security-policy"]).toBe( + "frame-ancestors 'none'", + ); + expect(result.headers["x-frame-options"]).toBe("DENY"); + } + }); + + it.each([ + "template", + "selected GitHub repo", + ])("uses the bundled UI proposal contract for %s without saving the draft", async (action) => { + const f = await fixture(); + const draft = { + ...f.config, + categories: ["unsaved"], + defaults: { category: "unsaved", owner: "draft-owner" }, + }; + const repo = { + provider: "github.com", + owner: "draft-owner", + name: action === "template" ? "" : "chosen", + category: "unsaved", + }; + const send: typeof fetch = (input, init) => + fetch(new URL(String(input), f.origin), { + ...init, + headers: { ...init?.headers, Origin: f.origin }, + }); + const nextDraft = await requestRepoProposal( + draft, + action === "template" ? undefined : repo, + send, + ); + expect(nextDraft).toMatchObject({ categories: ["unsaved"], repos: [repo] }); + expect(JSON.parse(await readFile(f.configPath, "utf8"))).toEqual(f.config); + expect( + (await request(f.origin, "/api/config", json(nextDraft))).status, + ).toBe(200); + expect(JSON.parse(await readFile(f.configPath, "utf8"))).toEqual(nextDraft); + }); + it("rejects public binds before loading config or building UI", async () => { const { runEdit, createEditServer } = await import( "../src/commands/edit.js" diff --git a/ui/src/App.svelte b/ui/src/App.svelte index e6fedc4..ad2aa14 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -14,6 +14,7 @@ import { Stethoscope, } from "lucide-svelte"; import { onMount } from "svelte"; +import { requestRepoProposal } from "./repo-proposal.js"; type ConfigFormat = "json" | "yaml"; type EditorTab = "visual" | "raw"; @@ -513,21 +514,8 @@ async function addRepoTemplate() { errorMessage = ""; try { - const response = await fetch("/api/repos", { - method: "POST", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify({ - repo: getRepoTemplate(), - }), - }); - - if (!response.ok) { - throw new Error(await response.text()); - } - - await load(); + value = await requestRepoProposal(getCurrentPayload(), undefined); + rawValue = serializeConfig(value); await previewWorkspace(); successMessage = "added repo template"; } catch (error) { @@ -582,26 +570,13 @@ async function addSelectedGhRepo() { errorMessage = ""; try { - const response = await fetch("/api/repos", { - method: "POST", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify({ - repo: { - provider: selected.provider, - owner: selected.owner, - name: selected.name, - category: selected.category, - }, - }), + value = await requestRepoProposal(getCurrentPayload(), { + provider: selected.provider, + owner: selected.owner, + name: selected.name, + category: selected.category, }); - - if (!response.ok) { - throw new Error(await response.text()); - } - - await load(); + rawValue = serializeConfig(value); await previewWorkspace(); successMessage = `added ${selected.nameWithOwner}`; } catch (error) { diff --git a/ui/src/repo-proposal.ts b/ui/src/repo-proposal.ts new file mode 100644 index 0000000..5851022 --- /dev/null +++ b/ui/src/repo-proposal.ts @@ -0,0 +1,23 @@ +import type { JsonValue } from "@visual-json/core"; + +/** Return a proposed draft; adding a repo never saves the config. */ +export async function requestRepoProposal( + config: JsonValue, + repo: unknown, + send: typeof fetch = fetch, +): Promise { + const response = await send("/api/repos", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ config, repo }), + }); + const payload = (await response.json()) as { + ok?: boolean; + value?: JsonValue; + message?: string; + }; + if (!response.ok || payload.ok !== true || payload.value === undefined) { + throw new Error(payload.message ?? "Failed to propose repo draft"); + } + return payload.value; +} From d9b4453fe31757eb5842f218c4b943fedd6ff9c9 Mon Sep 17 00:00:00 2001 From: ts-76 Date: Sat, 3 Oct 2026 18:52:43 +0900 Subject: [PATCH 7/9] fix: preserve observed link ownership and raced entries --- README.md | 6 + docs/README.ja.md | 6 + src/workspace/safe-links.ts | 150 +++++++++++-- src/workspace/sync-agent-skills.ts | 16 +- src/workspace/sync-workspace.ts | 20 +- test/sync-races.test.ts | 342 +++++++++++++++++++++++++++++ 6 files changed, 516 insertions(+), 24 deletions(-) create mode 100644 test/sync-races.test.ts diff --git a/README.md b/README.md index 3d6a145..68e4c23 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,12 @@ Sync checks the entire repository/skill link plan before changing links. It refu A link already pointing to the configured source is safe to reuse, including a broken link whose source is currently missing. Other links can be replaced only when their current target matches the source recorded in `.ghq-sector/repo-links-manifest.json` or `agent-skills-manifest.json`. Unexpected links remain untouched. Stale skill links are removed only when both their recorded destination and actual target match; targets and real directories/files are never recursively deleted. Old path-only skill manifests cannot prove ownership, so their stale entries are left for manual review. Move a conflicting entry or update the config, then rerun Sync/Apply. This protection applies to managed links and their metadata; configured resources and hooks retain their explicit copy/command behavior. +When a configured repo source is missing, the ownership manifest keeps the observed managed target of any existing link; it does not grant ownership to the missing desired source. Case behavior is probed inside the workspace's volume using a temporary directory that is immediately removed. + +Sync acquires an exclusive `.ghq-sector-sync.lock` directory so CLI/editor sync operations cannot overlap. A crashed operation may leave this lease; inspect it and ensure no sync is running before manually removing it. Managed link replacement and stale cleanup atomically move the old entry into a private `.ghq-sector-retired-*/entry` directory beside its original location, inspect the moved entry, and retain it as recovery data. These recovery directories are not automatically deleted. Review and remove them manually when no longer needed; do not recursively follow their symlinks or delete their targets. + +If another application swaps in a real file/directory at the instant of a link update, Sync preserves the moved entry, stops, and reports its recovery path. It restores file access with an exclusive hardlink or directory access with a recovery symlink when the destination is still free; an entry newly created at the destination is left untouched. The lease coordinates ghq-sector operations, not arbitrary filesystem writers. Keep external writers from renaming workspace parent directories during Sync; this is not a cross-process transaction or a guarantee against a process replacing ancestor directories concurrently. + ### `gsec apply` Apply the full config state: ensure missing repositories exist in `ghq`, sync the workspace, and copy the config file into the workspace root. diff --git a/docs/README.ja.md b/docs/README.ja.md index f6a74ef..e90c460 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -160,6 +160,12 @@ Sync は repository と skill のリンク計画全体を、リンク変更前 設定された source をすでに指すリンクは、source が存在しない壊れたリンクも含めて保持します。別のリンク先へ更新できるのは、現在のリンク先が `.ghq-sector/repo-links-manifest.json` または `agent-skills-manifest.json` に記録された source と一致する場合だけです。想定外のリンクは変更しません。不要になった skill は、記録された宛先と実際のリンク先が一致する symlink だけを削除し、リンク先の実体や実ファイル・ディレクトリを再帰削除しません。従来のパスだけの skill manifest では管理対象と確認できないため、不要なエントリーは手動確認用に保持します。衝突するエントリーの移動または設定の変更後に Sync / Apply を再実行してください。この保護の対象は管理リンクとその metadata です。設定済み resources のコピーと hooks のコマンド実行は引き続き設定どおりに行います。 +設定した repo の source が存在しない場合、既存の管理リンクが実際に指す source を manifest に保持します。存在しない変更予定の source に管理権限を与える記録は作りません。大文字・小文字の区別は workspace のボリューム内に一時ディレクトリを作って検査し、すぐに片付けます。 + +Sync は `.ghq-sector-sync.lock` ディレクトリで排他制御し、CLI と編集 UI の Sync が同時に進行することを防ぎます。異常終了後に残った場合は、実行中の Sync がないことと内容を確認してから手動で取り除いてください。管理リンクの置換と stale cleanup は、元のエントリーを同じ親ディレクトリ内の非公開な `.ghq-sector-retired-*/entry` へ原子的に移し、その実体を確認したうえで復旧用に保持します。退避ディレクトリは自動削除しません。不要になったら手動で確認・削除してください。symlink のリンク先を再帰的に辿ったり削除したりしないでください。 + +リンク更新の瞬間に他のアプリが実ファイル・ディレクトリへ差し替えた場合も、移された実体を保持して Sync を中断し、復旧先を表示します。宛先が空いていれば、ファイルは排他的な hardlink、ディレクトリは復旧先への symlink でアクセスを戻します。別のエントリーが新たに作られていれば上書きしません。排他制御の対象は ghq-sector の操作です。外部アプリによる親ディレクトリの変更までは制御できないため、Sync 中は workspace の親ディレクトリを移動しないでください。複数プロセスにまたがるトランザクションや、親ディレクトリを同時に差し替えるプロセスに対する完全な保証は提供しません。 + ### `gsec apply` config の完全な状態を反映します。`ghq` 内に不足している repository を揃え、workspace を sync し、config file を workspace root にコピーします。 diff --git a/src/workspace/safe-links.ts b/src/workspace/safe-links.ts index af140fb..e31b81f 100644 --- a/src/workspace/safe-links.ts +++ b/src/workspace/safe-links.ts @@ -1,12 +1,15 @@ import { constants } from "node:fs"; import { + link as hardlink, lstat, mkdir, + mkdtemp, open, readFile, readlink, + rename, + rmdir, symlink, - unlink, } from "node:fs/promises"; import path from "node:path"; @@ -85,27 +88,47 @@ export async function assertMetadataFile(root: string, filename: string) { export async function usesCaseInsensitivePaths(root: string) { let ancestor = path.resolve(root); while (!(await statIfPresent(ancestor))) ancestor = path.dirname(ancestor); - while (ancestor !== path.dirname(ancestor)) { - const basename = path.basename(ancestor); - const alternate = basename.replace(/[a-zA-Z]/, (letter) => - letter === letter.toUpperCase() - ? letter.toLowerCase() - : letter.toUpperCase(), + // Probe a child of the target directory, not its basename in the parent + // volume: a workspace may itself be a mount point with different behavior. + const probe = await mkdtemp(path.join(ancestor, ".ghq-sector-case-")); + try { + const original = await lstat(probe); + const changed = await statIfPresent( + path.join(ancestor, path.basename(probe).toUpperCase()), ); - if (alternate !== basename) { - const original = await statIfPresent(ancestor); - const changed = await statIfPresent( - path.join(path.dirname(ancestor), alternate), - ); - return ( - !!changed && - original?.ino === changed.ino && - original?.dev === changed.dev + return ( + !!changed && original.ino === changed.ino && original.dev === changed.dev + ); + } finally { + await rmdir(probe); + } +} + +// mkdir is an exclusive cross-process lease shared by CLI and editor Sync. +// A crash leaves the empty lease for manual inspection; never steal a lease. +export async function withWorkspaceSyncLease( + root: string, + action: () => Promise, +): Promise { + await assertDirectory(root, root); + await mkdir(root, { recursive: true }); + const lease = path.join(root, ".ghq-sector-sync.lock"); + try { + await mkdir(lease, { mode: 0o700 }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") + conflict( + lease, + "another sync holds the workspace lease; inspect a leftover lease before removing it", ); - } - ancestor = path.dirname(ancestor); + throw error; + } + try { + return await action(); + } finally { + // rmdir refuses files and nonempty directories: never recursively clean. + await rmdir(lease); } - return false; } function destinationKey(destination: string, caseInsensitive: boolean) { @@ -217,7 +240,6 @@ export async function installLink( link: ManagedLink, previous: ManagedLink[], ) { - // Recheck immediately before unlink; never use recursive removal on a link. await assertLink(root, link, previous); await mkdir(path.dirname(link.destinationPath), { recursive: true }); const stat = await statIfPresent(link.destinationPath); @@ -228,7 +250,17 @@ export async function installLink( await readlink(link.destinationPath), ); if (target === path.resolve(link.sourcePath)) return; - await unlink(link.destinationPath); + if ( + !previous.some( + (entry) => + path.resolve(entry.destinationPath) === + path.resolve(link.destinationPath) && + path.resolve(entry.sourcePath) === target, + ) + ) { + conflict(link.destinationPath, "unexpected symlink target is preserved"); + } + await retireLink(root, { ...link, sourcePath: target }); } await symlink(link.sourcePath, link.destinationPath, "dir"); } @@ -242,10 +274,84 @@ export async function removeRecordedLink(root: string, link: ManagedLink) { await readlink(link.destinationPath), ); if (target !== path.resolve(link.sourcePath)) return false; - await unlink(link.destinationPath); + await retireLink(root, link); return true; } +async function retireLink(root: string, link: ManagedLink) { + await assertSafePath(root, link.destinationPath); + // Atomically move the entry before inspecting it. Checking then unlinking the + // original pathname could delete a real file swapped in by an external writer. + // Keep the displaced entry as recovery data: never unlink it after a check. + const recovery = await mkdtemp( + path.join(path.dirname(link.destinationPath), ".ghq-sector-retired-"), + ); + const retained = path.join(recovery, "entry"); + try { + await assertSafePath(root, link.destinationPath); + await rename(link.destinationPath, retained); + } catch (error) { + await rmdir(recovery); + throw error; + } + const stat = await lstat(retained); + const target = stat.isSymbolicLink() + ? path.resolve(path.dirname(link.destinationPath), await readlink(retained)) + : null; + if (target === path.resolve(link.sourcePath)) return; + + // Restore access without ever overwriting an entry created by another writer. + // Files/symlinks use an exclusive hardlink; directories use a recovery symlink. + // The retained entry survives either outcome for manual recovery. + let restored = false; + try { + await assertSafePath(root, link.destinationPath); + if (stat.isDirectory()) + await symlink(retained, link.destinationPath, "dir"); + else await hardlink(retained, link.destinationPath); + restored = true; + } catch { + // EEXIST or a changed parent must not replace the new destination. + } + conflict( + link.destinationPath, + `entry changed during sync and was preserved at ${retained}${restored ? "; access restored at the destination" : "; destination left untouched"}`, + ); +} + +export async function observedOwnedLinks( + root: string, + desired: ManagedLink[], + previous: ManagedLink[], +) { + const observed: ManagedLink[] = []; + for (const link of desired) { + await assertLink(root, link, previous); + const stat = await statIfPresent(link.destinationPath); + if (!stat?.isSymbolicLink()) continue; + const sourcePath = path.resolve( + path.dirname(link.destinationPath), + await readlink(link.destinationPath), + ); + if ( + sourcePath !== path.resolve(link.sourcePath) && + !previous.some( + (entry) => + path.resolve(entry.destinationPath) === + path.resolve(link.destinationPath) && + path.resolve(entry.sourcePath) === sourcePath, + ) + ) { + conflict(link.destinationPath, "unexpected symlink target is preserved"); + } + observed.push({ + destinationPath: link.destinationPath, + sourcePath, + }); + } + return observed; +} + export async function writeLinkManifest( root: string, filename: string, diff --git a/src/workspace/sync-agent-skills.ts b/src/workspace/sync-agent-skills.ts index d97d297..0014484 100644 --- a/src/workspace/sync-agent-skills.ts +++ b/src/workspace/sync-agent-skills.ts @@ -14,10 +14,12 @@ import { assertMetadataFile, assertSafePath, installLink, + observedOwnedLinks, readLinkManifest, removeRecordedLink, statIfPresent, usesCaseInsensitivePaths, + withWorkspaceSyncLease, writeLinkManifest, writeMetadataFile, } from "./safe-links.js"; @@ -90,6 +92,14 @@ export async function syncAgentSkills( plan: PlannedAgentSkills, prepared?: Awaited>, ): Promise { + if (!prepared) + return withWorkspaceSyncLease(workspaceRoot, async () => + syncAgentSkills( + workspaceRoot, + plan, + await prepareAgentSkillsSync(workspaceRoot, plan), + ), + ); const { reports, manifestPath, previous, stale } = prepared ?? (await prepareAgentSkillsSync(workspaceRoot, plan)); if (!plan.enabled) { @@ -125,7 +135,11 @@ export async function syncAgentSkills( linked.push(skill.destinationPath); } - await writeLinkManifest(workspaceRoot, manifestPath, plan.selected); + await writeLinkManifest( + workspaceRoot, + manifestPath, + await observedOwnedLinks(workspaceRoot, plan.selected, previous), + ); await writeMetadataFile( workspaceRoot, diff --git a/src/workspace/sync-workspace.ts b/src/workspace/sync-workspace.ts index fd437b0..6f93e53 100644 --- a/src/workspace/sync-workspace.ts +++ b/src/workspace/sync-workspace.ts @@ -15,8 +15,10 @@ import { assertLinkDirectories, assertMetadataFile, installLink, + observedOwnedLinks, readLinkManifest, usesCaseInsensitivePaths, + withWorkspaceSyncLease, writeLinkManifest, } from "./safe-links.js"; import { @@ -46,6 +48,14 @@ export interface SyncWorkspaceResult { export async function syncWorkspace( config: GhqWsConfig, +): Promise { + return withWorkspaceSyncLease(config.workspaceRoot, () => + syncWorkspaceWithLease(config), + ); +} + +async function syncWorkspaceWithLease( + config: GhqWsConfig, ): Promise { const workspaceRoot = config.workspaceRoot; const ghqRoot = config.ghqRoot; @@ -71,6 +81,10 @@ export async function syncWorkspace( destinationPath: path.join(workspaceRoot, ".ghq-sector", filename), sourcePath: "", })), + { + destinationPath: path.join(workspaceRoot, ".ghq-sector-sync.lock"), + sourcePath: "", + }, ], caseInsensitive, ); @@ -133,7 +147,11 @@ export async function syncWorkspace( }); } - await writeLinkManifest(workspaceRoot, manifestPath, links); + await writeLinkManifest( + workspaceRoot, + manifestPath, + await observedOwnedLinks(workspaceRoot, links, previous), + ); const agentSkillResult = await syncAgentSkills( workspaceRoot, agentSkillPlan, diff --git a/test/sync-races.test.ts b/test/sync-races.test.ts new file mode 100644 index 0000000..366fbef --- /dev/null +++ b/test/sync-races.test.ts @@ -0,0 +1,342 @@ +import { + lstat, + mkdir, + readdir, + readFile, + readlink, + symlink, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { syncWorkspace } from "../src/workspace/sync-workspace.js"; +import { createConfig, importFresh, makeTempRoot } from "./helpers.js"; + +async function fixture() { + const root = await makeTempRoot(); + const config = createConfig(root); + config.repos = config.repos.slice(0, 1); + const sourcePath = path.join(config.ghqRoot, "github.com", "ts-76", "life"); + const destinationPath = path.join(config.workspaceRoot, "projects", "life"); + await mkdir(sourcePath, { recursive: true }); + await mkdir(path.dirname(destinationPath), { recursive: true }); + await symlink(sourcePath, destinationPath); + return { root, config, sourcePath, destinationPath }; +} + +describe("ownership manifest consistency", () => { + it("retains A ownership while desired B is missing and updates safely once B appears", async () => { + const { config, sourcePath, destinationPath } = await fixture(); + await writeFile(path.join(sourcePath, "keep.txt"), "original source"); + await syncWorkspace(config); + const repo = config.repos[0]; + if (!repo) throw new Error("expected fixture repo"); + repo.owner = "new-owner"; + const nextSource = path.join( + config.ghqRoot, + "github.com", + "new-owner", + "life", + ); + const manifest = path.join( + config.workspaceRoot, + ".ghq-sector", + "repo-links-manifest.json", + ); + for (let repeat = 0; repeat < 2; repeat++) { + await syncWorkspace(config); + expect(await readlink(destinationPath)).toBe(sourcePath); + expect(JSON.parse(await readFile(manifest, "utf8")).links).toEqual([ + { destinationPath, sourcePath }, + ]); + } + await mkdir(nextSource, { recursive: true }); + await syncWorkspace(config); + expect(await readlink(destinationPath)).toBe(nextSource); + expect(JSON.parse(await readFile(manifest, "utf8")).links).toEqual([ + { destinationPath, sourcePath: nextSource }, + ]); + expect(await readFile(path.join(sourcePath, "keep.txt"), "utf8")).toBe( + "original source", + ); + }); + + it("does not grant ownership to missing destinations", async () => { + const root = await makeTempRoot(); + const config = createConfig(root); + await syncWorkspace(config); + const manifest = path.join( + config.workspaceRoot, + ".ghq-sector", + "repo-links-manifest.json", + ); + expect(JSON.parse(await readFile(manifest, "utf8")).links).toEqual([]); + }); +}); + +describe("case detection on the workspace volume", () => { + it.each([ + false, + true, + ])("probes a child on the target volume (caseInsensitive=%s), not its parent", async (caseInsensitive) => { + const root = await makeTempRoot(); + const workspace = path.join(root, "mounted-workspace"); + await mkdir(workspace); + let probe = ""; + const requests: string[] = []; + vi.doMock("node:fs/promises", async () => { + const actual = + await vi.importActual( + "node:fs/promises", + ); + return { + ...actual, + mkdtemp: vi.fn(async (prefix: string) => { + expect(path.dirname(prefix)).toBe(workspace); + probe = await actual.mkdtemp(prefix); + return probe; + }), + lstat: vi.fn(async (filename: string) => { + requests.push(filename); + if ( + probe && + filename === + path.join(workspace, path.basename(probe).toUpperCase()) + ) { + if (caseInsensitive) return actual.lstat(probe); + throw Object.assign(new Error("not found"), { code: "ENOENT" }); + } + return actual.lstat(filename); + }), + }; + }); + const { usesCaseInsensitivePaths } = await importFresh< + typeof import("../src/workspace/safe-links.js") + >("../src/workspace/safe-links.js"); + expect(await usesCaseInsensitivePaths(workspace)).toBe(caseInsensitive); + expect(probe).not.toBe(""); + expect( + requests.every( + (entry) => entry === workspace || path.dirname(entry) === workspace, + ), + ).toBe(true); + expect(await readdir(workspace)).toEqual([]); + }); + + it("cleans the probe when inspection fails", async () => { + const root = await makeTempRoot(); + vi.doMock("node:fs/promises", async () => { + const actual = + await vi.importActual( + "node:fs/promises", + ); + return { + ...actual, + lstat: vi.fn(async (filename: string) => { + if (path.basename(filename).startsWith(".GHQ-SECTOR-CASE-")) + throw Object.assign(new Error("probe denied"), { code: "EACCES" }); + return actual.lstat(filename); + }), + }; + }); + const { usesCaseInsensitivePaths } = await importFresh< + typeof import("../src/workspace/safe-links.js") + >("../src/workspace/safe-links.js"); + await expect(usesCaseInsensitivePaths(root)).rejects.toThrow( + "probe denied", + ); + expect(await readdir(root)).toEqual([]); + }); +}); + +describe("entry replacement races", () => { + it.each([ + "update", + "manifest", + ])("does not trust a symlink target swapped during %s validation", async (operation) => { + const { root, config, sourcePath, destinationPath } = await fixture(); + const nextSource = path.join(root, "next-source"); + const foreignSource = path.join(root, "foreign-source"); + await mkdir(nextSource); + await mkdir(foreignSource); + let reads = 0; + vi.doMock("node:fs/promises", async () => { + const actual = + await vi.importActual( + "node:fs/promises", + ); + return { + ...actual, + readlink: vi.fn(async (filename: string) => { + if ( + filename === destinationPath && + ++reads === (operation === "update" ? 3 : 2) + ) { + await actual.unlink(destinationPath); + await actual.symlink(foreignSource, destinationPath); + } + return actual.readlink(filename); + }), + }; + }); + const { installLink, observedOwnedLinks } = await importFresh< + typeof import("../src/workspace/safe-links.js") + >("../src/workspace/safe-links.js"); + const desired = { sourcePath: nextSource, destinationPath }; + const previous = [{ sourcePath, destinationPath }]; + const result = + operation === "update" + ? installLink(config.workspaceRoot, desired, previous) + : observedOwnedLinks(config.workspaceRoot, [desired], previous); + await expect(result).rejects.toThrow( + "unexpected symlink target is preserved", + ); + expect(await readlink(destinationPath)).toBe(foreignSource); + expect( + (await readdir(path.dirname(destinationPath))).some((name) => + name.startsWith(".ghq-sector-retired-"), + ), + ).toBe(false); + }); + it.each([ + "update file", + "update directory", + "cleanup file", + "cleanup directory", + "update concurrent file", + "cleanup concurrent file", + ])("preserves data during %s", async (scenario) => { + const { root, config, sourcePath, destinationPath } = await fixture(); + const nextSource = path.join(root, "next-source"); + await mkdir(nextSource); + let retained = ""; + let raced = false; + vi.doMock("node:fs/promises", async () => { + const actual = + await vi.importActual( + "node:fs/promises", + ); + return { + ...actual, + rename: vi.fn(async (from: string, to: string) => { + if (from === destinationPath && !raced) { + raced = true; + await actual.unlink(from); + if (scenario.endsWith("directory")) { + await actual.mkdir(from); + await actual.writeFile( + path.join(from, "keep.txt"), + "raced directory", + ); + } else await actual.writeFile(from, "raced file"); + await actual.rename(from, to); + retained = to; + if (scenario.includes("concurrent")) + await actual.writeFile(from, "new concurrent file"); + return; + } + return actual.rename(from, to); + }), + }; + }); + const { installLink, removeRecordedLink } = await importFresh< + typeof import("../src/workspace/safe-links.js") + >("../src/workspace/safe-links.js"); + const link = { sourcePath, destinationPath }; + const result = scenario.startsWith("update") + ? installLink(config.workspaceRoot, { ...link, sourcePath: nextSource }, [ + link, + ]) + : removeRecordedLink(config.workspaceRoot, link); + await expect(result).rejects.toThrow( + "entry changed during sync and was preserved at", + ); + expect(raced).toBe(true); + if (scenario.endsWith("directory")) { + expect((await lstat(retained)).isDirectory()).toBe(true); + expect(await readFile(path.join(retained, "keep.txt"), "utf8")).toBe( + "raced directory", + ); + expect( + await readFile(path.join(destinationPath, "keep.txt"), "utf8"), + ).toBe("raced directory"); + } else { + expect(await readFile(retained, "utf8")).toBe("raced file"); + expect(await readFile(destinationPath, "utf8")).toBe( + scenario.includes("concurrent") ? "new concurrent file" : "raced file", + ); + } + expect((await lstat(sourcePath)).isDirectory()).toBe(true); + }); + + it("retains replaced/stale links for recovery and never removes their targets", async () => { + const { root, config, sourcePath, destinationPath } = await fixture(); + const { installLink, removeRecordedLink } = await importFresh< + typeof import("../src/workspace/safe-links.js") + >("../src/workspace/safe-links.js"); + const nextSource = path.join(root, "next-source"); + await mkdir(nextSource); + await installLink( + config.workspaceRoot, + { sourcePath: nextSource, destinationPath }, + [{ sourcePath, destinationPath }], + ); + expect(await readlink(destinationPath)).toBe(nextSource); + expect( + await removeRecordedLink(config.workspaceRoot, { + sourcePath: nextSource, + destinationPath, + }), + ).toBe(true); + const backups = (await readdir(path.dirname(destinationPath))).filter( + (name) => name.startsWith(".ghq-sector-retired-"), + ); + expect(backups).toHaveLength(2); + const targets = await Promise.all( + backups.map((name) => + readlink(path.join(path.dirname(destinationPath), name, "entry")), + ), + ); + expect(targets.sort()).toEqual([sourcePath, nextSource].sort()); + expect((await lstat(sourcePath)).isDirectory()).toBe(true); + expect((await lstat(nextSource)).isDirectory()).toBe(true); + }); +}); + +describe("exclusive workspace sync lease", () => { + it("refuses a second concurrent sync and releases the lease after completion or failure", async () => { + const { config } = await fixture(); + const { withWorkspaceSyncLease } = await importFresh< + typeof import("../src/workspace/safe-links.js") + >("../src/workspace/safe-links.js"); + await withWorkspaceSyncLease(config.workspaceRoot, async () => { + await expect(syncWorkspace(config)).rejects.toThrow( + "another sync holds the workspace lease", + ); + }); + await expect( + withWorkspaceSyncLease(config.workspaceRoot, async () => { + throw new Error("fixture failure"); + }), + ).rejects.toThrow("fixture failure"); + expect( + await lstat( + path.join(config.workspaceRoot, ".ghq-sector-sync.lock"), + ).catch(() => null), + ).toBeNull(); + await syncWorkspace(config); + }); + + it("does not steal or recursively remove an existing/nonempty lease", async () => { + const { config } = await fixture(); + const lease = path.join(config.workspaceRoot, ".ghq-sector-sync.lock"); + await mkdir(lease); + await writeFile(path.join(lease, "keep.txt"), "user contents"); + await expect(syncWorkspace(config)).rejects.toThrow( + "another sync holds the workspace lease", + ); + expect(await readFile(path.join(lease, "keep.txt"), "utf8")).toBe( + "user contents", + ); + }); +}); From 908e4b9e9ab5426a8b845322c1735f0deb4651b4 Mon Sep 17 00:00:00 2001 From: ts-76 Date: Sat, 3 Oct 2026 18:56:15 +0900 Subject: [PATCH 8/9] docs: clarify managed sync lease and link recovery scope --- README.md | 4 ++-- docs/README.ja.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 68e4c23..3c866c7 100644 --- a/README.md +++ b/README.md @@ -176,9 +176,9 @@ A link already pointing to the configured source is safe to reuse, including a b When a configured repo source is missing, the ownership manifest keeps the observed managed target of any existing link; it does not grant ownership to the missing desired source. Case behavior is probed inside the workspace's volume using a temporary directory that is immediately removed. -Sync acquires an exclusive `.ghq-sector-sync.lock` directory so CLI/editor sync operations cannot overlap. A crashed operation may leave this lease; inspect it and ensure no sync is running before manually removing it. Managed link replacement and stale cleanup atomically move the old entry into a private `.ghq-sector-retired-*/entry` directory beside its original location, inspect the moved entry, and retain it as recovery data. These recovery directories are not automatically deleted. Review and remove them manually when no longer needed; do not recursively follow their symlinks or delete their targets. +Sync acquires an exclusive `.ghq-sector-sync.lock` directory so managed repository/skill-link synchronization from CLI/editor operations cannot overlap. Resource copies, generated `.code-workspace` and `AGENTS.md` updates run outside this lease. A crashed operation may leave this lease; inspect it and ensure no sync is running before manually removing it. Managed link replacement and stale cleanup atomically move the old entry into a private `.ghq-sector-retired-*/entry` directory beside its original location, inspect the moved entry, and retain it as recovery data. These recovery directories are not automatically deleted. Review and remove them manually when no longer needed; do not recursively follow their symlinks or delete their targets. A retained relative symlink keeps its original text: restore it to its original parent directory before using it, since it may resolve differently inside the recovery directory. -If another application swaps in a real file/directory at the instant of a link update, Sync preserves the moved entry, stops, and reports its recovery path. It restores file access with an exclusive hardlink or directory access with a recovery symlink when the destination is still free; an entry newly created at the destination is left untouched. The lease coordinates ghq-sector operations, not arbitrary filesystem writers. Keep external writers from renaming workspace parent directories during Sync; this is not a cross-process transaction or a guarantee against a process replacing ancestor directories concurrently. +If another application swaps in a real file/directory at the instant of a link update, Sync preserves the moved entry, stops, and reports its recovery path. It restores file access with an exclusive hardlink or directory access with a recovery symlink when the destination is still free; an entry newly created at the destination is left untouched. The lease coordinates ghq-sector managed-link synchronization, not arbitrary filesystem writers. Keep external writers from renaming workspace parent directories during Sync; this is not a cross-process transaction or a guarantee against a process replacing ancestor directories concurrently. ### `gsec apply` diff --git a/docs/README.ja.md b/docs/README.ja.md index e90c460..54bc739 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -162,9 +162,9 @@ Sync は repository と skill のリンク計画全体を、リンク変更前 設定した repo の source が存在しない場合、既存の管理リンクが実際に指す source を manifest に保持します。存在しない変更予定の source に管理権限を与える記録は作りません。大文字・小文字の区別は workspace のボリューム内に一時ディレクトリを作って検査し、すぐに片付けます。 -Sync は `.ghq-sector-sync.lock` ディレクトリで排他制御し、CLI と編集 UI の Sync が同時に進行することを防ぎます。異常終了後に残った場合は、実行中の Sync がないことと内容を確認してから手動で取り除いてください。管理リンクの置換と stale cleanup は、元のエントリーを同じ親ディレクトリ内の非公開な `.ghq-sector-retired-*/entry` へ原子的に移し、その実体を確認したうえで復旧用に保持します。退避ディレクトリは自動削除しません。不要になったら手動で確認・削除してください。symlink のリンク先を再帰的に辿ったり削除したりしないでください。 +Sync は `.ghq-sector-sync.lock` ディレクトリで排他制御し、CLI と編集 UI の repository / skill 管理リンク同期が同時に進行することを防ぎます。resources のコピー、`.code-workspace` と `AGENTS.md` の生成はこの排他制御の対象外です。異常終了後に残った場合は、実行中の Sync がないことと内容を確認してから手動で取り除いてください。管理リンクの置換と stale cleanup は、元のエントリーを同じ親ディレクトリ内の非公開な `.ghq-sector-retired-*/entry` へ原子的に移し、その実体を確認したうえで復旧用に保持します。退避ディレクトリは自動削除しません。不要になったら手動で確認・削除してください。symlink のリンク先を再帰的に辿ったり削除したりしないでください。退避された相対 symlink は元のリンク文字列を保持しており、退避先では別のパスに解決される可能性があります。使用前に元の親ディレクトリへ戻してください。 -リンク更新の瞬間に他のアプリが実ファイル・ディレクトリへ差し替えた場合も、移された実体を保持して Sync を中断し、復旧先を表示します。宛先が空いていれば、ファイルは排他的な hardlink、ディレクトリは復旧先への symlink でアクセスを戻します。別のエントリーが新たに作られていれば上書きしません。排他制御の対象は ghq-sector の操作です。外部アプリによる親ディレクトリの変更までは制御できないため、Sync 中は workspace の親ディレクトリを移動しないでください。複数プロセスにまたがるトランザクションや、親ディレクトリを同時に差し替えるプロセスに対する完全な保証は提供しません。 +リンク更新の瞬間に他のアプリが実ファイル・ディレクトリへ差し替えた場合も、移された実体を保持して Sync を中断し、復旧先を表示します。宛先が空いていれば、ファイルは排他的な hardlink、ディレクトリは復旧先への symlink でアクセスを戻します。別のエントリーが新たに作られていれば上書きしません。排他制御の対象は ghq-sector の管理リンク同期です。外部アプリによる親ディレクトリの変更までは制御できないため、Sync 中は workspace の親ディレクトリを移動しないでください。複数プロセスにまたがるトランザクションや、親ディレクトリを同時に差し替えるプロセスに対する完全な保証は提供しません。 ### `gsec apply` From 7adfe509cee1f14602849d7fbfb6d3e7a604e3d9 Mon Sep 17 00:00:00 2001 From: ts-76 Date: Sat, 3 Oct 2026 20:01:41 +0900 Subject: [PATCH 9/9] style: align API tests with audited Biome --- test/edit-server.test.ts | 171 ++++++++++++++++++++------------------- 1 file changed, 90 insertions(+), 81 deletions(-) diff --git a/test/edit-server.test.ts b/test/edit-server.test.ts index 35b5194..d9fb371 100644 --- a/test/edit-server.test.ts +++ b/test/edit-server.test.ts @@ -130,36 +130,35 @@ function deferred() { } describe("local editor HTTP boundaries", () => { - it.each([ - "127.0.0.1", - "localhost", - "::1", - ])("supports legitimate reads, Save, Preview and Apply on %s", async (host) => { - const f = await fixture(host); - expect( - ( - await request(f.origin, "/api/config", { - headers: { Origin: f.origin }, - }) - ).status, - ).toBe(200); - const draft = { ...f.config, categories: ["draft"] }; - expect((await request(f.origin, "/api/config", json(draft))).status).toBe( - 200, - ); - expect( - (await request(f.origin, "/api/preview", json(draft, "POST"))).status, - ).toBe(200); - expect( - (await request(f.origin, "/api/apply", json(draft, "POST"))).status, - ).toBe(200); - expect(JSON.parse(await readFile(f.configPath, "utf8")).categories).toEqual( - ["draft"], - ); - expect( - await readFile(path.join(f.config.workspaceRoot, "AGENTS.md"), "utf8"), - ).toContain("ghq-sector"); - }); + it.each(["127.0.0.1", "localhost", "::1"])( + "supports legitimate reads, Save, Preview and Apply on %s", + async (host) => { + const f = await fixture(host); + expect( + ( + await request(f.origin, "/api/config", { + headers: { Origin: f.origin }, + }) + ).status, + ).toBe(200); + const draft = { ...f.config, categories: ["draft"] }; + expect((await request(f.origin, "/api/config", json(draft))).status).toBe( + 200, + ); + expect( + (await request(f.origin, "/api/preview", json(draft, "POST"))).status, + ).toBe(200); + expect( + (await request(f.origin, "/api/apply", json(draft, "POST"))).status, + ).toBe(200); + expect( + JSON.parse(await readFile(f.configPath, "utf8")).categories, + ).toEqual(["draft"]); + expect( + await readFile(path.join(f.config.workspaceRoot, "AGENTS.md"), "utf8"), + ).toContain("ghq-sector"); + }, + ); it("prevents UI framing on assets and API success/error responses", async () => { const f = await fixture(); @@ -176,39 +175,46 @@ describe("local editor HTTP boundaries", () => { } }); - it.each([ - "template", - "selected GitHub repo", - ])("uses the bundled UI proposal contract for %s without saving the draft", async (action) => { - const f = await fixture(); - const draft = { - ...f.config, - categories: ["unsaved"], - defaults: { category: "unsaved", owner: "draft-owner" }, - }; - const repo = { - provider: "github.com", - owner: "draft-owner", - name: action === "template" ? "" : "chosen", - category: "unsaved", - }; - const send: typeof fetch = (input, init) => - fetch(new URL(String(input), f.origin), { - ...init, - headers: { ...init?.headers, Origin: f.origin }, + it.each(["template", "selected GitHub repo"])( + "uses the bundled UI proposal contract for %s without saving the draft", + async (action) => { + const f = await fixture(); + const draft = { + ...f.config, + categories: ["unsaved"], + defaults: { category: "unsaved", owner: "draft-owner" }, + }; + const repo = { + provider: "github.com", + owner: "draft-owner", + name: action === "template" ? "" : "chosen", + category: "unsaved", + }; + const send: typeof fetch = (input, init) => + fetch(new URL(String(input), f.origin), { + ...init, + headers: { ...init?.headers, Origin: f.origin }, + }); + const nextDraft = await requestRepoProposal( + draft, + action === "template" ? undefined : repo, + send, + ); + expect(nextDraft).toMatchObject({ + categories: ["unsaved"], + repos: [repo], }); - const nextDraft = await requestRepoProposal( - draft, - action === "template" ? undefined : repo, - send, - ); - expect(nextDraft).toMatchObject({ categories: ["unsaved"], repos: [repo] }); - expect(JSON.parse(await readFile(f.configPath, "utf8"))).toEqual(f.config); - expect( - (await request(f.origin, "/api/config", json(nextDraft))).status, - ).toBe(200); - expect(JSON.parse(await readFile(f.configPath, "utf8"))).toEqual(nextDraft); - }); + expect(JSON.parse(await readFile(f.configPath, "utf8"))).toEqual( + f.config, + ); + expect( + (await request(f.origin, "/api/config", json(nextDraft))).status, + ).toBe(200); + expect(JSON.parse(await readFile(f.configPath, "utf8"))).toEqual( + nextDraft, + ); + }, + ); it("rejects public binds before loading config or building UI", async () => { const { runEdit, createEditServer } = await import( @@ -298,25 +304,28 @@ describe("local editor HTTP boundaries", () => { ["schema mismatch", '{"ghqRoot":42}', "application/json", 400], ["form body", "a=b", "application/x-www-form-urlencoded", 415], ["plain body", "{}", "text/plain", 415], - ])("returns controlled errors for %s without changing config", async (_label, body, type, status) => { - const f = await fixture(); - const original = await readFile(f.configPath, "utf8"); - for (const [route, method] of [ - ["/api/config", "PUT"], - ["/api/preview", "POST"], - ["/api/apply", "POST"], - ["/api/repos", "POST"], - ]) { - const result = await request(f.origin, route, { - method, - body: String(body), - headers: { "Content-Type": String(type) }, - }); - expect(result.status).toBe(status); - expect(result.body.ok).toBe(false); - } - expect(await readFile(f.configPath, "utf8")).toBe(original); - }); + ])( + "returns controlled errors for %s without changing config", + async (_label, body, type, status) => { + const f = await fixture(); + const original = await readFile(f.configPath, "utf8"); + for (const [route, method] of [ + ["/api/config", "PUT"], + ["/api/preview", "POST"], + ["/api/apply", "POST"], + ["/api/repos", "POST"], + ]) { + const result = await request(f.origin, route, { + method, + body: String(body), + headers: { "Content-Type": String(type) }, + }); + expect(result.status).toBe(status); + expect(result.body.ok).toBe(false); + } + expect(await readFile(f.configPath, "utf8")).toBe(original); + }, + ); it("rejects malformed UTF-8 and missing Content-Type", async () => { const f = await fixture();