@vantran-se/openchamber-web 1.22.1 → 1.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -3
- package/bin/cli.js +11 -1
- package/bin/cli.test.js +92 -0
- package/bin/lib/cli-startup.js +49 -0
- package/bin/lib/commands-startup.js +41 -6
- package/dist/assets/{CodeMirrorCommentWidgets-C53Q7pbX.js → CodeMirrorCommentWidgets-DFg2wnpo.js} +1 -1
- package/dist/assets/DiffView-3Dn4QHbu.js +4 -0
- package/dist/assets/DirtyBranchSwitchDialog-CwOerqVi.js +1 -0
- package/dist/assets/FilesView-BdTOpew9.js +17 -0
- package/dist/assets/GitView-Dqf9vHJh.js +6 -0
- package/dist/assets/{JsonTreeView-Bl0lp9Rs.js → JsonTreeView-BKdsjg5B.js} +1 -1
- package/dist/assets/LinearIssuesView-CmbeL7I6.js +1 -0
- package/dist/assets/MarkdownImageGallery-_5gEVr0X.js +1 -0
- package/dist/assets/MarkdownRendererImpl-wVZlx4dd.js +12 -0
- package/dist/assets/OnboardingScreen-DdP4_nuc.js +1 -0
- package/dist/assets/{PatchDiff-D6NXAfJk.js → PatchDiff-am3SAss2.js} +1 -1
- package/dist/assets/PierreDiffViewer-DCsydlKs.js +161 -0
- package/dist/assets/PlanView-BoJxdRZe.js +5 -0
- package/dist/assets/SettingsView-CcM8pF5O.js +28 -0
- package/dist/assets/{SettingsWindow-BiWDBRvb.js → SettingsWindow-DVTdDSol.js} +1 -1
- package/dist/assets/SymbolsNerdFontMono-Regular-aK5vsLov.woff2 +0 -0
- package/dist/assets/{ToolOutputDialog-BQexMQIm.js → ToolOutputDialog-BgfTbDZ4.js} +6 -6
- package/dist/assets/ToolPartDiffPreview-C7WLk5i9.js +8 -0
- package/dist/assets/{VirtualizedFileDiff-Dpz2ra8h.js → VirtualizedFileDiff-DcdgkH-x.js} +1 -1
- package/dist/assets/WalkthroughView-BEVUDxuw.js +4 -0
- package/dist/assets/{computeVirtualFileMetrics-DtyozJdt.js → computeVirtualFileMetrics-C8byMnRh.js} +2 -2
- package/dist/assets/contextPanelEmbeddedChat-CirXEPJq.js +1 -0
- package/dist/assets/de-C8jV7J9y.js +10 -0
- package/dist/assets/es-BI6J1M5R.js +16 -0
- package/dist/assets/fr-D6oDFt7E.js +14 -0
- package/dist/assets/ghostty-vt-DdA0Zryv.wasm +0 -0
- package/dist/assets/{index-DmhujXbT.js → index-BB7ryz8y.js} +1 -1
- package/dist/assets/index-BqhiCmYS.js +39 -0
- package/dist/assets/index-FqSOBz0J.css +1 -0
- package/dist/assets/{isDiffPlainText-XyulMHxx.js → isDiffPlainText-45ujMPGb.js} +1 -1
- package/dist/assets/{isFilePlainText-B19Ddps1.js → isFilePlainText-Cc4KFSYi.js} +1 -1
- package/dist/assets/ja-C_4i5EQu.js +16 -0
- package/dist/assets/ko-BFbOTpbq.js +16 -0
- package/dist/assets/{main-B2a_JfKZ.js → main-C9ETLKFc.js} +2 -2
- package/dist/assets/main-CJmb2dF8.js +930 -0
- package/dist/assets/markdownCore-DVuZpnli.js +5 -0
- package/dist/assets/{miniChat-B3CSU_Ps.js → miniChat-B4Ytoq2n.js} +2 -2
- package/dist/assets/{mobile-QNcFIVCW.js → mobile-BUT6-lo1.js} +2 -2
- package/dist/assets/patchFileDiff-8j1apICU.js +13 -0
- package/dist/assets/pl-COFvT56l.js +16 -0
- package/dist/assets/pt-BR-DtVOO2Lj.js +16 -0
- package/dist/assets/renderElectronMiniChatApp-B6vUG-vF.js +2 -0
- package/dist/assets/renderMobileApp-BNgHraq6.js +25 -0
- package/dist/assets/runtimeEndpointReset-B8LYpWvr.js +17 -0
- package/dist/assets/scheduledTasksApi-GC4epIYA.js +1 -0
- package/dist/assets/{shikiHighlight-DE-OwqGw.js → shikiHighlight-BJISeZsQ.js} +1 -1
- package/dist/assets/surface-BvRzS58U.js +1 -0
- package/dist/assets/tr-B8Q0Go2l.js +7 -0
- package/dist/assets/uk-Dk4a-Fn-.js +16 -0
- package/dist/assets/{useAppFontEffects-COAeIX-D.js → useAppFontEffects-DpFNefUA.js} +153 -179
- package/dist/assets/useGitComparison-Dfxaqd0s.js +1 -0
- package/dist/assets/useInlineCommentController-CscsE4t-.js +2 -0
- package/dist/assets/useRootScrollLock-BaCvuRlT.js +1 -0
- package/dist/assets/{vendor-base-ui-BeMD8X_w.js → vendor-base-ui-DdV8vhHn.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-data-BG3v1Ghq.js → vendor-codemirror-language-data-DzlSIu_W.js} +2 -2
- package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
- package/dist/assets/vendor-zod-BPAANqvH.js +39 -0
- package/dist/assets/{web-components-BNMwa0qm.js → web-components-CdGfR2XJ.js} +1 -1
- package/dist/assets/zh-CN-B_BXxG1Q.js +16 -0
- package/dist/assets/zh-TW-DLrl1SPZ.js +16 -0
- package/dist/index.html +5 -43
- package/dist/mini-chat.html +4 -4
- package/dist/mobile.html +4 -4
- package/package.json +8 -5
- package/server/index.d.ts +15 -0
- package/server/index.js +82 -13
- package/server/lib/agent-tool/runtime.js +2 -19
- package/server/lib/changelog/update-notes.js +82 -0
- package/server/lib/changelog/update-notes.test.js +59 -0
- package/server/lib/data-dir-migration.js +40 -0
- package/server/lib/data-dir-migration.test.js +59 -0
- package/server/lib/dev-tunnel/DOCUMENTATION.md +13 -4
- package/server/lib/dev-tunnel/runtime.js +4 -4
- package/server/lib/dev-tunnel/tunnel.test.js +20 -0
- package/server/lib/event-stream/runtime.test.js +12 -12
- package/server/lib/fs/DOCUMENTATION.md +5 -0
- package/server/lib/fs/routes.js +63 -21
- package/server/lib/fs/routes.test.js +187 -1
- package/server/lib/git/DOCUMENTATION.md +32 -6
- package/server/lib/git/routes.js +40 -4
- package/server/lib/git/service.js +291 -215
- package/server/lib/git/service.test.js +593 -25
- package/server/lib/inherited-env.js +16 -9
- package/server/lib/inherited-env.test.js +8 -9
- package/server/lib/mcp-reconnect/DOCUMENTATION.md +55 -0
- package/server/lib/mcp-reconnect/runtime.js +122 -0
- package/server/lib/mcp-reconnect/runtime.test.js +140 -0
- package/server/lib/message-queue/DOCUMENTATION.md +161 -0
- package/server/lib/message-queue/runtime.js +870 -0
- package/server/lib/message-queue/runtime.test.js +482 -0
- package/server/lib/network-defaults.js +23 -0
- package/server/lib/network-defaults.test.js +52 -0
- package/server/lib/openchamber-sessions/routes.js +102 -0
- package/server/lib/openchamber-sessions/routes.test.js +88 -0
- package/server/lib/opencode/DOCUMENTATION.md +30 -6
- package/server/lib/opencode/bootstrap-runtime.js +2 -0
- package/server/lib/opencode/config-paths.test.js +116 -0
- package/server/lib/opencode/core-routes.js +1 -0
- package/server/lib/opencode/feature-routes-runtime.js +7 -0
- package/server/lib/opencode/input-history-scope.js +14 -0
- package/server/lib/opencode/managed-plugin-config.js +35 -0
- package/server/lib/opencode/openchamber-routes.js +64 -4
- package/server/lib/opencode/openchamber-routes.test.js +130 -3
- package/server/lib/opencode/plugins.js +2 -2
- package/server/lib/opencode/providers.js +44 -2
- package/server/lib/opencode/providers.test.js +131 -4
- package/server/lib/opencode/routes.js +9 -7
- package/server/lib/opencode/server-startup-runtime.js +1 -1
- package/server/lib/opencode/settings-files.js +214 -0
- package/server/lib/opencode/settings-helpers.js +113 -41
- package/server/lib/opencode/settings-helpers.test.js +342 -0
- package/server/lib/opencode/settings-registry.json +747 -0
- package/server/lib/opencode/settings-runtime.js +141 -20
- package/server/lib/opencode/settings-runtime.test.js +191 -4
- package/server/lib/opencode/shared.js +4 -1
- package/server/lib/opencode/shutdown-runtime.js +2 -0
- package/server/lib/opencode/skill-routes.js +2 -1
- package/server/lib/opencode/snippets.js +1 -2
- package/server/lib/package-manager.js +4 -29
- package/server/lib/package-manager.test.js +2 -2
- package/server/lib/project-context/DOCUMENTATION.md +25 -1
- package/server/lib/project-context/routes.http.test.js +18 -0
- package/server/lib/project-context/routes.js +15 -0
- package/server/lib/project-context/runtime.js +248 -23
- package/server/lib/project-context/runtime.test.js +106 -3
- package/server/lib/projects/DOCUMENTATION.md +76 -0
- package/server/lib/projects/project-config.js +142 -9
- package/server/lib/projects/project-id.js +17 -0
- package/server/lib/projects/project-setup.js +409 -0
- package/server/lib/projects/project-setup.test.js +557 -0
- package/server/lib/projects/routes.js +59 -0
- package/server/lib/quota/DOCUMENTATION.md +40 -2
- package/server/lib/quota/credentials/providers.js +4 -0
- package/server/lib/quota/credentials/store.js +1 -1
- package/server/lib/quota/credentials/store.test.js +4 -4
- package/server/lib/quota/index.js +1 -0
- package/server/lib/quota/providers/cline-pass.js +125 -0
- package/server/lib/quota/providers/cline-pass.test.js +103 -0
- package/server/lib/quota/providers/exe-dev.js +75 -0
- package/server/lib/quota/providers/exe-dev.test.js +47 -0
- package/server/lib/quota/providers/hyper.js +118 -0
- package/server/lib/quota/providers/hyper.test.js +126 -0
- package/server/lib/quota/providers/index.js +22 -0
- package/server/lib/quota/providers/neuralwatt.js +3 -6
- package/server/lib/quota/providers/neuralwatt.test.js +7 -7
- package/server/lib/quota/providers/ollama-cloud.js +31 -0
- package/server/lib/quota/providers/ollama-cloud.test.js +33 -1
- package/server/lib/quota/providers/opencode-go.js +2 -1
- package/server/lib/quota/providers/opencode-go.test.js +31 -2
- package/server/lib/quota/providers/openrouter.js +119 -24
- package/server/lib/quota/providers/openrouter.test.js +330 -0
- package/server/lib/quota/routes.js +2 -0
- package/server/lib/quota/utils/auth.js +1 -1
- package/server/lib/relay/DOCUMENTATION.md +56 -1
- package/server/lib/relay/cross-compat.test.js +26 -0
- package/server/lib/relay/downstream-scheduler.js +154 -0
- package/server/lib/relay/downstream-scheduler.test.js +162 -0
- package/server/lib/relay/e2ee.js +10 -8
- package/server/lib/relay/flow-control.test.js +177 -0
- package/server/lib/relay/host-client.js +37 -8
- package/server/lib/relay/host-client.test.js +9 -1
- package/server/lib/relay/tunnel-codec.js +9 -0
- package/server/lib/relay/tunnel-host.js +69 -18
- package/server/lib/relay/tunnel-host.test.js +9 -1
- package/server/lib/security/request-security.js +21 -6
- package/server/lib/security/request-security.test.js +42 -8
- package/server/lib/session-assist/runtime.js +7 -10
- package/server/lib/session-goal/DOCUMENTATION.md +20 -7
- package/server/lib/session-goal/create.js +3 -1
- package/server/lib/session-goal/runtime.js +81 -20
- package/server/lib/session-goal/runtime.test.js +426 -0
- package/server/lib/skills-catalog/DOCUMENTATION.md +1 -1
- package/server/lib/skills-catalog/install.js +3 -2
- package/server/lib/small-model/DOCUMENTATION.md +7 -2
- package/server/lib/small-model/call.js +17 -6
- package/server/lib/small-model/call.test.js +52 -0
- package/server/lib/small-model/index.js +7 -11
- package/server/lib/small-model/routes.js +2 -1
- package/server/lib/small-model/runtime-providers.js +15 -7
- package/server/lib/small-model/runtime-providers.test.js +7 -1
- package/server/lib/system-prompt/runtime.js +2 -21
- package/server/lib/terminal/DOCUMENTATION.md +14 -10
- package/server/lib/terminal/runtime.js +190 -31
- package/server/lib/terminal/runtime.test.js +571 -37
- package/server/lib/terminal/shells.js +17 -0
- package/server/lib/terminal/shells.test.js +32 -1
- package/server/lib/ui-auth/ui-auth.js +4 -1
- package/server/lib/ui-auth/ui-auth.test.js +16 -0
- package/server/lib/walkthrough/DOCUMENTATION.md +33 -14
- package/server/lib/walkthrough/index.js +1 -0
- package/server/lib/walkthrough/model-settings.js +8 -12
- package/server/lib/walkthrough/prompt.js +3 -1
- package/server/lib/walkthrough/routes.test.js +26 -5
- package/server/lib/walkthrough/sources.js +23 -2
- package/dist/assets/CommitSection-X-f2RgYr.js +0 -1
- package/dist/assets/DiffView-Dh1ey2x9.js +0 -4
- package/dist/assets/FilesView-3ZYP72ki.js +0 -17
- package/dist/assets/GitView-CMuN7z2Z.js +0 -6
- package/dist/assets/LinearIssuesView-CQiUDU8b.js +0 -1
- package/dist/assets/MarkdownImageGallery-bA0PS-tn.js +0 -1
- package/dist/assets/MarkdownRendererImpl-BGQi2PLq.js +0 -12
- package/dist/assets/OnboardingScreen-D3kYJIb6.js +0 -1
- package/dist/assets/PierreDiffViewer-Ct2A13NO.js +0 -157
- package/dist/assets/PlanView-6AUjzNQn.js +0 -5
- package/dist/assets/SettingsView-CHI8fFaK.js +0 -28
- package/dist/assets/ToolPartDiffPreview-DPAATNn6.js +0 -8
- package/dist/assets/WalkthroughView-AX-u2pFg.js +0 -4
- package/dist/assets/contextPanelEmbeddedChat-C9EcRSDF.js +0 -1
- package/dist/assets/de-DqVn5vme.js +0 -9
- package/dist/assets/es-hj43Z53i.js +0 -15
- package/dist/assets/fr-BmkZt2mF.js +0 -13
- package/dist/assets/index-L_ncV-6H.css +0 -1
- package/dist/assets/index-qES19dlJ.js +0 -7
- package/dist/assets/ja-DioMIEGF.js +0 -15
- package/dist/assets/ko-BqPgaQJb.js +0 -15
- package/dist/assets/main-CYgSu6dx.js +0 -928
- package/dist/assets/markdownCore-CWRCqSH2.js +0 -3
- package/dist/assets/patchFileDiff-BpMQZnYu.js +0 -10
- package/dist/assets/pl-slULBF4R.js +0 -15
- package/dist/assets/pt-BR-BqH8fuCH.js +0 -15
- package/dist/assets/renderElectronMiniChatApp-D77bmLqo.js +0 -2
- package/dist/assets/renderMobileApp-BQ3Kdm-d.js +0 -25
- package/dist/assets/runtimeEndpointReset-CRGn1gVj.js +0 -16
- package/dist/assets/scheduledTasksApi-BSRCtP6T.js +0 -1
- package/dist/assets/tr-CjEbw1Aw.js +0 -6
- package/dist/assets/uk-BkkfxbOs.js +0 -15
- package/dist/assets/useInlineCommentController-JsBboQwt.js +0 -2
- package/dist/assets/useRootScrollLock-C0N9mCwz.js +0 -1
- package/dist/assets/vendor-ghostty-web-CrxinGGH.js +0 -13
- package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
- package/dist/assets/vendor-zod-BLfBSgBh.js +0 -39
- package/dist/assets/zh-CN-C6FMdVgv.js +0 -15
- package/dist/assets/zh-TW-CdcMjDxv.js +0 -15
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Projects
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Server-owned storage for a project's per-user config file,
|
|
6
|
+
`~/.config/openchamber/projects/<projectId>.json`. The file holds two
|
|
7
|
+
families of keys with different writers, and this module is the only place
|
|
8
|
+
that writes it:
|
|
9
|
+
|
|
10
|
+
| Keys | Owner | Reached through |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| `version`, `scheduledTasks` | `project-config.js` (scheduled-task runtime) | `/api/projects/:projectId/scheduled-tasks/*` |
|
|
13
|
+
| `setup-worktree`, `setup-worktree-wait`, `projectActions`, `projectActionsPrimaryId`, `draftStarters`, `projectPath` | `project-setup.js` via `readProjectSetup` / `updateProjectSetup` on the same runtime | `GET/PUT /api/projects/:projectId/config` (`routes.js`) |
|
|
14
|
+
|
|
15
|
+
Notes, todos, and plans moved out of this file to `packages/web/server/lib/project-context`.
|
|
16
|
+
|
|
17
|
+
A second, optional source is the team's shared file, `<repo>/.openchamber/project.json`
|
|
18
|
+
(`version: 1`; `setupWorktree`, `setupWorktreeWait`, `projectActions`, `draftStarters`,
|
|
19
|
+
`plansDir`). The server reads it from the checkout the project id names
|
|
20
|
+
(`projectPathFromId`). `GET /api/projects/:projectId/config`
|
|
21
|
+
returns one merged view: what runs at the top level, plus `shared` and `personal`
|
|
22
|
+
blocks so a page can edit the personal file without copying a teammate's entry into it.
|
|
23
|
+
|
|
24
|
+
| Field | Merge rule |
|
|
25
|
+
|---|---|
|
|
26
|
+
| `setupWorktree` | shared first, then personal; personal `setupWorktreeMode: "replace"` uses the personal list only |
|
|
27
|
+
| `setupWorktreeWait` | personal when the personal file sets it, else shared, else `false` |
|
|
28
|
+
| `projectActions` | union by `id`; a personal action replaces the shared one with the same id; ids in personal `hiddenSharedActionIds` are dropped; every entry carries `source` |
|
|
29
|
+
| `projectActionsPrimaryId` | personal only |
|
|
30
|
+
| `draftStarters` | union by `type:name`, shared first, every entry carries `source` |
|
|
31
|
+
| `plansDir` | shared only |
|
|
32
|
+
|
|
33
|
+
A shared file that exists but cannot be parsed (or names a `plansDir` outside the
|
|
34
|
+
repo) is `shared.status: "invalid"` with a `reason`; the personal setup is still
|
|
35
|
+
served. It is never treated as "no shared setup".
|
|
36
|
+
|
|
37
|
+
### Writing the shared file
|
|
38
|
+
|
|
39
|
+
`PUT /api/projects/:projectId/config/shared` (`updateSharedProjectSetup`) is
|
|
40
|
+
the only writer. The patch replaces the keys it names over the current file
|
|
41
|
+
(a broken file counts as empty, so a write repairs it); the result is written
|
|
42
|
+
pretty-printed with `version` first and only the keys that carry something
|
|
43
|
+
(`serializeSharedProjectConfig`), because the file is committed and reviewed.
|
|
44
|
+
A result with nothing in it removes the file and the `.openchamber` folder
|
|
45
|
+
when that leaves it empty, so unsharing the last item leaves no trace. The
|
|
46
|
+
write refuses a checkout that does not exist and a `plansDir` outside the
|
|
47
|
+
repo. The writer has seen what it shared, so its personal trust record is set
|
|
48
|
+
to the new hash; teammates still get the prompt. The shared UI composes
|
|
49
|
+
"share" and "make personal" as a shared write followed by a personal write.
|
|
50
|
+
|
|
51
|
+
### Trust
|
|
52
|
+
|
|
53
|
+
Shared setup commands and shared actions run on the machine of whoever pulls
|
|
54
|
+
the repo, so they run only after the user has seen them. The view carries
|
|
55
|
+
`trust: { hash, trusted }`: `hash` is `sharedTrustHashOf(shared)`, a SHA-256
|
|
56
|
+
over the executable parts (`setupWorktree` and each action's `id`, `command`,
|
|
57
|
+
`runIn`, actions sorted by id; names and icons do not count), or `null` when
|
|
58
|
+
nothing executes. `trusted` is true when nothing executes or the personal
|
|
59
|
+
file's `sharedTrust.hash` equals the current hash, so a pull that changes a
|
|
60
|
+
command brings the prompt back. The client records an answer with a PUT of
|
|
61
|
+
`sharedTrustHash` (`null` forgets it). The prompt itself lives in the shared
|
|
62
|
+
UI (`packages/ui/src/lib/sharedTrustConfirmation.ts`).
|
|
63
|
+
|
|
64
|
+
## Modules
|
|
65
|
+
|
|
66
|
+
- `project-id.js` — `createProjectIdFromPath` / `projectPathFromId`: the path-derived id (`path_<base64url>`) that names the file, and the checkout path back from it. The shared UI derives the same id (`packages/ui/src/lib/projectId.ts`); both sides must agree.
|
|
67
|
+
- `project-config.js` — `createProjectConfigRuntime`: raw read, atomic write, the cross-process file lock (Electron and a CLI `serve` can share one projects dir), scheduled-task normalization, and the project-setup read/update.
|
|
68
|
+
- `project-setup.js` — sanitizers, the shared-file parser (`parseSharedProjectConfig`, `normalizePlansDir`), the merge (`mergeProjectSetup`), and the personal view for the setup keys. Mirrored in the VS Code extension host (`packages/vscode/src/project-setup.ts`), which owns the same file when the webview has no OpenChamber server; keep the two in sync.
|
|
69
|
+
- `routes.js` — the setup routes. `/api/projects` is on the JSON-body allowlist in `opencode/core-routes.js`.
|
|
70
|
+
|
|
71
|
+
## Invariants
|
|
72
|
+
|
|
73
|
+
- **Every write is a locked read-modify-write of the whole document.** Keys the writer does not own, and keys from newer builds, come back out unchanged. A setup update and a scheduled-task update never clobber each other.
|
|
74
|
+
- **A wrongly shaped key is a 400, not a silent drop.** `projectSetupPatchToStored` throws; the file is untouched. Values inside a well-shaped key are sanitized (trimmed, capped, deduplicated) rather than rejected.
|
|
75
|
+
- **The client never composes the path.** `packages/ui/src/lib/openchamberConfig.ts` speaks only HTTP; the same code serves web, desktop, VS Code, and the phone, including a phone on a remote instance.
|
|
76
|
+
- **`OPENCHAMBER_DATA_DIR` moves this directory too.** Every OpenChamber folder hangs off the one root; a custom root gets `projects/`, `themes/`, and `speech-models/` copied in from `~/.config/openchamber` once at startup (copied, not moved: a second instance beside the default one must not strip it) (`lib/data-dir-migration.js`). A scratch server started with its own data dir therefore never touches the real project configs.
|
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import { DateTime, IANAZone } from 'luxon';
|
|
2
2
|
import parser from 'cron-parser';
|
|
3
3
|
|
|
4
|
+
import { projectPathFromId } from './project-id.js';
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_PLANS_DIR,
|
|
7
|
+
EMPTY_SHARED_PROJECT_CONFIG,
|
|
8
|
+
SHARED_CONFIG_RELATIVE_PATH,
|
|
9
|
+
applySharedProjectSetupPatch,
|
|
10
|
+
isSharedProjectConfigEmpty,
|
|
11
|
+
mergeProjectSetup,
|
|
12
|
+
parseSharedProjectConfig,
|
|
13
|
+
projectSetupPatchToStored,
|
|
14
|
+
projectSetupViewOf,
|
|
15
|
+
serializeSharedProjectConfig,
|
|
16
|
+
sharedTrustHashOf,
|
|
17
|
+
} from './project-setup.js';
|
|
18
|
+
|
|
4
19
|
const PROJECT_CONFIG_VERSION = 1;
|
|
5
20
|
export const MAX_TASK_NAME_LENGTH = 80;
|
|
6
21
|
const MAX_TASK_PROMPT_LENGTH = 20_000;
|
|
@@ -548,21 +563,16 @@ export const createProjectConfigRuntime = (deps) => {
|
|
|
548
563
|
})
|
|
549
564
|
);
|
|
550
565
|
|
|
551
|
-
|
|
566
|
+
// Atomic whole-document write; callers hand in the merged document so the
|
|
567
|
+
// keys they do not own survive untouched.
|
|
568
|
+
const writeRawProjectConfigToDisk = async (projectID, document) => {
|
|
552
569
|
const filePath = resolveProjectConfigPath(projectID);
|
|
553
570
|
const parentDirectory = path.dirname(filePath);
|
|
554
571
|
const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
555
572
|
|
|
556
|
-
const existing = await readRawProjectConfigFromDisk(projectID);
|
|
557
|
-
const merged = {
|
|
558
|
-
...existing,
|
|
559
|
-
version: PROJECT_CONFIG_VERSION,
|
|
560
|
-
scheduledTasks: Array.isArray(config?.scheduledTasks) ? config.scheduledTasks : [],
|
|
561
|
-
};
|
|
562
|
-
|
|
563
573
|
await fsPromises.mkdir(parentDirectory, { recursive: true });
|
|
564
574
|
try {
|
|
565
|
-
await fsPromises.writeFile(temporaryPath, JSON.stringify(
|
|
575
|
+
await fsPromises.writeFile(temporaryPath, JSON.stringify(document, null, 2), 'utf8');
|
|
566
576
|
await fsPromises.rename(temporaryPath, filePath);
|
|
567
577
|
} catch (error) {
|
|
568
578
|
await fsPromises.rm(temporaryPath, { force: true }).catch(() => {});
|
|
@@ -570,6 +580,15 @@ export const createProjectConfigRuntime = (deps) => {
|
|
|
570
580
|
}
|
|
571
581
|
};
|
|
572
582
|
|
|
583
|
+
const writeProjectConfigToDisk = async (projectID, config) => {
|
|
584
|
+
const existing = await readRawProjectConfigFromDisk(projectID);
|
|
585
|
+
await writeRawProjectConfigToDisk(projectID, {
|
|
586
|
+
...existing,
|
|
587
|
+
version: PROJECT_CONFIG_VERSION,
|
|
588
|
+
scheduledTasks: Array.isArray(config?.scheduledTasks) ? config.scheduledTasks : [],
|
|
589
|
+
});
|
|
590
|
+
};
|
|
591
|
+
|
|
573
592
|
const withProjectWriteLock = async (projectID, mutate) => {
|
|
574
593
|
const key = sanitizeProjectID(projectID);
|
|
575
594
|
const previous = writeLocks.get(key) || Promise.resolve();
|
|
@@ -905,7 +924,121 @@ export const createProjectConfigRuntime = (deps) => {
|
|
|
905
924
|
});
|
|
906
925
|
};
|
|
907
926
|
|
|
927
|
+
// The client-owned part of the file (worktree setup, project actions, draft
|
|
928
|
+
// starters); see `project-setup.js`. Reads are lock-free like task lists;
|
|
929
|
+
// an update merges the sanitized patch over the raw document under the same
|
|
930
|
+
// cross-process lock the task writers use, so neither side clobbers the other.
|
|
931
|
+
// The shared file lives in the project's checkout. The checkout path comes
|
|
932
|
+
// from the id itself (`path_<base64url>`), with the personal file's
|
|
933
|
+
// `projectPath` as the fallback for ids of another form. A missing file is
|
|
934
|
+
// the normal case; an unreadable or unparsable one is reported as invalid,
|
|
935
|
+
// never as "no shared setup".
|
|
936
|
+
const projectPathOf = (projectID, personalRaw) => (
|
|
937
|
+
projectPathFromId(projectID) || (typeof personalRaw.projectPath === 'string' ? personalRaw.projectPath.trim() : '')
|
|
938
|
+
);
|
|
939
|
+
const sharedConfigPathOf = (projectPath) => path.join(projectPath, ...SHARED_CONFIG_RELATIVE_PATH.split('/'));
|
|
940
|
+
|
|
941
|
+
const readSharedProjectConfig = async (projectID, personalRaw) => {
|
|
942
|
+
const projectPath = projectPathOf(projectID, personalRaw);
|
|
943
|
+
if (!projectPath) return { status: 'missing' };
|
|
944
|
+
let raw;
|
|
945
|
+
try {
|
|
946
|
+
raw = await fsPromises.readFile(sharedConfigPathOf(projectPath), 'utf8');
|
|
947
|
+
} catch (error) {
|
|
948
|
+
if (error && typeof error === 'object' && error.code === 'ENOENT') return { status: 'missing' };
|
|
949
|
+
return { status: 'invalid', reason: error instanceof Error ? error.message : String(error) };
|
|
950
|
+
}
|
|
951
|
+
return parseSharedProjectConfig(raw);
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
const mergedProjectSetupOf = async (projectID, personalRaw) => (
|
|
955
|
+
mergeProjectSetup(projectSetupViewOf(personalRaw), await readSharedProjectConfig(projectID, personalRaw))
|
|
956
|
+
);
|
|
957
|
+
|
|
958
|
+
const readProjectSetup = async (projectID) => mergedProjectSetupOf(projectID, await readRawProjectConfigFromDisk(projectID));
|
|
959
|
+
|
|
960
|
+
const updateProjectSetup = async (projectID, patch) => {
|
|
961
|
+
const stored = projectSetupPatchToStored(patch);
|
|
962
|
+
return withProjectWriteLock(projectID, async () => {
|
|
963
|
+
const existing = await readRawProjectConfigFromDisk(projectID);
|
|
964
|
+
const merged = { ...existing, ...stored };
|
|
965
|
+
for (const [key, value] of Object.entries(stored)) {
|
|
966
|
+
if (value === undefined) delete merged[key];
|
|
967
|
+
}
|
|
968
|
+
await writeRawProjectConfigToDisk(projectID, merged);
|
|
969
|
+
return mergedProjectSetupOf(projectID, merged);
|
|
970
|
+
});
|
|
971
|
+
};
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Change the team's shared file in the checkout: the patch replaces the
|
|
975
|
+
* keys it names over the current file (a broken file counts as empty, so
|
|
976
|
+
* a write repairs it). A result with nothing in it removes the file (and
|
|
977
|
+
* the `.openchamber` folder when that leaves it empty), so unsharing the
|
|
978
|
+
* last item leaves no trace. The writer has seen the commands it just
|
|
979
|
+
* shared, so the personal trust record is set to the new hash on this
|
|
980
|
+
* instance; teammates still get the prompt.
|
|
981
|
+
*/
|
|
982
|
+
const updateSharedProjectSetup = async (projectID, patch) => (
|
|
983
|
+
withProjectWriteLock(projectID, async () => {
|
|
984
|
+
const personalRaw = await readRawProjectConfigFromDisk(projectID);
|
|
985
|
+
const projectPath = projectPathOf(projectID, personalRaw);
|
|
986
|
+
if (!projectPath) throw new Error('project checkout not found');
|
|
987
|
+
try {
|
|
988
|
+
if (!(await fsPromises.stat(projectPath)).isDirectory()) throw new Error('project checkout not found');
|
|
989
|
+
} catch {
|
|
990
|
+
throw new Error('project checkout not found');
|
|
991
|
+
}
|
|
992
|
+
const currentRead = await readSharedProjectConfig(projectID, personalRaw);
|
|
993
|
+
const current = currentRead.status === 'ok' ? currentRead.config : EMPTY_SHARED_PROJECT_CONFIG;
|
|
994
|
+
const next = applySharedProjectSetupPatch(current, patch);
|
|
995
|
+
|
|
996
|
+
const filePath = sharedConfigPathOf(projectPath);
|
|
997
|
+
if (isSharedProjectConfigEmpty(next)) {
|
|
998
|
+
await fsPromises.rm(filePath, { force: true });
|
|
999
|
+
await fsPromises.rmdir(path.dirname(filePath)).catch(() => {});
|
|
1000
|
+
} else {
|
|
1001
|
+
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
|
|
1002
|
+
const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
1003
|
+
try {
|
|
1004
|
+
await fsPromises.writeFile(temporaryPath, serializeSharedProjectConfig(next), 'utf8');
|
|
1005
|
+
await fsPromises.rename(temporaryPath, filePath);
|
|
1006
|
+
} catch (error) {
|
|
1007
|
+
await fsPromises.rm(temporaryPath, { force: true }).catch(() => {});
|
|
1008
|
+
throw error;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const hash = sharedTrustHashOf(next);
|
|
1013
|
+
const personalNext = { ...personalRaw };
|
|
1014
|
+
if (hash) personalNext.sharedTrust = { hash, trustedAt: Date.now() };
|
|
1015
|
+
else delete personalNext.sharedTrust;
|
|
1016
|
+
await writeRawProjectConfigToDisk(projectID, personalNext);
|
|
1017
|
+
return mergedProjectSetupOf(projectID, personalNext);
|
|
1018
|
+
})
|
|
1019
|
+
);
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* The absolute repository plans folder of a project: `plansDir` from the
|
|
1023
|
+
* shared file when set, else the default `.openchamber/plans`. Setting
|
|
1024
|
+
* `plansDir` replaces the default outright (nothing is read from it any
|
|
1025
|
+
* more); moving files between the two is the user's job. Null only when the
|
|
1026
|
+
* checkout cannot be located.
|
|
1027
|
+
*/
|
|
1028
|
+
const resolveSharedPlansDir = async (projectID) => {
|
|
1029
|
+
const personalRaw = await readRawProjectConfigFromDisk(projectID);
|
|
1030
|
+
const projectPath = projectPathOf(projectID, personalRaw);
|
|
1031
|
+
if (!projectPath) return null;
|
|
1032
|
+
const shared = await readSharedProjectConfig(projectID, personalRaw);
|
|
1033
|
+
const relative = shared.status === 'ok' && shared.config.plansDir ? shared.config.plansDir : DEFAULT_PLANS_DIR;
|
|
1034
|
+
return path.join(projectPath, ...relative.split('/'));
|
|
1035
|
+
};
|
|
1036
|
+
|
|
908
1037
|
return {
|
|
1038
|
+
readProjectSetup,
|
|
1039
|
+
updateProjectSetup,
|
|
1040
|
+
updateSharedProjectSetup,
|
|
1041
|
+
resolveSharedPlansDir,
|
|
909
1042
|
listScheduledTasks,
|
|
910
1043
|
upsertScheduledTask,
|
|
911
1044
|
deleteScheduledTask,
|
|
@@ -11,3 +11,20 @@ export const createProjectIdFromPath = (projectPath) => {
|
|
|
11
11
|
|
|
12
12
|
return `path_${Buffer.from(normalized, 'utf8').toString('base64url')}`;
|
|
13
13
|
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The path a `path_<base64url>` id was made from, or `''` when the id is not
|
|
17
|
+
* of that form. The projects dir names files by this id, so the server can
|
|
18
|
+
* find the project's checkout (and the shared config inside it) from the id
|
|
19
|
+
* alone.
|
|
20
|
+
*/
|
|
21
|
+
export const projectPathFromId = (projectId) => {
|
|
22
|
+
if (typeof projectId !== 'string' || !projectId.startsWith('path_')) return '';
|
|
23
|
+
const encoded = projectId.slice('path_'.length);
|
|
24
|
+
if (!encoded || !/^[A-Za-z0-9_-]+$/.test(encoded)) return '';
|
|
25
|
+
try {
|
|
26
|
+
return Buffer.from(encoded, 'base64url').toString('utf8');
|
|
27
|
+
} catch {
|
|
28
|
+
return '';
|
|
29
|
+
}
|
|
30
|
+
};
|