From 6922cc52f8d648ec44b3c0c0881d0070fe9223a3 Mon Sep 17 00:00:00 2001 From: ToussaintKnight Date: Sun, 12 Jul 2026 19:20:22 +0800 Subject: [PATCH] fix(projects): sync project tasks and paths Select project-scoped tasks, publish portable project catalogs, and map imported task roots across Windows and macOS.\n\nRefs #5 --- CHANGELOG.md | 4 +- CHANGELOG.zh-CN.md | 4 +- README.md | 4 +- README.zh-CN.md | 4 +- SKILL.md | 17 ++-- references/protocol.md | 4 +- references/usage.md | 21 ++++- scripts/sync2.mjs | 186 ++++++++++++++++++++++++++++++++++++++--- scripts/test-sync2.mjs | 15 +++- 9 files changed, 234 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7536d7e..ae0b2e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,9 @@ All notable changes are documented here. ## Unreleased -- No unreleased changes. +- Fix project synchronization so selecting a project also selects its non-archived Codex tasks instead of producing a project with `No tasks` on the target device. +- Add project discovery, per-device portable project catalogs, target-side folder acceptance, Codex desktop registration, and explicit path-mapping commands. +- Make Windows/macOS project path mapping separator-aware and path-boundary-aware. ## 0.2.0 - 2026-07-12 diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 1d1213d..d7bc3ee 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -6,7 +6,9 @@ ## 未发布 -- 暂无未发布变更。 +- 修复项目同步:选择项目时会一并选择其非归档 Codex 任务,避免目标设备只出现项目却显示 `No tasks`。 +- 新增项目发现、每设备可移植项目目录、目标端文件夹接收、Codex 桌面注册与显式路径映射命令。 +- Windows/macOS 项目路径映射现在可识别不同分隔符,并严格检查路径边界。 ## 0.2.0 - 2026-07-12 diff --git a/README.md b/README.md index ad4a958..dbef942 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Sync2 adds that missing semantic layer: - quarantine incomplete or divergent history instead of inventing a merge; - synchronize personal skill collections with three-way hashes and preserved conflict copies; - update local Codex indexes without copying another device's database. +- discover Codex Projects, select their tasks as a group, and map project roots across Windows and macOS. ## Quick start @@ -77,7 +78,7 @@ Syncthing or private Git transports bytes. Sync2 decides which bytes are a safe npm run check ``` -The deterministic suite runs 65 checks across folder and Git transports, interrupted-event recovery, legacy abort repair, stable active checkpoints, conflict preservation, Windows extended paths, desktop catalog updates, maintenance mode, device reports, and skill reconciliation. +The deterministic suite runs 70 checks across folder and Git transports, project discovery and task selection, cross-platform project path mapping, interrupted-event recovery, legacy abort repair, stable active checkpoints, conflict preservation, Windows extended paths, desktop catalog updates, maintenance mode, device reports, and skill reconciliation. GitHub Actions runs the same privacy gate and tests on Windows, macOS, and Linux with Node.js 22. The fixtures use isolated temporary Codex homes and synthetic conversations; they never read the user's real vault. @@ -89,6 +90,7 @@ Included: - portable task metadata; - user-defined skill files; - device selection events and health reports. +- per-device project catalogs and explicit source-to-local path mappings. Excluded: diff --git a/README.zh-CN.md b/README.zh-CN.md index 442d232..422678a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -29,6 +29,7 @@ Sync2 补上了这一层语义安全能力: - 隔离不完整或分叉历史,而不是虚构合并结果; - 通过三方哈希同步个人 Skill,并保留冲突副本; - 更新本地 Codex 索引,而不是复制其他设备的数据库。 +- 发现 Codex Projects、按项目批量选择任务,并映射 Windows 与 macOS 的不同项目路径。 ## 快速开始 @@ -77,7 +78,7 @@ Syncthing 或私有 Git 负责传输字节;Sync2 负责判断哪些字节构 npm run check ``` -确定性测试套件包含 65 项检查,覆盖文件夹与 Git 传输、中断事件恢复、旧式 abort 修复、活动回合稳定检查点、冲突保留、Windows 扩展路径、桌面目录更新、维护模式、设备报告和 Skill 同步。 +确定性测试套件包含 70 项检查,覆盖文件夹与 Git 传输、项目发现与任务选择、跨平台项目路径映射、中断事件恢复、旧式 abort 修复、活动回合稳定检查点、冲突保留、Windows 扩展路径、桌面目录更新、维护模式、设备报告和 Skill 同步。 GitHub Actions 在 Windows、macOS 和 Linux 的 Node.js 22 环境中运行同一套隐私 gate 与测试。测试使用隔离的临时 Codex home 和合成会话,不读取用户的真实 vault。 @@ -89,6 +90,7 @@ GitHub Actions 在 Windows、macOS 和 Linux 的 Node.js 22 环境中运行同 - 可移植任务元数据; - 用户自定义 Skill 文件; - 设备选择事件与健康报告。 +- 每设备项目目录与显式的源路径到本地路径映射。 排除: diff --git a/SKILL.md b/SKILL.md index 2ea9eba..c159824 100644 --- a/SKILL.md +++ b/SKILL.md @@ -34,6 +34,7 @@ Map requests as follows: - `/sync2 project current` -> run `project add current` from the project root - `/sync2 project ` -> `project add ` - `/sync2 projects` -> `project list` +- `/sync2 project discover` -> `project discover` to list Codex Projects and their local tasks - `/sync2 devices` -> `device list` Always quote paths. Use `--config ` when operating a test or non-default profile. @@ -54,13 +55,15 @@ Never initialize a public Git repository for conversation data. Explain that vau Manage project folders through the background CLI/API; do not open the Syncthing UI unless the user explicitly asks for visible inspection. 1. Run `syncthing configure --syncthing-exe ` once when automatic detection fails. -2. From a newly created project root, run `project add current`. The command creates one independent Syncthing folder entry pointing directly at that project; it never copies the project into another Syncthing directory. -3. Share with all paired remote devices by default. Use `--share-with ` or `--share-with none` for a narrower set. Add repeated `--ignore ` arguments when generated caches or large local-only paths must stay out. -4. Reject parent/child overlap with an existing Syncthing folder. Keep each selected project and the Sync2 conversation vault as sibling, independent folder entries. -5. Verify with `project status ` and use `project rescan ` only when the filesystem watcher has not noticed a change. -6. Run `project add` on one originating device only. On other devices, accept the Syncthing folder offer and choose the corresponding local path; do not independently generate a second folder ID. -7. Pair a new machine with `syncthing add-device --device-id --name `. Projects registered with the default `sharePolicy=all` are shared to newly paired remote devices by the next scheduled `sync` run. -8. Use `device report` on a newly bootstrapped machine. Scheduled sync also publishes a content-stable device report so another device can verify local conversation indexes, skill counts, and daemon installation without copying terminal output. +2. Run `project discover` to show the project roots currently known to the Codex desktop app and the non-archived local tasks whose `cwd` belongs to each root. +3. From an originating project root, run `project add current`. The command creates one independent Syncthing folder entry pointing directly at that project and selects every non-archived Codex task under that root. It never copies the project into another Syncthing directory. Use `--no-tasks` only when project files should be shared without task history. +4. Share with all paired remote devices by default. Use `--share-with ` or `--share-with none` for a narrower set. Add repeated `--ignore ` arguments when generated caches or large local-only paths must stay out. +5. Reject parent/child overlap with an existing Syncthing folder. Keep each selected project and the Sync2 conversation vault as sibling, independent folder entries. +6. Run `sync` on the source so `.sync2/project-catalogs/.json` advertises the portable folder ID, source path, and selected tasks. +7. On a target device run `project catalogs`, then `project accept --path `. This preserves the source folder ID, adds a source-to-local path map, and registers the local root in Codex via its supported `codex://new?path=...` deep link. Use `--no-register` only when UI registration is intentionally deferred. Do not independently generate a second folder ID. +8. Verify with `project status ` and use `project rescan ` only when the filesystem watcher has not noticed a change. If only task selection needs repair, run `project tasks `; if only paths need repair, run `project map --from --to `. +9. Pair a new machine with `syncthing add-device --device-id --name `. Projects registered with the default `sharePolicy=all` are shared to newly paired remote devices by the next scheduled `sync` run. +10. Use `device report` on a newly bootstrapped machine. Scheduled sync also publishes a content-stable device report so another device can verify local conversation indexes, project catalogs, skill counts, and daemon installation without copying terminal output. ## Safety behavior diff --git a/references/protocol.md b/references/protocol.md index 6288e82..c6862fd 100644 --- a/references/protocol.md +++ b/references/protocol.md @@ -108,7 +108,9 @@ Conversation/skill vault synchronization works whenever an external tool keeps t The executable is required for `project`, `syncthing add-device`, rescan, and automatic project sharing. Store its device-specific executable path only in local config. -Register each project as a sibling independent Syncthing folder pointing at its real path. Reject parent/child overlaps. Generate the folder ID once on the originating device; other devices accept the offered ID and choose local paths. +Register each project as a sibling independent Syncthing folder pointing at its real path. Reject parent/child overlaps. Generate the folder ID once on the originating device; other devices accept the advertised ID and choose local paths. + +Project selection is also a conversation-selection boundary: `project add` selects each non-archived local thread whose `cwd` is the project root or a descendant. Per-device files under `.sync2/project-catalogs/` advertise the portable folder ID, local source path, Syncthing device ID, and task list without creating a shared mutable manifest. A target acceptance records an explicit source-to-local path map before importing tasks and registers the local root with Codex. Path matching is separator-aware and boundary-aware so `F:\Projects\MUON` maps safely to `/Volumes/Lab/MUON` without also matching a sibling such as `MUON-old`. Transport-level `.sync-conflict-*` files indicate Syncthing observed concurrent changes outside a single reconciled view. Preserve them and resolve manually before resuming automatic sync. diff --git a/references/usage.md b/references/usage.md index 0695d15..8edca81 100644 --- a/references/usage.md +++ b/references/usage.md @@ -25,7 +25,8 @@ Use `$sync2` for native Codex skill invocation. `/sync2` is a conversational ali | `$sync2 audit ` | Check JSONL syntax, tool-call/output pairs, turn closure, and local index agreement. | | `$sync2 repair ` | Back up and repair interrupted tool events and stale older turns without inventing output. | | `$sync2 auto` | Install or refresh the local automatic task. | -| `$sync2 project current` | Register the current project as an independent Syncthing folder. | +| `$sync2 project discover` | List Codex desktop projects and the local tasks grouped under each root. | +| `$sync2 project current` | Register the current project and select every non-archived task under it. | | `$sync2 projects` | List project folder IDs and their real local paths. | ## Daily workflows @@ -60,13 +61,29 @@ Use stable collection names across devices. Keep incompatible agent ecosystems i ### Register a project +First inspect the roots and task grouping known to Codex: + +```text +node /scripts/sync2.mjs project discover +``` + Run this once on the originating device from the actual project root: ```text $sync2 project current ``` -Accept the resulting Syncthing folder offer on other devices and choose each device's desired local checkout path. Do not independently create a second folder ID for the same project. +`project add` also selects every non-archived task whose `cwd` belongs to the project root. Run `sync` so the portable project catalog and those stable task histories reach the vault. + +On another device, inspect and accept the advertised folder ID: + +```text +node /scripts/sync2.mjs project catalogs +node /scripts/sync2.mjs project accept --path "" +node /scripts/sync2.mjs sync +``` + +`project accept` preserves the source Syncthing folder ID, maps the source path to the target path for imported task metadata, and registers the local root in the Codex desktop Projects list. Do not independently create a second folder ID for the same project. ### Verify health diff --git a/scripts/sync2.mjs b/scripts/sync2.mjs index 9e38322..0409888 100644 --- a/scripts/sync2.mjs +++ b/scripts/sync2.mjs @@ -50,7 +50,7 @@ function parseArgs(argv) { continue; } const [rawKey, inline] = value.slice(2).split(/=(.*)/s, 2); - if (["json", "dry-run", "quiet", "force", "no-sync", "help", "all"].includes(rawKey)) { + if (["json", "dry-run", "quiet", "force", "no-sync", "help", "all", "no-tasks", "register", "no-register"].includes(rawKey)) { options[rawKey] = inline === undefined ? true : inline !== "false"; continue; } @@ -430,9 +430,15 @@ function resolveThread(config, selector) { function applyPathMaps(value, maps = []) { if (!value) return value; - for (const mapping of maps) { - const from = String(mapping.from); - if (value.toLowerCase().startsWith(from.toLowerCase())) return `${mapping.to}${value.slice(from.length)}`; + const portableValue = stripWindowsExtendedPrefix(String(value)).replaceAll("\\", "/").replace(/\/+$/, ""); + const ordered = [...maps].sort((a, b) => String(b.from).length - String(a.from).length); + for (const mapping of ordered) { + const from = stripWindowsExtendedPrefix(String(mapping.from)).replaceAll("\\", "/").replace(/\/+$/, ""); + const lowerValue = portableValue.toLowerCase(); + const lowerFrom = from.toLowerCase(); + if (lowerValue !== lowerFrom && !lowerValue.startsWith(`${lowerFrom}/`)) continue; + const suffix = portableValue.slice(from.length).replace(/^\/+/, ""); + return suffix ? path.join(expandPath(String(mapping.to)), ...suffix.split("/")) : expandPath(String(mapping.to)); } return value; } @@ -678,17 +684,144 @@ function resolveProjectFolder(inventory, selector) { return matches[0]; } +function projectThreads(config, projectPath) { + return listThreads(config.codexHome).filter((thread) => { + if (Number(thread.archived ?? 0) !== 0 || !thread.cwd) return false; + const localAbsolute = process.platform === "win32" + ? path.win32.isAbsolute(stripWindowsExtendedPrefix(thread.cwd)) + : path.posix.isAbsolute(String(thread.cwd)); + if (!localAbsolute) return false; + try { return pathContains(projectPath, thread.cwd); } catch { return false; } + }); +} + +function selectProjectThreads(config, projectPath, selected = true) { + const changedAt = nowIso(); + const threads = projectThreads(config, projectPath); + for (const thread of threads) { + config.conversations[thread.id] = { + selected, + updatedAt: changedAt, + title: thread.title ?? thread.thread_name ?? thread.id, + deviceId: config.deviceId, + projectPath: path.resolve(projectPath), + }; + } + return threads.map((thread) => ({ id: thread.id, title: thread.title ?? thread.thread_name ?? thread.id, cwd: thread.cwd })); +} + +function codexProjectRoots(config) { + const stateFile = path.join(config.codexHome, ".codex-global-state.json"); + const state = fs.existsSync(stateFile) ? readJson(stateFile, {}) : {}; + const configuredRoots = [ + ...(Array.isArray(state["electron-saved-workspace-roots"]) ? state["electron-saved-workspace-roots"] : []), + ...(Array.isArray(state["active-workspace-roots"]) ? state["active-workspace-roots"] : []), + ]; + const roots = []; + const add = (candidate, origin) => { + if (!candidate) return; + const localAbsolute = process.platform === "win32" + ? path.win32.isAbsolute(stripWindowsExtendedPrefix(candidate)) + : path.posix.isAbsolute(String(candidate)); + if (!localAbsolute) return; + let resolved; + try { resolved = expandPath(String(candidate)); } catch { return; } + if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) return; + if (roots.some((item) => normalizedPath(item.path) === normalizedPath(resolved))) return; + if (origin === "thread-cwd" && roots.some((item) => pathContains(item.path, resolved))) return; + roots.push({ path: resolved, label: path.basename(resolved), origin }); + }; + for (const root of configuredRoots) add(root, "codex-projects"); + for (const thread of listThreads(config.codexHome)) add(thread.cwd, "thread-cwd"); + return roots.map((root) => ({ ...root, tasks: projectThreads(config, root.path).map((thread) => ({ id: thread.id, title: thread.title ?? thread.thread_name ?? thread.id, cwd: thread.cwd })) })); +} + +function registerCodexProject(projectPath, dryRun = false) { + const resolved = path.resolve(projectPath); + const url = `codex://new?path=${encodeURIComponent(resolved)}`; + if (dryRun) return { registered: false, dryRun: true, url }; + let result; + if (process.platform === "darwin") result = run("open", [url], { allowFailure: true }); + else if (process.platform === "win32") result = run("cmd.exe", ["/d", "/s", "/c", "start", "", url], { allowFailure: true }); + else return { registered: false, unsupported: true, url }; + if (result.status !== 0) throw new Sync2Error(`Codex project registration failed: ${(result.stderr || result.stdout).trim()}`); + return { registered: true, url }; +} + +function projectCatalogDir(config) { + return path.join(config.vault, ".sync2", "project-catalogs"); +} + +function publishProjectCatalog(config, dryRun = false) { + let syncthingDeviceId = null; + try { + if (config.syncthing) syncthingDeviceId = syncthingInventory(findSyncthing(config)).myID; + } catch { /* A catalog remains useful when Syncthing is temporarily unavailable. */ } + const discovered = codexProjectRoots(config); + const configured = Object.values(config.projects ?? {}).map((project) => { + const candidate = discovered.find((item) => normalizedPath(item.path) === normalizedPath(project.path)); + return { ...project, tasks: candidate?.tasks ?? projectThreads(config, project.path).map((thread) => ({ id: thread.id, title: thread.title ?? thread.thread_name ?? thread.id, cwd: thread.cwd })) }; + }); + const configuredPaths = new Set(configured.map((item) => normalizedPath(item.path))); + const projects = [...configured, ...discovered.filter((item) => !configuredPaths.has(normalizedPath(item.path))).map((item) => ({ ...item, configured: false }))]; + const catalog = { version: VERSION, protocolRevision: PROTOCOL_REVISION, deviceId: config.deviceId, syncthingDeviceId, updatedAt: nowIso(), projects }; + writeJson(path.join(projectCatalogDir(config), `${config.deviceId}.json`), catalog, dryRun); + return catalog; +} + +function listProjectCatalogs(config) { + const dir = projectCatalogDir(config); + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.endsWith(".json")) + .map((entry) => readJson(path.join(dir, entry.name))); +} + +function addPathMap(config, from, to) { + config.pathMaps ??= []; + const normalizedFrom = stripWindowsExtendedPrefix(String(from)).replaceAll("\\", "/").replace(/\/+$/, "").toLowerCase(); + config.pathMaps = config.pathMaps.filter((mapping) => stripWindowsExtendedPrefix(String(mapping.from)).replaceAll("\\", "/").replace(/\/+$/, "").toLowerCase() !== normalizedFrom); + config.pathMaps.push({ from: String(from), to: path.resolve(to) }); +} + async function projectCommand(options, action, rest) { const { config, file } = loadConfig(options); + config.projects ??= {}; + if (action === "discover") { + const projects = codexProjectRoots(config).map((project) => { + const registered = Object.values(config.projects).find((item) => normalizedPath(item.path) === normalizedPath(project.path)); + return { ...project, registered: registered ? { id: registered.id, sharePolicy: registered.sharePolicy } : null }; + }); + return { stateFile: path.join(config.codexHome, ".codex-global-state.json"), projects }; + } + if (action === "catalogs") return { catalogs: listProjectCatalogs(config) }; + if (action === "tasks") { + const selector = rest.join(" ") || options.path || "current"; + const projectPath = selector === "current" ? process.cwd() : expandPath(selector); + if (!fs.existsSync(projectPath) || !fs.statSync(projectPath).isDirectory()) throw new Sync2Error(`Project directory does not exist: ${projectPath}`); + const selectedTasks = selectProjectThreads(config, projectPath, true); + saveConfig(file, config, Boolean(options["dry-run"])); + return { action: "project-tasks-selected", path: path.resolve(projectPath), selectedTasks, count: selectedTasks.length }; + } + if (action === "map") { + if (!options.from || !options.to) throw new Sync2Error("project map requires --from --to "); + addPathMap(config, options.from, expandPath(options.to)); + saveConfig(file, config, Boolean(options["dry-run"])); + return { action: "project-path-mapped", pathMaps: config.pathMaps }; + } + if (action === "register") { + const selector = rest.join(" ") || options.path || "current"; + const projectPath = selector === "current" ? process.cwd() : expandPath(selector); + if (!fs.existsSync(projectPath) || !fs.statSync(projectPath).isDirectory()) throw new Sync2Error(`Project directory does not exist: ${projectPath}`); + return { action: "registered-in-codex", path: path.resolve(projectPath), ...registerCodexProject(projectPath, Boolean(options["dry-run"])) }; + } const executable = findSyncthing(config, options); config.syncthing = { ...(config.syncthing ?? {}), executable }; - config.projects ??= {}; const inventory = syncthingInventory(executable); if (action === "list") { saveConfig(file, config); return { executable, devices: inventory.devices, folders: inventory.folders.map((folder) => ({ id: folder.id, label: folder.label, path: folder.path, type: folder.type, devices: folder.devices.map((item) => item.deviceID) })) }; } - if (action === "add") { + if (action === "add" || action === "select") { const selector = rest.join(" ") || options.path || "current"; const projectPath = selector === "current" ? process.cwd() : expandPath(selector); if (!fs.existsSync(projectPath) || !fs.statSync(projectPath).isDirectory()) throw new Sync2Error(`Project directory does not exist: ${projectPath}`); @@ -724,9 +857,39 @@ async function projectCommand(options, action, rest) { if (options.ignore?.length && !options["dry-run"]) { await syncthingRest(config, `/db/ignores?folder=${encodeURIComponent(folder.id)}`, { method: "POST", body: { ignore: ignorePatterns } }); } - config.projects[folder.id] = { id: folder.id, label: folder.label ?? label, path: path.resolve(projectPath), devices: [...new Set([...existingDevices, ...devices.map((item) => item.id)])], sharePolicy, ignorePatterns, addedAt: config.projects[folder.id]?.addedAt ?? nowIso(), updatedAt: nowIso() }; + const selectedTasks = options["no-tasks"] ? [] : selectProjectThreads(config, projectPath, true); + config.projects[folder.id] = { id: folder.id, label: folder.label ?? label, path: path.resolve(projectPath), devices: [...new Set([...existingDevices, ...devices.map((item) => item.id)])], sharePolicy, ignorePatterns, selectedTaskIds: selectedTasks.map((item) => item.id), addedAt: config.projects[folder.id]?.addedAt ?? nowIso(), updatedAt: nowIso() }; + saveConfig(file, config, Boolean(options["dry-run"])); + return { action: created ? "created" : "already-configured", dryRun: Boolean(options["dry-run"]), folder: config.projects[folder.id], selectedTasks, sharedWith: devices, newlySharedWith: addedDevices }; + } + if (action === "accept") { + const id = rest[0] || options.id; + const localPath = expandPath(options.path ?? rest.slice(1).join(" ")); + if (!id || !localPath) throw new Sync2Error("project accept requires --path "); + const catalogs = listProjectCatalogs(config); + const sources = catalogs.flatMap((catalog) => (catalog.projects ?? []).filter((project) => project.id === id).map((project) => ({ catalog, project }))); + if (!sources.length) throw new Sync2Error(`No portable project catalog advertises folder ID: ${id}`); + const source = sources.sort((a, b) => String(b.catalog.updatedAt).localeCompare(String(a.catalog.updatedAt)))[0]; + mkdir(localPath, Boolean(options["dry-run"])); + let folder = inventory.folders.find((item) => item.id === id); + if (folder && normalizedPath(folder.path) !== normalizedPath(localPath)) throw new Sync2Error(`Syncthing folder ${id} already uses another path: ${folder.path}`); + if (!folder) { + if (!options["dry-run"]) stCli(executable, ["config", "folders", "add", "--id", id, "--label", source.project.label ?? id, "--path", path.resolve(localPath), "--type", "sendreceive"]); + folder = { id, label: source.project.label ?? id, path: path.resolve(localPath), type: "sendreceive", devices: [{ deviceID: inventory.myID }] }; + } + const existingDevices = new Set((folder.devices ?? []).map((item) => item.deviceID)); + for (const item of sources) { + const remoteId = item.catalog.syncthingDeviceId; + if (!remoteId || remoteId === inventory.myID || existingDevices.has(remoteId)) continue; + if (!inventory.devices.some((device) => device.id === remoteId)) continue; + if (!options["dry-run"]) stCli(executable, ["config", "folders", id, "devices", "add", "--device-id", remoteId]); + existingDevices.add(remoteId); + } + for (const item of sources) addPathMap(config, item.project.path, localPath); + config.projects[id] = { id, label: source.project.label ?? id, path: path.resolve(localPath), devices: [...existingDevices], sharePolicy: "selected", ignorePatterns: source.project.ignorePatterns ?? [], sourceDevices: sources.map((item) => item.catalog.deviceId), addedAt: config.projects[id]?.addedAt ?? nowIso(), updatedAt: nowIso() }; + const registration = options["no-register"] ? { registered: false, skipped: true } : registerCodexProject(localPath, Boolean(options["dry-run"])); saveConfig(file, config, Boolean(options["dry-run"])); - return { action: created ? "created" : "already-configured", dryRun: Boolean(options["dry-run"]), folder: config.projects[folder.id], sharedWith: devices, newlySharedWith: addedDevices }; + return { action: "accepted", dryRun: Boolean(options["dry-run"]), folder: config.projects[id], pathMaps: config.pathMaps, registration }; } const selector = rest.join(" ") || options.id || options.path; if (action === "remove") { @@ -804,7 +967,7 @@ function gitPost(config, dryRun) { function ensureVault(config, dryRun = false) { mkdir(config.vault, dryRun); - for (const relative of [".sync2/selections", "conversations", "skills", "conflicts/skills"]) mkdir(path.join(config.vault, relative), dryRun); + for (const relative of [".sync2/selections", ".sync2/project-catalogs", "conversations", "skills", "conflicts/skills"]) mkdir(path.join(config.vault, relative), dryRun); const marker = path.join(config.vault, ".sync2", "vault.json"); const existing = fs.existsSync(marker) ? readJson(marker) : null; if (existing && existing.version !== VERSION) throw new Sync2Error(`Vault protocol version ${existing.version} is unsupported.`); @@ -1255,6 +1418,7 @@ function buildDeviceReport(config, lastRunOverride = undefined) { vault: config.vault, selectedConversations, skillSources, + projects: Object.values(config.projects ?? {}).map((project) => ({ id: project.id, label: project.label, path: project.path, selectedTaskIds: project.selectedTaskIds ?? [] })), daemon: daemonStatus(config, config._configFile), maintenance: { enabled: fs.existsSync(maintenanceFile(config)) }, lastRun: lastRunOverride === undefined ? readJson(lastRunFile(config), null) : lastRunOverride, @@ -1317,6 +1481,7 @@ function syncCommand(options, direction = "sync") { withVaultLock(config, dryRun, () => { syncConversations(config, direction, dryRun, summary); syncSkills(config, direction, dryRun, summary); + publishProjectCatalog(config, dryRun); reconcileProjectShares(config, dryRun, summary); const successfulRun = { ok: true, startedAt, finishedAt: nowIso(), summary }; recordLastRun(config, successfulRun, dryRun); @@ -1714,6 +1879,7 @@ function statusCommand(options) { selectedConversations: selected, skillSources: config.skillSources, projects: Object.values(config.projects ?? {}), + projectCatalogs: listProjectCatalogs(config).map((catalog) => ({ deviceId: catalog.deviceId, syncthingDeviceId: catalog.syncthingDeviceId ?? null, updatedAt: catalog.updatedAt, projects: (catalog.projects ?? []).map((project) => ({ id: project.id ?? null, label: project.label, path: project.path, tasks: project.tasks?.length ?? 0, configured: project.configured !== false })) })), syncthing: config.syncthing, deviceReports: listDeviceReports(config).map((report) => ({ deviceId: report.deviceId, platform: report.platform, protocolRevision: report.protocolRevision ?? 1, scriptSha256: report.scriptSha256 ?? null, selectedConversations: report.selectedConversations.length, skillSources: report.skillSources.map((source) => ({ name: source.name, skillDirectories: source.skillDirectories, files: source.files })), daemonInstalled: report.daemon?.installed, lastRunOk: report.lastRun?.ok ?? null })), conversationConflicts, @@ -1785,7 +1951,7 @@ function daemonCommand(options, action) { } function helpText() { - return `Sync2 v${VERSION}\n\nCommands:\n init --vault PATH [--transport folder|git] [--repo URL]\n vault use --vault PATH [--transport folder|git] [--repo URL]\n sync|push|pull [--dry-run]\n status | doctor\n maintenance on|off|status [--reason TEXT]\n conversation select \n conversation unselect \n conversation list\n conversation audit \n conversation repair [--title TITLE]\n conversation resolve --from-device DEVICE\n skills list|discover\n skills add --name NAME --path PATH [--exclude NAME]\n skills remove NAME\n skills install NAME --to PATH\n project add [--id ID] [--label LABEL] [--share-with all|DEVICE] [--ignore PATTERN]\n project list | project status | project rescan \n project remove (registration only; keeps local files)\n syncthing configure --syncthing-exe PATH | syncthing status\n syncthing add-device --device-id ID [--name NAME]\n device report | device list\n daemon install|uninstall|status [--minutes 5] [--dry-run]\n\nGlobal options: --config PATH --json --quiet`; + return `Sync2 v${VERSION}\n\nCommands:\n init --vault PATH [--transport folder|git] [--repo URL]\n vault use --vault PATH [--transport folder|git] [--repo URL]\n sync|push|pull [--dry-run]\n status | doctor\n maintenance on|off|status [--reason TEXT]\n conversation select \n conversation unselect \n conversation list\n conversation audit \n conversation repair [--title TITLE]\n conversation resolve --from-device DEVICE\n skills list|discover\n skills add --name NAME --path PATH [--exclude NAME]\n skills remove NAME\n skills install NAME --to PATH\n project discover | project catalogs\n project add|select [--id ID] [--label LABEL] [--share-with all|DEVICE] [--ignore PATTERN] [--no-tasks]\n project tasks \n project accept --path PATH [--no-register]\n project map --from SOURCE_PATH --to LOCAL_PATH | project register \n project list | project status | project rescan \n project remove (registration only; keeps local files)\n syncthing configure --syncthing-exe PATH | syncthing status\n syncthing add-device --device-id ID [--name NAME]\n device report | device list\n daemon install|uninstall|status [--minutes 5] [--dry-run]\n\nGlobal options: --config PATH --json --quiet`; } function printResult(result, options) { diff --git a/scripts/test-sync2.mjs b/scripts/test-sync2.mjs index 2658f66..371af13 100644 --- a/scripts/test-sync2.mjs +++ b/scripts/test-sync2.mjs @@ -74,6 +74,7 @@ function makeHome(name) { } function createThread(homeInfo) { + fs.mkdirSync(path.join(root, "project"), { recursive: true }); const rollout = path.join(homeInfo.sessionDir, `rollout-2026-01-01T00-00-00-${id}.jsonl`); const lines = [ record("session_meta", { id, timestamp: "2026-01-01T00:00:00.000Z", cwd: path.join(root, "project"), originator: "test", cli_version: "1.0", source: "cli", model_provider: "openai" }), @@ -117,10 +118,21 @@ try { run(["init", "--vault", vault, "--transport", "folder", "--device", "win-a", "--codex-home", a.home, "--config", configA]); run(["init", "--vault", vault, "--transport", "folder", "--device", "mac-b", "--codex-home", b.home, "--config", configB]); + fs.writeFileSync(path.join(a.home, ".codex-global-state.json"), `${JSON.stringify({ "electron-saved-workspace-roots": [path.join(root, "project")] })}\n`); + const discoveredProjects = run(["project", "discover", "--config", configA]); + assert.equal(discoveredProjects.projects.find((project) => project.path === path.join(root, "project")).tasks.length, 1); + const selectedProjectTasks = run(["project", "tasks", path.join(root, "project"), "--config", configA]); + assert.equal(selectedProjectTasks.count, 1); + const mappedProject = path.join(root, "mapped-project"); + fs.mkdirSync(mappedProject, { recursive: true }); + const mapped = run(["project", "map", "--from", path.join(root, "project"), "--to", mappedProject, "--config", configB]); + assert.equal(mapped.pathMaps[0].to, mappedProject); run(["conversation", "select", "current", "--config", configA], { CODEX_THREAD_ID: id }); const first = run(["sync", "--config", configA]); assert.equal(first.conversationsPushed, 1); assert.ok(first.skillFilesPushed >= 1); + const projectCatalog = JSON.parse(fs.readFileSync(path.join(vault, ".sync2", "project-catalogs", "win-a.json"), "utf8")); + assert.equal(projectCatalog.projects.find((project) => project.path === path.join(root, "project")).tasks.length, 1); const selectionFile = path.join(vault, ".sync2", "selections", "win-a.json"); const headFile = path.join(vault, "conversations", id, "heads", "win-a.jsonl"); const canonicalFile = path.join(vault, "conversations", id, "canonical.jsonl"); @@ -154,6 +166,7 @@ try { assert.equal(reportB.lastRun.summary.skillFilesPulled, pullB.skillFilesPulled); const dbB = new DatabaseSync(path.join(b.home, "state_5.sqlite")); assert.equal(dbB.prepare("SELECT title FROM threads WHERE id=?").get(id).title, "Sync2 E2E Thread"); + assert.equal(dbB.prepare("SELECT cwd FROM threads WHERE id=?").get(id).cwd, mappedProject); dbB.close(); const appDbB = new DatabaseSync(path.join(b.home, "sqlite", "codex-dev.db")); assert.equal(appDbB.prepare("SELECT display_title FROM local_thread_catalog WHERE thread_id=?").get(id).display_title, "Sync2 E2E Thread"); @@ -337,7 +350,7 @@ try { assert.equal(relocation.action, "vault-updated"); assert.ok(fs.existsSync(path.join(relocatedVault, "skills", "codex", "git-skill", "SKILL.md"))); - console.log(JSON.stringify({ ok: true, root, checks: 65, deviceReports: true, noOpWrites: true, stableActiveCheckpoint: true, extendedWindowsPath: true, semanticRepair: true, legacyAbortRepair: true, unsafeCanonicalQuarantine: true, activeTurnProtection: true, perDeviceMetadata: true, desktopCatalogImport: true, maintenanceMode: true, maintenanceBootstrap: process.platform === "win32", folderTransport: true, gitTransport: true, vaultRelocation: true, conversationImport: true, skillThreeWay: true, conflictRecovery: true, daemonPreview: true }, null, 2)); + console.log(JSON.stringify({ ok: true, root, checks: 70, projectDiscovery: true, projectTaskSelection: true, projectCatalogs: true, projectPathMapping: true, deviceReports: true, noOpWrites: true, stableActiveCheckpoint: true, extendedWindowsPath: true, semanticRepair: true, legacyAbortRepair: true, unsafeCanonicalQuarantine: true, activeTurnProtection: true, perDeviceMetadata: true, desktopCatalogImport: true, maintenanceMode: true, maintenanceBootstrap: process.platform === "win32", folderTransport: true, gitTransport: true, vaultRelocation: true, conversationImport: true, skillThreeWay: true, conflictRecovery: true, daemonPreview: true }, null, 2)); } catch (error) { console.error(`E2E FAILED; artifacts kept at ${root}`); throw error;