@kabmo-labs/kabmo 0.7.0 → 0.8.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.
Files changed (130) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +403 -384
  3. package/dist/client/static/js/index-gtbpewr7.css +1 -0
  4. package/dist/client/static/js/{index-0wvbca0b.js → index-qwpvd9fv.js} +779 -779
  5. package/package.json +2 -1
  6. package/src/client/components/common/header-controls/index.tsx +24 -1
  7. package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
  8. package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
  9. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
  10. package/src/client/components/layout/desktop-layout/index.tsx +8 -2
  11. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
  12. package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
  13. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
  14. package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
  15. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  16. package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
  17. package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
  18. package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
  19. package/src/client/components/mobile/LayoutWrapper.tsx +22 -16
  20. package/src/client/components/mobile/RightSidebar.tsx +21 -10
  21. package/src/client/components/mobile/SessionSidebar.tsx +8 -0
  22. package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
  23. package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
  24. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
  25. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
  26. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
  27. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
  28. package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
  29. package/src/client/components/mobile/worktree-session.ts +51 -0
  30. package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
  31. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +140 -0
  32. package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
  33. package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
  34. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +29 -56
  35. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
  36. package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
  37. package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +227 -0
  38. package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +260 -0
  39. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
  40. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +24 -6
  41. package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
  42. package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
  43. package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
  44. package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
  45. package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
  46. package/src/client/components/workspace/file-explorer/filter.ts +47 -0
  47. package/src/client/components/workspace/file-explorer/index.tsx +77 -64
  48. package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
  49. package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
  50. package/src/client/hooks/chat/timeline/index.ts +9 -5
  51. package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
  52. package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
  53. package/src/client/hooks/chat/timeline/send.test.ts +2 -0
  54. package/src/client/hooks/chat/timeline/send.ts +11 -1
  55. package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
  56. package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
  57. package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
  58. package/src/client/hooks/ui/long-press.test.ts +174 -0
  59. package/src/client/hooks/ui/long-press.ts +112 -0
  60. package/src/client/hooks/ui/updates/shell-download.test.ts +73 -0
  61. package/src/client/hooks/ui/updates/shell-download.ts +112 -28
  62. package/src/client/hooks/ui/updates/shell-offer.test.ts +187 -0
  63. package/src/client/hooks/workspace/file-create.ts +41 -11
  64. package/src/client/hooks/workspace/file-listing.ts +56 -1
  65. package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
  66. package/src/client/hooks/workspace/worktree/index.ts +150 -0
  67. package/src/client/tailwind.css +19 -0
  68. package/src/server/index.ts +7 -2
  69. package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
  70. package/src/server/lib/db/schema.ts +4 -0
  71. package/src/server/lib/db/worktree-registry.server.ts +91 -0
  72. package/src/server/lib/fs/canonical.ts +52 -0
  73. package/src/server/lib/fs/relative-path.ts +48 -0
  74. package/src/server/lib/fs/root-worktree.test.ts +116 -0
  75. package/src/server/lib/fs/root.ts +39 -10
  76. package/src/server/lib/fs/worktree/carry.ts +134 -0
  77. package/src/server/lib/fs/worktree/create.test.ts +249 -0
  78. package/src/server/lib/fs/worktree/create.ts +263 -0
  79. package/src/server/lib/fs/worktree/errors.ts +43 -0
  80. package/src/server/lib/fs/worktree/list.test.ts +207 -0
  81. package/src/server/lib/fs/worktree/list.ts +129 -0
  82. package/src/server/lib/fs/worktree/location.test.ts +206 -0
  83. package/src/server/lib/fs/worktree/location.ts +114 -0
  84. package/src/server/lib/fs/worktree/project-id.ts +53 -0
  85. package/src/server/lib/fs/worktree/remove.test.ts +242 -0
  86. package/src/server/lib/fs/worktree/remove.ts +152 -0
  87. package/src/server/lib/fs/worktree/settings.server.ts +56 -0
  88. package/src/server/lib/fs/worktree/topology.ts +142 -0
  89. package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
  90. package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
  91. package/src/server/lib/panels/local-edits.server.ts +33 -0
  92. package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
  93. package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
  94. package/src/server/lib/panels/sync.server.test.ts +125 -147
  95. package/src/server/lib/panels/sync.server.ts +153 -48
  96. package/src/server/lib/updates/shell-assets.test.ts +114 -0
  97. package/src/server/lib/updates/shell-assets.ts +109 -0
  98. package/src/server/lib/updates/shell-pages.test.ts +156 -0
  99. package/src/server/lib/updates/shell-pages.ts +145 -0
  100. package/src/server/lib/updates/shell-release.test.ts +75 -70
  101. package/src/server/lib/updates/shell-release.ts +120 -136
  102. package/src/server/routes/fs/action-create.test.ts +18 -13
  103. package/src/server/routes/fs/action-nested.test.ts +184 -0
  104. package/src/server/routes/fs/action.ts +47 -16
  105. package/src/server/routes/index.ts +2 -0
  106. package/src/server/routes/updates/desktop.ts +37 -0
  107. package/src/server/routes/updates/index.ts +2 -0
  108. package/src/server/routes/worktrees/create.ts +138 -0
  109. package/src/server/routes/worktrees/index.ts +25 -0
  110. package/src/server/routes/worktrees/list.ts +124 -0
  111. package/src/server/routes/worktrees/remove.ts +102 -0
  112. package/src/server/routes/worktrees/routes.test.ts +309 -0
  113. package/src/server/routes/worktrees/shared.ts +90 -0
  114. package/src/shared/lib/fs/file-tree.test.ts +183 -0
  115. package/src/shared/lib/fs/file-tree.ts +159 -0
  116. package/src/shared/lib/fs/paths.test.ts +24 -1
  117. package/src/shared/lib/fs/paths.ts +17 -0
  118. package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
  119. package/src/shared/lib/omp/session/prewarm.ts +7 -0
  120. package/src/shared/lib/updates/shell-ua.ts +24 -4
  121. package/src/shared/lib/workspace/active-project.ts +23 -0
  122. package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
  123. package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
  124. package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
  125. package/src/shared/lib/workspace/worktree/types.ts +104 -0
  126. package/src/shared/types/fs.ts +10 -0
  127. package/src/shared/types/workspace.ts +14 -0
  128. package/src/test-support/file-explorer-disk.test-util.ts +76 -0
  129. package/src/test-support/file-explorer.test-util.ts +71 -40
  130. package/dist/client/static/js/index-nzyjf92m.css +0 -1
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The two-directory fixture the bundled-sync suites run against: a WORKING
8
+ * marketplace (what is installed) and a STORE (what the package ships).
9
+ *
10
+ * Split out because the two suites that pin the sync would otherwise each carry
11
+ * a copy of the same setup, and the paths are the whole contract — a copy that
12
+ * forgot `KABMO_BUNDLED_MARKETPLACE_DIR` would silently compare the real
13
+ * package's store against a temp install and pass for the wrong reason.
14
+ *
15
+ * `isolateDb` is part of the setup, not a separate step: the baselines and the
16
+ * refusal records live in `app_settings`, and a suite that skipped it would read
17
+ * (and write) the developer's own database.
18
+ */
19
+
20
+ import fs from 'node:fs';
21
+ import { mkdtempSync } from 'node:fs';
22
+ import { tmpdir } from 'node:os';
23
+ import { join } from 'node:path';
24
+
25
+ import { invalidatePanelScan } from '@/server/lib/panels/registry.server';
26
+ import { hashTrackedFiles } from '@/server/lib/panels/local-edits.server';
27
+ import { writeBaseline } from '@/server/lib/panels/baseline.server';
28
+ import { isolateDb, releaseDb } from '@/test-support/isolated-db';
29
+
30
+ export interface SyncFixture {
31
+ /** The working marketplace root (installs live here). */
32
+ root: string;
33
+ /** The bundled store root (the package's copy). */
34
+ store: string;
35
+ /** A scratch directory for git repositories. */
36
+ work: string;
37
+ }
38
+
39
+ export const FIXTURE_MANIFEST = { id: 'demo', name: 'Demo', version: '1.0.0', app: 'dist/app.js' };
40
+
41
+ /** Write a plugin directory: manifest, bundle, and any extra files. */
42
+ export function writePlugin(dir: string, manifest: unknown, files: Record<string, string> = {}): void {
43
+ fs.mkdirSync(join(dir, 'dist'), { recursive: true });
44
+ fs.writeFileSync(join(dir, 'kabmo.json'), JSON.stringify(manifest));
45
+ fs.writeFileSync(join(dir, 'dist', 'app.js'), 'export default {};');
46
+ for (const [name, content] of Object.entries(files)) {
47
+ const target = join(dir, name);
48
+ fs.mkdirSync(join(target, '..'), { recursive: true });
49
+ fs.writeFileSync(target, content);
50
+ }
51
+ }
52
+
53
+ /** Create the fixture and point the process at it. Call from `beforeEach`. */
54
+ export async function setupSyncFixture(): Promise<SyncFixture> {
55
+ const fixture: SyncFixture = {
56
+ root: mkdtempSync(join(tmpdir(), 'omp-sync-work-')),
57
+ store: mkdtempSync(join(tmpdir(), 'omp-sync-store-')),
58
+ work: mkdtempSync(join(tmpdir(), 'omp-sync-repos-')),
59
+ };
60
+ process.env.KABMO_MARKETPLACE_DIR = fixture.root;
61
+ process.env.KABMO_BUNDLED_MARKETPLACE_DIR = fixture.store;
62
+ await isolateDb();
63
+ invalidatePanelScan();
64
+ return fixture;
65
+ }
66
+
67
+ /** Tear the fixture down. Call from `afterEach`. */
68
+ export async function teardownSyncFixture(fixture: SyncFixture): Promise<void> {
69
+ delete process.env.KABMO_MARKETPLACE_DIR;
70
+ delete process.env.KABMO_BUNDLED_MARKETPLACE_DIR;
71
+ await releaseDb();
72
+ invalidatePanelScan();
73
+ for (const dir of [fixture.root, fixture.store, fixture.work]) {
74
+ fs.rmSync(dir, { recursive: true, force: true });
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Install a bundled plugin the way the install path does: the store's copy is
80
+ * written into the working marketplace, and the baseline is taken from what
81
+ * landed. `files` therefore go into the STORE, or the install would carry a file
82
+ * the store never had — a state an install cannot produce.
83
+ */
84
+ export async function installBundled(
85
+ fixture: SyncFixture,
86
+ version: string,
87
+ files: Record<string, string> = {},
88
+ ): Promise<void> {
89
+ writePlugin(join(fixture.store, 'plugins', 'demo'), { ...FIXTURE_MANIFEST, version }, files);
90
+ writePlugin(join(fixture.root, 'plugins', 'demo'), { ...FIXTURE_MANIFEST, version }, files);
91
+ await writeBaseline('demo', await hashTrackedFiles(join(fixture.root, 'plugins', 'demo')));
92
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The local-edit gate. The sync REPLACES files, so a plugin whose tree no longer
8
+ * matches what the install wrote is refused — and the baseline has to be stable
9
+ * across the install's own build, or every install would read as edited the first
10
+ * time it built and the sync would never run again.
11
+ *
12
+ * What the baseline must NOT do is outrank the trees in front of it: see
13
+ * `baselineDescribesInstall`. A baseline that contradicts them describes a copy
14
+ * this install never had, and obeying it is what made a bundled update
15
+ * unrecoverable.
16
+ */
17
+
18
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
19
+ import fs from 'node:fs';
20
+ import { join } from 'node:path';
21
+
22
+ import { syncBundledPlugins } from '@/server/lib/panels/sync.server';
23
+ import { hashTrackedFiles } from '@/server/lib/panels/local-edits.server';
24
+ import { readBlockedSyncs, writeBaseline } from '@/server/lib/panels/baseline.server';
25
+ import {
26
+ FIXTURE_MANIFEST as MANIFEST,
27
+ installBundled,
28
+ setupSyncFixture,
29
+ teardownSyncFixture,
30
+ writePlugin,
31
+ type SyncFixture,
32
+ } from '@/server/lib/panels/sync.fixture.test-util';
33
+
34
+ let fixture: SyncFixture;
35
+ let root = '';
36
+ let store = '';
37
+
38
+ beforeEach(async () => {
39
+ fixture = await setupSyncFixture();
40
+ root = fixture.root;
41
+ store = fixture.store;
42
+ });
43
+
44
+ afterEach(async () => {
45
+ await teardownSyncFixture(fixture);
46
+ });
47
+
48
+ describe('local edit protection', () => {
49
+ test('an edited file blocks the sync and is named', async () => {
50
+ await installBundled(fixture, '1.0.0', { 'src/note.txt': 'original' });
51
+ fs.writeFileSync(join(root, 'plugins', 'demo', 'src', 'note.txt'), 'mine');
52
+ writePlugin(join(store, 'plugins', 'demo'), { ...MANIFEST, version: '2.0.0' });
53
+
54
+ const result = await syncBundledPlugins();
55
+ expect(result.updated).toEqual([]);
56
+ expect(result.blocked).toHaveLength(1);
57
+ expect(result.blocked[0].changed).toEqual(['src/note.txt']);
58
+ // The edit survives — that is the whole point.
59
+ expect(fs.readFileSync(join(root, 'plugins', 'demo', 'src', 'note.txt'), 'utf8')).toBe('mine');
60
+ // And the refusal is recorded for the pane.
61
+ expect((await readBlockedSyncs())['demo'].changed).toEqual(['src/note.txt']);
62
+ });
63
+
64
+ test('an added file blocks the sync', async () => {
65
+ await installBundled(fixture, '1.0.0');
66
+ fs.writeFileSync(join(root, 'plugins', 'demo', 'src-extra.js'), 'export default {};');
67
+ writePlugin(join(store, 'plugins', 'demo'), { ...MANIFEST, version: '2.0.0' });
68
+
69
+ const result = await syncBundledPlugins();
70
+ expect(result.blocked[0].changed).toContain('src-extra.js');
71
+ });
72
+
73
+ test('a deleted file blocks the sync', async () => {
74
+ await installBundled(fixture, '1.0.0', { 'src/note.txt': 'original' });
75
+ fs.rmSync(join(root, 'plugins', 'demo', 'src', 'note.txt'));
76
+ writePlugin(join(store, 'plugins', 'demo'), { ...MANIFEST, version: '2.0.0' });
77
+
78
+ expect((await syncBundledPlugins()).blocked[0].changed).toEqual(['src/note.txt']);
79
+ });
80
+
81
+ test('an unchanged tree syncs, and the new baseline allows the NEXT sync', async () => {
82
+ await installBundled(fixture, '1.0.0');
83
+ writePlugin(join(store, 'plugins', 'demo'), { ...MANIFEST, version: '2.0.0' });
84
+ expect((await syncBundledPlugins()).updated).toHaveLength(1);
85
+
86
+ // The baseline was re-taken after the build, so the tree the sync just wrote
87
+ // is not read as an edit — without this the pass would block itself forever.
88
+ writePlugin(join(store, 'plugins', 'demo'), { ...MANIFEST, version: '3.0.0' });
89
+ const second = await syncBundledPlugins();
90
+ expect(second.updated).toHaveLength(1);
91
+ expect(second.blocked).toEqual([]);
92
+ });
93
+
94
+ test('build output and lockfiles are not edits', async () => {
95
+ // A plugin WITH source: its `dist` is this build's output, so its hashes
96
+ // change on every build and tracking it would block the sync forever.
97
+ await installBundled(fixture, '1.0.0', { 'src/app.tsx': 'export default {};' });
98
+ fs.writeFileSync(join(root, 'plugins', 'demo', 'bun.lock'), '{}');
99
+ fs.mkdirSync(join(root, 'plugins', 'demo', 'node_modules', 'dep'), { recursive: true });
100
+ fs.writeFileSync(join(root, 'plugins', 'demo', 'node_modules', 'dep', 'package.json'), '{"name":"dep"}');
101
+ fs.writeFileSync(join(root, 'plugins', 'demo', 'dist', 'app.js'), 'export default { built: true };');
102
+ writePlugin(join(store, 'plugins', 'demo'), { ...MANIFEST, version: '2.0.0' }, { 'src/app.tsx': 'export default {};' });
103
+
104
+ const result = await syncBundledPlugins();
105
+ expect(result.blocked).toEqual([]);
106
+ expect(result.updated).toHaveLength(1);
107
+ });
108
+
109
+ test('an edited bundle of a plugin with no SOURCE blocks the sync', async () => {
110
+ // The shape this gate exists for: a plugin with no `src/` DISTRIBUTES its
111
+ // bundle, so `dist` is content the user can edit rather than build output.
112
+ // Excluding it let the sync silently overwrite the edit — measured against a
113
+ // live server before this case existed.
114
+ writePlugin(join(store, 'plugins', 'demo'), MANIFEST);
115
+ writePlugin(join(root, 'plugins', 'demo'), MANIFEST);
116
+ await writeBaseline('demo', await hashTrackedFiles(join(root, 'plugins', 'demo')));
117
+
118
+ fs.writeFileSync(join(root, 'plugins', 'demo', 'dist', 'app.js'), 'export default { MINE: true };');
119
+ writePlugin(join(store, 'plugins', 'demo'), { ...MANIFEST, version: '2.0.0' });
120
+
121
+ const result = await syncBundledPlugins();
122
+ expect(result.blocked).toHaveLength(1);
123
+ expect(result.blocked[0].changed).toEqual(['dist/app.js']);
124
+ expect(fs.readFileSync(join(root, 'plugins', 'demo', 'dist', 'app.js'), 'utf8')).toContain('MINE');
125
+ });
126
+
127
+ test('a plugin with no baseline runs, so an upgrade cannot freeze it', async () => {
128
+ // An install written before this check existed. Refusing would leave it on
129
+ // the old version forever with no way to say why.
130
+ writePlugin(join(root, 'plugins', 'demo'), MANIFEST);
131
+ writePlugin(join(store, 'plugins', 'demo'), { ...MANIFEST, version: '2.0.0' });
132
+
133
+ expect((await syncBundledPlugins()).updated).toHaveLength(1);
134
+ });
135
+
136
+ test('reverting the edit lets the next pass sync, with no user action', async () => {
137
+ await installBundled(fixture, '1.0.0', { 'src/note.txt': 'original' });
138
+ fs.writeFileSync(join(root, 'plugins', 'demo', 'src', 'note.txt'), 'mine');
139
+ writePlugin(join(store, 'plugins', 'demo'), { ...MANIFEST, version: '2.0.0' });
140
+ expect((await syncBundledPlugins()).blocked).toHaveLength(1);
141
+
142
+ fs.writeFileSync(join(root, 'plugins', 'demo', 'src', 'note.txt'), 'original');
143
+ const result = await syncBundledPlugins();
144
+ expect(result.updated).toHaveLength(1);
145
+ expect(result.blocked).toEqual([]);
146
+ // The refusal record is cleared, or the pane would keep reporting a fault
147
+ // the user has already resolved.
148
+ expect(await readBlockedSyncs()).toEqual({});
149
+ });
150
+ });
@@ -8,65 +8,48 @@
8
8
  *
9
9
  * Three properties decide whether this pass is safe to run on every boot, and
10
10
  * each of them is a failure that is invisible until it has already overwritten
11
- * something: the version gate (an older store must not roll an install back), the
12
- * git exclusion (a clone that shares an id with a store entry must not be
13
- * replaced by it), and the preservation of `node_modules`/`dist` (the store ships
14
- * no build output, so wiping those would leave the plugin unservable until a
15
- * rebuild).
11
+ * something: the provenance gate (a hand-copied directory must not be replaced
12
+ * just because a store entry shares its id), the git exclusion (a clone that
13
+ * shares an id must not be replaced by it), and the preservation of
14
+ * `node_modules`/`dist` (the store ships no build output, so wiping those would
15
+ * leave the plugin unservable until a rebuild).
16
+ *
17
+ * The local-edit gate has its own suite (`sync.local-edits.test.ts`).
16
18
  */
17
19
 
18
20
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
19
21
  import fs from 'node:fs';
20
- import { tmpdir } from 'node:os';
21
22
  import { join } from 'node:path';
22
23
 
23
24
  import { forceSyncBundledPlugin, syncBundledPlugins } from '@/server/lib/panels/sync.server';
24
25
  import { installPanelPluginFromGit } from '@/server/lib/panels/install.server';
25
- import { hashTrackedFiles } from '@/server/lib/panels/local-edits.server';
26
- import { readBlockedSyncs, writeBaseline } from '@/server/lib/panels/baseline.server';
27
- import { invalidatePanelScan } from '@/server/lib/panels/registry.server';
28
- import { isolateDb, releaseDb } from '@/test-support/isolated-db';
26
+ import { changedPaths, hashTrackedFiles } from '@/server/lib/panels/local-edits.server';
27
+ import { readBaseline, readBlockedSyncs, writeBaseline } from '@/server/lib/panels/baseline.server';
28
+ import {
29
+ FIXTURE_MANIFEST as MANIFEST,
30
+ installBundled,
31
+ setupSyncFixture,
32
+ teardownSyncFixture,
33
+ writePlugin,
34
+ type SyncFixture,
35
+ } from '@/server/lib/panels/sync.fixture.test-util';
29
36
 
30
37
  const hasGit = Boolean(Bun.which('git'));
31
38
 
39
+ let fixture: SyncFixture;
32
40
  let root = '';
33
41
  let store = '';
34
42
  let work = '';
35
43
 
36
- const MANIFEST = { id: 'demo', name: 'Demo', version: '1.0.0', app: 'dist/app.js' };
37
-
38
- /** A plugin directory: manifest, bundle, and optionally a marker file. */
39
- function writePlugin(dir: string, manifest: unknown, files: Record<string, string> = {}): void {
40
- fs.mkdirSync(join(dir, 'dist'), { recursive: true });
41
- fs.writeFileSync(join(dir, 'kabmo.json'), JSON.stringify(manifest));
42
- fs.writeFileSync(join(dir, 'dist', 'app.js'), 'export default {};');
43
- for (const [name, content] of Object.entries(files)) {
44
- const target = join(dir, name);
45
- fs.mkdirSync(join(target, '..'), { recursive: true });
46
- fs.writeFileSync(target, content);
47
- }
48
- }
49
-
50
44
  beforeEach(async () => {
51
- root = fs.mkdtempSync(join(tmpdir(), 'omp-sync-work-'));
52
- store = fs.mkdtempSync(join(tmpdir(), 'omp-sync-store-'));
53
- work = fs.mkdtempSync(join(tmpdir(), 'omp-sync-repos-'));
54
- process.env.KABMO_MARKETPLACE_DIR = root;
55
- process.env.KABMO_BUNDLED_MARKETPLACE_DIR = store;
56
- // The baseline and refusal records live in `app_settings`, so this suite needs
57
- // an isolated database: a shared handle would read the developer's real one.
58
- await isolateDb();
59
- invalidatePanelScan();
45
+ fixture = await setupSyncFixture();
46
+ root = fixture.root;
47
+ store = fixture.store;
48
+ work = fixture.work;
60
49
  });
61
50
 
62
51
  afterEach(async () => {
63
- delete process.env.KABMO_MARKETPLACE_DIR;
64
- delete process.env.KABMO_BUNDLED_MARKETPLACE_DIR;
65
- await releaseDb();
66
- invalidatePanelScan();
67
- fs.rmSync(root, { recursive: true, force: true });
68
- fs.rmSync(store, { recursive: true, force: true });
69
- fs.rmSync(work, { recursive: true, force: true });
52
+ await teardownSyncFixture(fixture);
70
53
  });
71
54
 
72
55
  describe('syncBundledPlugins', () => {
@@ -95,7 +78,8 @@ describe('syncBundledPlugins', () => {
95
78
  expect(fs.readFileSync(join(root, 'plugins', 'demo', 'src', 'marker.txt'), 'utf8')).toBe('installed');
96
79
  });
97
80
 
98
- test('leaves an equal version alone', async () => {
81
+ test('leaves an equal version alone when Kabmo cannot prove it wrote the install', async () => {
82
+ // No baseline, so provenance is unknown and the version gate governs.
99
83
  writePlugin(join(root, 'plugins', 'demo'), MANIFEST, { 'src/marker.txt': 'installed' });
100
84
  writePlugin(join(store, 'plugins', 'demo'), MANIFEST, { 'src/marker.txt': 'store' });
101
85
 
@@ -167,142 +151,136 @@ describe('syncBundledPlugins', () => {
167
151
  });
168
152
 
169
153
  /**
170
- * The local-edit gate. The sync REPLACES files, so a plugin whose tree no longer
171
- * matches what the install wrote is refused — and the baseline has to be stable
172
- * across the install's own build, or every install would read as edited the first
173
- * time it built and the sync would never run again.
154
+ * The reported bug: a store change that did NOT move the manifest version was
155
+ * never applied, so the only way to get it was to remove the plugin and install
156
+ * it again.
157
+ *
158
+ * Measured on a real install: the product rename rewrote every bundled file AND
159
+ * reset the versions to `0.0.1`, so the version gate saw "not newer" forever
160
+ * while the recorded baseline still described the pre-rename files — which made
161
+ * the local-edit gate report the store's own updates as the user's edits.
174
162
  */
175
- describe('local edit protection', () => {
176
- /**
177
- * Install a bundled plugin the way the install path does: the store's copy is
178
- * written into the working marketplace, and the baseline is taken from what
179
- * landed. `files` therefore go into the STORE, or the install would carry a
180
- * file the store never had — a state an install cannot produce.
181
- */
182
- async function installBundled(version: string, files: Record<string, string> = {}): Promise<void> {
183
- writePlugin(join(store, 'plugins', 'demo'), { ...MANIFEST, version }, files);
184
- writePlugin(join(root, 'plugins', 'demo'), { ...MANIFEST, version }, files);
185
- await writeBaseline('demo', await hashTrackedFiles(join(root, 'plugins', 'demo')));
186
- }
187
-
188
- test('an edited file blocks the sync and is named', async () => {
189
- await installBundled('1.0.0', { 'src/note.txt': 'original' });
190
- fs.writeFileSync(join(root, 'plugins', 'demo', 'src', 'note.txt'), 'mine');
191
- writePlugin(join(store, 'plugins', 'demo'), { ...MANIFEST, version: '2.0.0' });
163
+ describe('store content that did not bump its version', () => {
164
+ test('a fix shipped without a version bump is applied', async () => {
165
+ await installBundled(fixture, '1.0.0', { 'src/app.tsx': 'export default { old: true };' });
166
+ // The store changes, the version does not — a shipped fix whose bump was
167
+ // missed, which is exactly what left users stuck.
168
+ writePlugin(join(store, 'plugins', 'demo'), { ...MANIFEST, version: '1.0.0' }, {
169
+ 'src/app.tsx': 'export default { fixed: true };',
170
+ });
192
171
 
193
172
  const result = await syncBundledPlugins();
194
- expect(result.updated).toEqual([]);
195
- expect(result.blocked).toHaveLength(1);
196
- expect(result.blocked[0].changed).toEqual(['src/note.txt']);
197
- // The edit survives — that is the whole point.
198
- expect(fs.readFileSync(join(root, 'plugins', 'demo', 'src', 'note.txt'), 'utf8')).toBe('mine');
199
- // And the refusal is recorded for the pane.
200
- expect((await readBlockedSyncs())['demo'].changed).toEqual(['src/note.txt']);
173
+ expect(result.blocked).toEqual([]);
174
+ expect(result.updated).toHaveLength(1);
175
+ expect(result.updated[0]).toMatchObject({ pluginId: 'demo', contentOnly: true });
176
+ expect(fs.readFileSync(join(root, 'plugins', 'demo', 'src', 'app.tsx'), 'utf8')).toContain('fixed');
201
177
  });
202
178
 
203
- test('an added file blocks the sync', async () => {
204
- await installBundled('1.0.0');
205
- fs.writeFileSync(join(root, 'plugins', 'demo', 'src-extra.js'), 'export default {};');
206
- writePlugin(join(store, 'plugins', 'demo'), { ...MANIFEST, version: '2.0.0' });
207
-
208
- const result = await syncBundledPlugins();
209
- expect(result.blocked[0].changed).toContain('src-extra.js');
210
- });
179
+ test('a stale baseline does not read as the user editing every file', async () => {
180
+ // The install and the store already agree, but the recorded baseline
181
+ // describes a copy that no longer exists — the state the rename produced.
182
+ writePlugin(join(store, 'plugins', 'demo'), MANIFEST, { 'src/app.tsx': 'export default { current: true };' });
183
+ writePlugin(join(root, 'plugins', 'demo'), MANIFEST, { 'src/app.tsx': 'export default { current: true };' });
184
+ await writeBaseline('demo', { 'src/app.tsx': 'a-hash-of-a-file-that-is-gone', 'package.json': 'also-gone' });
211
185
 
212
- test('a deleted file blocks the sync', async () => {
213
- await installBundled('1.0.0', { 'src/note.txt': 'original' });
214
- fs.rmSync(join(root, 'plugins', 'demo', 'src', 'note.txt'));
215
- writePlugin(join(store, 'plugins', 'demo'), { ...MANIFEST, version: '2.0.0' });
186
+ // Now the store moves on again. The stale baseline must not block it.
187
+ writePlugin(join(store, 'plugins', 'demo'), MANIFEST, { 'src/app.tsx': 'export default { newer: true };' });
216
188
 
217
- expect((await syncBundledPlugins()).blocked[0].changed).toEqual(['src/note.txt']);
189
+ const result = await syncBundledPlugins();
190
+ expect(result.blocked).toEqual([]);
191
+ expect(result.updated).toHaveLength(1);
192
+ expect(fs.readFileSync(join(root, 'plugins', 'demo', 'src', 'app.tsx'), 'utf8')).toContain('newer');
218
193
  });
219
194
 
220
- test('an unchanged tree syncs, and the new baseline allows the NEXT sync', async () => {
221
- await installBundled('1.0.0');
222
- writePlugin(join(store, 'plugins', 'demo'), { ...MANIFEST, version: '2.0.0' });
223
- expect((await syncBundledPlugins()).updated).toHaveLength(1);
195
+ test('a stale baseline is healed even when the store has nothing new', async () => {
196
+ // The exact state the rename left on a real install: the store and the
197
+ // install already agree, and the baseline describes neither. Nothing is
198
+ // applied, so this pass must still re-record the baseline — otherwise the
199
+ // NEXT release's fix reads as a user edit on every path and the plugin is
200
+ // stuck until it is reinstalled.
201
+ writePlugin(join(store, 'plugins', 'demo'), MANIFEST, { 'src/app.tsx': 'export default { current: true };' });
202
+ writePlugin(join(root, 'plugins', 'demo'), MANIFEST, { 'src/app.tsx': 'export default { current: true };' });
203
+ await writeBaseline('demo', { 'src/app.tsx': 'a-hash-of-a-file-that-is-gone' });
224
204
 
225
- // The baseline was re-taken after the build, so the tree the sync just wrote
226
- // is not read as an edit — without this the pass would block itself forever.
227
- writePlugin(join(store, 'plugins', 'demo'), { ...MANIFEST, version: '3.0.0' });
228
- const second = await syncBundledPlugins();
229
- expect(second.updated).toHaveLength(1);
230
- expect(second.blocked).toEqual([]);
205
+ expect((await syncBundledPlugins()).updated).toEqual([]);
206
+ const healed = await readBaseline('demo');
207
+ expect(healed).not.toBeNull();
208
+ expect(changedPaths(healed ?? {}, await hashTrackedFiles(join(root, 'plugins', 'demo')))).toEqual([]);
209
+
210
+ // And the next release's fix then applies with no further action.
211
+ writePlugin(join(store, 'plugins', 'demo'), MANIFEST, { 'src/app.tsx': 'export default { newer: true };' });
212
+ const next = await syncBundledPlugins();
213
+ expect(next.blocked).toEqual([]);
214
+ expect(next.updated).toHaveLength(1);
231
215
  });
232
216
 
233
- test('build output and lockfiles are not edits', async () => {
234
- // A plugin WITH source: its `dist` is this build's output, so its hashes
235
- // change on every build and tracking it would block the sync forever.
236
- await installBundled('1.0.0', { 'src/app.tsx': 'export default {};' });
237
- fs.writeFileSync(join(root, 'plugins', 'demo', 'bun.lock'), '{}');
238
- fs.mkdirSync(join(root, 'plugins', 'demo', 'node_modules', 'dep'), { recursive: true });
239
- fs.writeFileSync(join(root, 'plugins', 'demo', 'node_modules', 'dep', 'package.json'), '{"name":"dep"}');
240
- fs.writeFileSync(join(root, 'plugins', 'demo', 'dist', 'app.js'), 'export default { built: true };');
241
- writePlugin(join(store, 'plugins', 'demo'), { ...MANIFEST, version: '2.0.0' }, { 'src/app.tsx': 'export default {};' });
217
+ test('a real edit still blocks, even with a trusted baseline', async () => {
218
+ // The healing above must not become "overwrite whatever the user wrote":
219
+ // an install that differs from BOTH the baseline and the store is the
220
+ // user's own work.
221
+ await installBundled(fixture, '1.0.0', { 'src/note.txt': 'original' });
222
+ fs.writeFileSync(join(root, 'plugins', 'demo', 'src', 'note.txt'), 'mine');
223
+ writePlugin(join(store, 'plugins', 'demo'), { ...MANIFEST, version: '1.0.0' }, { 'src/note.txt': 'original' });
242
224
 
243
225
  const result = await syncBundledPlugins();
244
- expect(result.blocked).toEqual([]);
245
- expect(result.updated).toHaveLength(1);
226
+ expect(result.updated).toEqual([]);
227
+ expect(result.blocked[0].changed).toEqual(['src/note.txt']);
228
+ expect(fs.readFileSync(join(root, 'plugins', 'demo', 'src', 'note.txt'), 'utf8')).toBe('mine');
246
229
  });
247
230
 
248
- test('an edited bundle of a plugin with no SOURCE blocks the sync', async () => {
249
- // The shape this gate exists for: a plugin with no `src/` DISTRIBUTES its
250
- // bundle, so `dist` is content the user can edit rather than build output.
251
- // Excluding it let the sync silently overwrite the edit — measured against a
252
- // live server before this case existed.
253
- writePlugin(join(store, 'plugins', 'demo'), MANIFEST);
254
- writePlugin(join(root, 'plugins', 'demo'), MANIFEST);
255
- await writeBaseline('demo', await hashTrackedFiles(join(root, 'plugins', 'demo')));
256
-
257
- fs.writeFileSync(join(root, 'plugins', 'demo', 'dist', 'app.js'), 'export default { MINE: true };');
258
- writePlugin(join(store, 'plugins', 'demo'), { ...MANIFEST, version: '2.0.0' });
231
+ test('a hand-copied directory still keeps the version gate', async () => {
232
+ // No baseline means unknown provenance: an equal-version store entry must
233
+ // not replace a directory Kabmo cannot prove it wrote.
234
+ writePlugin(join(root, 'plugins', 'demo'), MANIFEST, { 'src/note.txt': 'mine' });
235
+ writePlugin(join(store, 'plugins', 'demo'), MANIFEST, { 'src/note.txt': 'store' });
259
236
 
260
- const result = await syncBundledPlugins();
261
- expect(result.blocked).toHaveLength(1);
262
- expect(result.blocked[0].changed).toEqual(['dist/app.js']);
263
- expect(fs.readFileSync(join(root, 'plugins', 'demo', 'dist', 'app.js'), 'utf8')).toContain('MINE');
237
+ expect((await syncBundledPlugins()).updated).toEqual([]);
238
+ expect(fs.readFileSync(join(root, 'plugins', 'demo', 'src', 'note.txt'), 'utf8')).toBe('mine');
264
239
  });
265
240
 
266
- test('a plugin with no baseline runs, so an upgrade cannot freeze it', async () => {
267
- // An install written before this check existed. Refusing would leave it on
268
- // the old version forever with no way to say why.
269
- writePlugin(join(root, 'plugins', 'demo'), MANIFEST);
270
- writePlugin(join(store, 'plugins', 'demo'), { ...MANIFEST, version: '2.0.0' });
271
-
241
+ test('a second pass is a no-op once the copy has landed', async () => {
242
+ await installBundled(fixture, '1.0.0', { 'src/app.tsx': 'export default { old: true };' });
243
+ writePlugin(join(store, 'plugins', 'demo'), { ...MANIFEST, version: '1.0.0' }, {
244
+ 'src/app.tsx': 'export default { fixed: true };',
245
+ });
272
246
  expect((await syncBundledPlugins()).updated).toHaveLength(1);
273
- });
274
247
 
275
- test('reverting the edit lets the next pass sync, with no user action', async () => {
276
- await installBundled('1.0.0', { 'src/note.txt': 'original' });
277
- fs.writeFileSync(join(root, 'plugins', 'demo', 'src', 'note.txt'), 'mine');
278
- writePlugin(join(store, 'plugins', 'demo'), { ...MANIFEST, version: '2.0.0' });
279
- expect((await syncBundledPlugins()).blocked).toHaveLength(1);
248
+ // The baseline was re-taken after the build, so the pass that just ran must
249
+ // not be read as a difference on the next boot — a sync that re-applies
250
+ // forever would rebuild the plugin on every start.
251
+ expect((await syncBundledPlugins()).updated).toEqual([]);
252
+ });
253
+ });
280
254
 
281
- fs.writeFileSync(join(root, 'plugins', 'demo', 'src', 'note.txt'), 'original');
282
- const result = await syncBundledPlugins();
283
- expect(result.updated).toHaveLength(1);
284
- expect(result.blocked).toEqual([]);
285
- // The refusal record is cleared, or the pane would keep reporting a fault
286
- // the user has already resolved.
287
- expect(await readBlockedSyncs()).toEqual({});
255
+ /**
256
+ * The override: the user has seen which files differ and asked for the store's
257
+ * copy anyway.
258
+ */
259
+ describe('forceSyncBundledPlugin', () => {
260
+ test('refuses a plugin that is not a bundled install', async () => {
261
+ writePlugin(join(root, 'plugins', 'demo'), MANIFEST);
262
+ const result = await forceSyncBundledPlugin('demo');
263
+ expect(result.ok).toBe(false);
264
+ expect(result.error).toContain('No bundled plugin');
288
265
  });
289
266
 
290
- test('the override replaces the edited files and clears the refusal', async () => {
291
- await installBundled('1.0.0', { 'src/note.txt': 'original' });
267
+ test('applies the store copy even when its version is not newer', async () => {
268
+ // The case the override most often exists for: a stale baseline refusing an
269
+ // update whose version did not move. Clearing the baseline and re-running
270
+ // the pass made this a no-op, because a cleared baseline reads as unknown
271
+ // provenance and the version gate then declines.
272
+ await installBundled(fixture, '1.0.0', { 'src/note.txt': 'original' });
292
273
  fs.writeFileSync(join(root, 'plugins', 'demo', 'src', 'note.txt'), 'mine');
293
- writePlugin(join(store, 'plugins', 'demo'), { ...MANIFEST, version: '2.0.0' });
294
274
  expect((await syncBundledPlugins()).blocked).toHaveLength(1);
295
275
 
296
276
  const forced = await forceSyncBundledPlugin('demo');
297
277
  expect(forced.ok).toBe(true);
298
278
  expect(fs.readFileSync(join(root, 'plugins', 'demo', 'src', 'note.txt'), 'utf8')).toBe('original');
299
279
  expect(await readBlockedSyncs()).toEqual({});
300
- });
301
-
302
- test('the override refuses a plugin that is not a bundled install', async () => {
303
- writePlugin(join(root, 'plugins', 'demo'), MANIFEST);
304
- const result = await forceSyncBundledPlugin('demo');
305
- expect(result.ok).toBe(false);
306
- expect(result.error).toContain('No bundled plugin');
280
+ // The baseline now describes what the override wrote, so the next pass is
281
+ // not read as a fresh edit.
282
+ const after = await hashTrackedFiles(join(root, 'plugins', 'demo'));
283
+ expect((await syncBundledPlugins()).blocked).toEqual([]);
284
+ expect(after['src/note.txt']).toBeDefined();
307
285
  });
308
286
  });