@kabmo-labs/kabmo 0.5.2 → 0.6.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/dist/client/index.html +1 -1
- package/dist/client/index.js +359 -350
- package/dist/client/static/js/{index-zcvn50cf.js → index-ae9z0yvm.js} +703 -703
- package/dist/client/static/js/index-nzyjf92m.css +1 -0
- package/marketplace/plugins/stats/README.md +26 -4
- package/marketplace/plugins/stats/package.json +2 -2
- package/marketplace/plugins/stats/src/HeaderReadout.test.tsx +157 -0
- package/marketplace/plugins/stats/src/HeaderReadout.tsx +115 -0
- package/marketplace/plugins/stats/src/app.tsx +11 -0
- package/marketplace/plugins/stats/src/format.test.ts +27 -1
- package/marketplace/plugins/stats/src/format.ts +41 -0
- package/package.json +2 -2
- package/src/client/bootstrap.d.ts +11 -0
- package/src/client/components/common/header-controls/MoreMenu.tsx +38 -1
- package/src/client/components/common/header-controls/index.tsx +9 -2
- package/src/client/components/common/header-controls/plugin-entry.test.tsx +229 -0
- package/src/client/components/common/header-panel-buttons/entries.test.ts +46 -0
- package/src/client/components/common/header-panel-buttons/entries.ts +109 -0
- package/src/client/components/common/header-panel-buttons/index.tsx +71 -38
- package/src/client/components/common/server-switcher/index.test.tsx +150 -0
- package/src/client/components/common/server-switcher/index.tsx +281 -0
- package/src/client/components/common/window-controls/index.test.tsx +113 -0
- package/src/client/components/common/window-controls/index.tsx +66 -0
- package/src/client/components/layout/desktop-layout/LeftColumn.tsx +3 -6
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +59 -3
- package/src/client/components/layout/desktop-layout/index.tsx +34 -26
- package/src/client/components/layout/session-sidebar/Footer.tsx +12 -12
- package/src/client/components/layout/session-sidebar/Header.tsx +4 -2
- package/src/client/components/mobile/LayoutWrapper.tsx +36 -0
- package/src/client/components/mobile/RightSidebar.tsx +2 -2
- package/src/client/components/mobile/SessionSidebar.tsx +10 -15
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +15 -12
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +9 -2
- package/src/client/components/mobile/mobile-session-sidebar/folder-gap.test.ts +41 -0
- package/src/client/components/mobile/mobile-session-sidebar/folder-gap.ts +39 -0
- package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +6 -6
- package/src/client/components/settings/categories/OmpSettings.tsx +25 -29
- package/src/client/components/settings/categories/omp-settings/PresetBar.tsx +187 -0
- package/src/client/components/settings/categories/omp-settings/RoleEditor.test.ts +260 -32
- package/src/client/components/settings/categories/omp-settings/RoleEditor.tsx +198 -102
- package/src/client/components/settings/categories/omp-settings/RoleRow.tsx +79 -0
- package/src/client/data/settings/omp-schema.json +2 -2
- package/src/client/hooks/chat/omp/session-list.test.ts +0 -24
- package/src/client/hooks/desktop/servers.test.ts +73 -0
- package/src/client/hooks/desktop/servers.ts +126 -0
- package/src/client/hooks/settings/model-roles.ts +156 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +157 -0
- package/src/client/hooks/ui/updates/shell-download.ts +220 -0
- package/src/client/hooks/workspace/keybindings.ts +44 -0
- package/src/client/hooks/workspace/open-panel.ts +42 -0
- package/src/client/lib/desktop/bridge.ts +158 -0
- package/src/client/lib/desktop/titlebar.test.ts +99 -0
- package/src/client/lib/desktop/titlebar.ts +97 -0
- package/src/client/lib/plugins/slots.test.ts +54 -0
- package/src/client/lib/plugins/slots.ts +37 -1
- package/src/server/db.server.test.ts +44 -3
- package/src/server/db.server.ts +26 -2
- package/src/server/lib/bundler/build-errors.test.ts +0 -6
- package/src/server/lib/bundler/titlebar-css.test.ts +75 -0
- package/src/server/lib/db/schema.ts +3 -0
- package/src/server/lib/model-roles/catalog.server.test.ts +75 -0
- package/src/server/lib/model-roles/catalog.server.ts +112 -0
- package/src/server/lib/model-roles/mock.server.ts +40 -0
- package/src/server/lib/model-roles/schema.server.ts +36 -0
- package/src/server/lib/model-roles/store.server.test.ts +218 -0
- package/src/server/lib/model-roles/store.server.ts +188 -0
- package/src/server/lib/omp/rpc/destroy-stream-row.test.ts +4 -4
- package/src/server/lib/omp/rpc/frame-fold.test.ts +30 -1
- package/src/server/lib/omp/rpc/frame-fold.ts +6 -1
- package/src/server/lib/omp/rpc/manager.ts +18 -13
- package/src/server/lib/omp/rpc/session-command-guards.ts +4 -0
- package/src/server/lib/omp/rpc/session-commands.test.ts +24 -0
- package/src/server/lib/omp/rpc/session-commands.ts +7 -0
- package/src/server/lib/omp/session/auto-title.server.test.ts +147 -93
- package/src/server/lib/omp/session/auto-title.server.ts +150 -120
- package/src/server/lib/omp/session/rename-with-ai.server.test.ts +18 -6
- package/src/server/lib/omp/session/rename-with-ai.server.ts +36 -20
- package/src/server/lib/omp/session/title-model-overlay.server.test.ts +29 -14
- package/src/server/lib/omp/session/title-model-overlay.server.ts +46 -25
- package/src/server/lib/omp/session/title-request.server.test.ts +183 -0
- package/src/server/lib/omp/session/title-request.server.ts +213 -0
- package/src/server/lib/panels/baseline.server.test.ts +4 -4
- package/src/server/lib/panels/compat.server.test.ts +4 -4
- package/src/server/lib/panels/registry-bundled.server.test.ts +4 -4
- package/src/server/lib/panels/registry.server.test.ts +4 -4
- package/src/server/lib/panels/state.server.test.ts +4 -4
- package/src/server/lib/panels/sync.server.test.ts +4 -4
- package/src/server/lib/plugins/background.test.ts +2 -2
- package/src/server/lib/plugins/e2e.test.ts +2 -2
- package/src/server/lib/plugins/platform.test.ts +2 -2
- package/src/server/lib/queue/delivery-dispatch.server.test.ts +34 -0
- package/src/server/lib/queue/delivery.server.ts +11 -2
- package/src/server/lib/updates/shell-release.test.ts +184 -0
- package/src/server/lib/updates/shell-release.ts +215 -0
- package/src/server/lib/waiting/store.server.test.ts +2 -2
- package/src/server/plugins/ssr.ts +13 -10
- package/src/server/routes/chat/stream.test.ts +4 -4
- package/src/server/routes/fs/git-merge.test.ts +0 -11
- package/src/server/routes/models/roles.ts +73 -14
- package/src/server/routes/panels/css.test.ts +4 -4
- package/src/server/routes/panels/panel-routes.test-util.ts +3 -3
- package/src/server/routes/panels/routes.test.ts +0 -7
- package/src/server/routes/plugins/routes.test.ts +4 -4
- package/src/server/routes/sessions/batch-delete.test.ts +4 -4
- package/src/server/routes/updates/android.ts +2 -2
- package/src/server/routes/updates/index.ts +2 -0
- package/src/server/routes/updates/ios.ts +26 -0
- package/src/server/routes/waiting/routes.test.ts +2 -2
- package/src/shared/lib/models/server-cache.ts +11 -0
- package/src/shared/lib/omp/config/model-roles.ts +129 -0
- package/src/shared/lib/updates/shell-ua.test.ts +56 -24
- package/src/shared/lib/updates/shell-ua.ts +41 -20
- package/src/shared/lib/workspace/mobile-dock.test.ts +7 -13
- package/src/shared/lib/workspace/mobile-dock.ts +15 -6
- package/src/shared/lib/workspace/panel-open.test.ts +56 -0
- package/src/shared/lib/workspace/panel-open.ts +76 -0
- package/src/shared/types/model-roles.ts +54 -0
- package/src/test-support/isolated-db.ts +17 -5
- package/dist/client/static/js/index-3qamd33d.css +0 -1
- package/src/client/components/layout/desktop-layout/left-column.test.tsx +0 -192
- package/src/client/hooks/ui/updates/android-download.test.ts +0 -112
- package/src/client/hooks/ui/updates/android-download.ts +0 -143
- package/src/server/lib/updates/android-release.test.ts +0 -138
- package/src/server/lib/updates/android-release.ts +0 -183
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import { fetchShellRelease, resetShellReleaseCache, versionFromAssetName } from '@/server/lib/updates/shell-release';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The asset name is the only place a shell artifact's version exists: the
|
|
12
|
+
* release TAG names the SERVER version, and the two move independently. A parser
|
|
13
|
+
* that read the tag would compare a server version against a shell version.
|
|
14
|
+
*
|
|
15
|
+
* Both shells are pinned here, because they share the parser and differ only by
|
|
16
|
+
* prefix/extension — the shape that silently accepts the other shell's asset.
|
|
17
|
+
*/
|
|
18
|
+
describe('versionFromAssetName', () => {
|
|
19
|
+
test('reads the version the release workflows write', () => {
|
|
20
|
+
expect(versionFromAssetName('android', 'kabmo-android-v1.2.3.apk')).toBe('1.2.3');
|
|
21
|
+
expect(versionFromAssetName('ios', 'kabmo-ios-v1.2.3.ipa')).toBe('1.2.3');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('accepts a hand-uploaded name without the v', () => {
|
|
25
|
+
expect(versionFromAssetName('android', 'kabmo-android-1.2.3.apk')).toBe('1.2.3');
|
|
26
|
+
expect(versionFromAssetName('ios', 'kabmo-ios-1.2.3.ipa')).toBe('1.2.3');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('ignores anything that is not that shell’s artifact', () => {
|
|
30
|
+
expect(versionFromAssetName('android', 'kabmo-android-v1.2.3.apk.zip')).toBeNull();
|
|
31
|
+
expect(versionFromAssetName('android', 'source.tar.gz')).toBeNull();
|
|
32
|
+
expect(versionFromAssetName('android', 'kabmo-android-latest.apk')).toBeNull();
|
|
33
|
+
expect(versionFromAssetName('android', '')).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('does not accept one shell’s asset for the other', () => {
|
|
37
|
+
// The two probes read the SAME release list, so a parser that ignored the
|
|
38
|
+
// kind would let an iOS probe answer with the APK (and offer an `.apk` to an
|
|
39
|
+
// iPhone).
|
|
40
|
+
expect(versionFromAssetName('ios', 'kabmo-android-v1.2.3.apk')).toBeNull();
|
|
41
|
+
expect(versionFromAssetName('android', 'kabmo-ios-v1.2.3.ipa')).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** A fetch double that answers one body, and counts its calls. */
|
|
46
|
+
function stubFetch(body: unknown, ok = true) {
|
|
47
|
+
const calls: string[] = [];
|
|
48
|
+
const impl = (async (input: string | URL | Request) => {
|
|
49
|
+
calls.push(String(input));
|
|
50
|
+
return {
|
|
51
|
+
ok,
|
|
52
|
+
json: async () => body,
|
|
53
|
+
} as Response;
|
|
54
|
+
}) as unknown as typeof fetch;
|
|
55
|
+
return { impl, calls };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('fetchShellRelease', () => {
|
|
59
|
+
// The cache is process-wide (see `resetShellReleaseCache`), so every case
|
|
60
|
+
// starts from a cold one or it reads the previous case's answer.
|
|
61
|
+
beforeEach(() => resetShellReleaseCache());
|
|
62
|
+
|
|
63
|
+
test('picks the APK asset and its version', async () => {
|
|
64
|
+
const { impl } = stubFetch([
|
|
65
|
+
{
|
|
66
|
+
html_url: 'https://github.com/kabmo-labs/kabmo/releases/tag/v9.9.9',
|
|
67
|
+
assets: [
|
|
68
|
+
{ name: 'source.tar.gz', browser_download_url: 'https://x/src.tgz' },
|
|
69
|
+
{
|
|
70
|
+
name: 'kabmo-android-v1.2.3.apk',
|
|
71
|
+
browser_download_url: 'https://x/kabmo-android-v1.2.3.apk',
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
expect(await fetchShellRelease('android', impl)).toEqual({
|
|
78
|
+
url: 'https://x/kabmo-android-v1.2.3.apk',
|
|
79
|
+
latest: '1.2.3',
|
|
80
|
+
releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v9.9.9',
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('picks the IPA asset for the iOS probe', async () => {
|
|
85
|
+
const { impl } = stubFetch([
|
|
86
|
+
{
|
|
87
|
+
html_url: 'https://x/app',
|
|
88
|
+
assets: [
|
|
89
|
+
{ name: 'kabmo-android-v0.9.0.apk', browser_download_url: 'https://x/a.apk' },
|
|
90
|
+
{ name: 'kabmo-ios-v0.4.0.ipa', browser_download_url: 'https://x/i.ipa' },
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
expect(await fetchShellRelease('ios', impl)).toEqual({
|
|
96
|
+
url: 'https://x/i.ipa',
|
|
97
|
+
latest: '0.4.0',
|
|
98
|
+
releaseUrl: 'https://x/app',
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('the two shells cache separately', async () => {
|
|
103
|
+
// One shared slot would make the second probe answer with the first one's
|
|
104
|
+
// artifact — the release list is the same read, the asset is not.
|
|
105
|
+
const release = {
|
|
106
|
+
html_url: 'https://x/app',
|
|
107
|
+
assets: [
|
|
108
|
+
{ name: 'kabmo-android-v1.0.0.apk', browser_download_url: 'https://x/a.apk' },
|
|
109
|
+
{ name: 'kabmo-ios-v2.0.0.ipa', browser_download_url: 'https://x/i.ipa' },
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
expect((await fetchShellRelease('android', stubFetch([release]).impl))?.latest).toBe('1.0.0');
|
|
113
|
+
expect((await fetchShellRelease('ios', stubFetch([release]).impl))?.latest).toBe('2.0.0');
|
|
114
|
+
|
|
115
|
+
// …and a reset of one leaves the other's answer in place.
|
|
116
|
+
resetShellReleaseCache('android');
|
|
117
|
+
const { impl, calls } = stubFetch([release]);
|
|
118
|
+
expect((await fetchShellRelease('ios', impl))?.latest).toBe('2.0.0');
|
|
119
|
+
expect(calls, 'a cached answer must not re-read GitHub').toHaveLength(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('skips a newer release that carries no artifact', async () => {
|
|
123
|
+
// This is the shape that broke the button: `release.yml` releases the npm
|
|
124
|
+
// packages FIRST, so `kabmo-sdk/v0.0.2` is newer than the app release that
|
|
125
|
+
// holds the shell. Asking `/releases/latest` answered the package release and
|
|
126
|
+
// the button vanished while the artifact sat one entry below.
|
|
127
|
+
const { impl } = stubFetch([
|
|
128
|
+
{ html_url: 'https://x/kabmo-sdk', assets: [{ name: 'source.tar.gz' }] },
|
|
129
|
+
{ html_url: 'https://x/kabmo-ui', assets: [] },
|
|
130
|
+
{
|
|
131
|
+
html_url: 'https://x/app',
|
|
132
|
+
assets: [{ name: 'kabmo-android-v0.3.0.apk', browser_download_url: 'https://x/a.apk' }],
|
|
133
|
+
},
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
expect(await fetchShellRelease('android', impl)).toEqual({
|
|
137
|
+
url: 'https://x/a.apk',
|
|
138
|
+
latest: '0.3.0',
|
|
139
|
+
releaseUrl: 'https://x/app',
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('skips drafts and prereleases', async () => {
|
|
144
|
+
const { impl } = stubFetch([
|
|
145
|
+
{
|
|
146
|
+
draft: true,
|
|
147
|
+
html_url: 'https://x/draft',
|
|
148
|
+
assets: [{ name: 'kabmo-android-v9.9.9.apk', browser_download_url: 'https://x/draft.apk' }],
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
prerelease: true,
|
|
152
|
+
html_url: 'https://x/pre',
|
|
153
|
+
assets: [{ name: 'kabmo-android-v8.0.0.apk', browser_download_url: 'https://x/pre.apk' }],
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
html_url: 'https://x/app',
|
|
157
|
+
assets: [{ name: 'kabmo-android-v0.3.0.apk', browser_download_url: 'https://x/a.apk' }],
|
|
158
|
+
},
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
expect((await fetchShellRelease('android', impl))?.latest).toBe('0.3.0');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('answers an empty result for a list with no artifact anywhere', async () => {
|
|
165
|
+
const { impl } = stubFetch([
|
|
166
|
+
{ html_url: 'https://x', assets: [{ name: 'notes.md' }] },
|
|
167
|
+
{ html_url: 'https://y', assets: [] },
|
|
168
|
+
]);
|
|
169
|
+
expect(await fetchShellRelease('android', impl)).toEqual({ url: null, latest: null, releaseUrl: null });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('answers null when the probe fails', async () => {
|
|
173
|
+
// An offline box or a rate-limited GitHub must not take the sidebar down,
|
|
174
|
+
// and a failure must not be read as "no newer version" either — it is
|
|
175
|
+
// simply no offer.
|
|
176
|
+
const { impl } = stubFetch({}, false);
|
|
177
|
+
expect(await fetchShellRelease('android', impl)).toBeNull();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('answers null when the body is malformed', async () => {
|
|
181
|
+
const { impl } = stubFetch({ assets: 'nope' });
|
|
182
|
+
expect(await fetchShellRelease('android', impl)).toBeNull();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which published native-shell artifact a browser or an installed shell should
|
|
3
|
+
* download, and whether the installed one is behind.
|
|
4
|
+
*
|
|
5
|
+
* Neither shell is an npm package (see `.github/workflows/publish-android.yml`
|
|
6
|
+
* and `publish-ios.yml`), so the npm probe that answers for the app and for
|
|
7
|
+
* `omp` cannot answer here. The only record of a published shell is a GitHub
|
|
8
|
+
* release asset, which means one unauthenticated REST read of the release LIST —
|
|
9
|
+
* not `/releases/latest`, which answers the newest release of ANY kind (see
|
|
10
|
+
* `fetchShellRelease`).
|
|
11
|
+
*
|
|
12
|
+
* ONE module for both shells, because the question is identical and only the
|
|
13
|
+
* asset naming differs. Two copies is how one of them starts reading the release
|
|
14
|
+
* tag, or caching under a slot the other overwrites.
|
|
15
|
+
*
|
|
16
|
+
* Two facts travel back, and they answer different questions:
|
|
17
|
+
*
|
|
18
|
+
* - `url` is the asset to download. It is resolved from the release's OWN asset
|
|
19
|
+
* list rather than built as `releases/latest/download/<name>`, because that
|
|
20
|
+
* path needs the asset name to match exactly and the name carries the tag
|
|
21
|
+
* (`kabmo-android-v1.2.3.apk`) — a name built here would be a second copy of
|
|
22
|
+
* the workflow's naming rule, and the first release that renames it 404s.
|
|
23
|
+
* - `latest` is the version that asset ships, read from the asset name. The
|
|
24
|
+
* release TAG cannot be used: the artifact is attached to the app's release,
|
|
25
|
+
* whose tag names the SERVER version, and the two move independently.
|
|
26
|
+
*
|
|
27
|
+
* Failure is a normal answer here (`null`), never a throw: an offline box or a
|
|
28
|
+
* rate-limited GitHub must not take the sidebar down, and a button that appears
|
|
29
|
+
* because a probe failed would offer a download that does not exist.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { REPO } from '@/server/lib/updates/release-url';
|
|
33
|
+
import type { ShellKind } from '@/shared/lib/updates/shell-ua';
|
|
34
|
+
|
|
35
|
+
/** One published shell artifact, as the console needs it. */
|
|
36
|
+
export interface ShellRelease {
|
|
37
|
+
/** Asset download URL, or null when the release carries no artifact. */
|
|
38
|
+
url: string | null;
|
|
39
|
+
/** Version the asset name declares (`v1.2.3` → `1.2.3`), or null. */
|
|
40
|
+
latest: string | null;
|
|
41
|
+
/** The release page, for a "what changed" link. */
|
|
42
|
+
releaseUrl: string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** How each shell's asset is named by its publish workflow. */
|
|
46
|
+
export const SHELL_ASSETS: Record<ShellKind, { prefix: string; extension: string }> = {
|
|
47
|
+
android: { prefix: 'kabmo-android-', extension: '.apk' },
|
|
48
|
+
ios: { prefix: 'kabmo-ios-', extension: '.ipa' },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `kabmo-android-v1.2.3.apk` → `1.2.3` (and the `.ipa` twin).
|
|
53
|
+
*
|
|
54
|
+
* The `v` is optional because a hand-uploaded asset may omit it, and a version
|
|
55
|
+
* that cannot be read is not evidence of an update — the caller treats null as
|
|
56
|
+
* "no new version".
|
|
57
|
+
*/
|
|
58
|
+
export function versionFromAssetName(kind: ShellKind, name: string): string | null {
|
|
59
|
+
const { prefix, extension } = SHELL_ASSETS[kind];
|
|
60
|
+
if (!name.startsWith(prefix) || !name.endsWith(extension)) return null;
|
|
61
|
+
const middle = name.slice(prefix.length, -extension.length);
|
|
62
|
+
const version = middle.startsWith('v') ? middle.slice(1) : middle;
|
|
63
|
+
return /^\d+\.\d+\.\d+/.test(version) ? version : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The newest release that actually carries this shell's artifact.
|
|
68
|
+
*
|
|
69
|
+
* NOT `/releases/latest`, which answers the newest release of ANY kind. This
|
|
70
|
+
* repository publishes three artifacts from one push, and the packages are
|
|
71
|
+
* released FIRST (`packages → marketplace → android → apk-build → app` in
|
|
72
|
+
* `release.yml`), so their `kabmo-sdk/v*` / `kabmo-ui/v*` releases are newer
|
|
73
|
+
* than the app release that carries the shell. `@semantic-release/github` runs
|
|
74
|
+
* in both pipelines, so those are real releases — measured against a stub that
|
|
75
|
+
* answered a package release: `/releases/latest` produced
|
|
76
|
+
* `{url: null, latest: null}` and the sidebar's button disappeared even though
|
|
77
|
+
* the APK sat on the release directly below.
|
|
78
|
+
*
|
|
79
|
+
* One request per shell, cached for five minutes on `globalThis` for the same
|
|
80
|
+
* reason the database handle is anchored there: `bun --hot` re-evaluates this
|
|
81
|
+
* module while the cache it would otherwise hold lives on. GitHub allows 60
|
|
82
|
+
* unauthenticated reads an hour per address, and the sidebar asks on every
|
|
83
|
+
* mount.
|
|
84
|
+
*/
|
|
85
|
+
export async function fetchShellRelease(
|
|
86
|
+
kind: ShellKind,
|
|
87
|
+
fetchImpl: typeof fetch = fetch,
|
|
88
|
+
): Promise<ShellRelease | null> {
|
|
89
|
+
const cache = cacheSlot(kind);
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
if (cache.entry && now - cache.at < CACHE_MS) return cache.entry;
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const response = await fetchImpl(`${githubApiBase()}/repos/${REPO}/releases?per_page=${RELEASE_SCAN}`, {
|
|
95
|
+
headers: {
|
|
96
|
+
accept: 'application/vnd.github+json',
|
|
97
|
+
// GitHub rejects a request with no User-Agent.
|
|
98
|
+
'user-agent': 'kabmo-console',
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
if (!response.ok) return cache.entry ?? null;
|
|
102
|
+
|
|
103
|
+
const body = (await response.json()) as unknown;
|
|
104
|
+
// A body that is not the release list is a failed probe, not an empty
|
|
105
|
+
// repository: answering `{url: null, latest: null}` here would be
|
|
106
|
+
// indistinguishable from "no release carries an artifact", and the caller
|
|
107
|
+
// would cache that mistake for five minutes.
|
|
108
|
+
if (!Array.isArray(body)) return cache.entry ?? null;
|
|
109
|
+
|
|
110
|
+
// Drafts are not published and prereleases are not what a console offers an
|
|
111
|
+
// installed shell — the same two the `latest` endpoint excluded.
|
|
112
|
+
const published = body.filter(
|
|
113
|
+
(entry): entry is Record<string, unknown> =>
|
|
114
|
+
entry !== null && typeof entry === 'object' && entry.draft !== true && entry.prerelease !== true,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const release = published
|
|
118
|
+
.map((entry) => ({ entry, asset: shellAsset(kind, entry.assets) }))
|
|
119
|
+
.find((candidate) => candidate.asset !== null);
|
|
120
|
+
|
|
121
|
+
if (!release?.asset) {
|
|
122
|
+
const fallback: ShellRelease = { url: null, latest: null, releaseUrl: null };
|
|
123
|
+
cache.entry = fallback;
|
|
124
|
+
cache.at = now;
|
|
125
|
+
return fallback;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const resolved: ShellRelease = {
|
|
129
|
+
url: typeof release.asset.browser_download_url === 'string' ? release.asset.browser_download_url : null,
|
|
130
|
+
latest: versionFromAssetName(kind, String(release.asset.name)),
|
|
131
|
+
releaseUrl: typeof release.entry.html_url === 'string' ? release.entry.html_url : null,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
cache.entry = resolved;
|
|
135
|
+
cache.at = now;
|
|
136
|
+
return resolved;
|
|
137
|
+
} catch {
|
|
138
|
+
// Keep the last good answer rather than replacing it with a failure: a
|
|
139
|
+
// momentary outage must not remove a download button that was valid.
|
|
140
|
+
return cache.entry ?? null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** How many releases to look back through for the artifact. */
|
|
145
|
+
const RELEASE_SCAN = 20;
|
|
146
|
+
|
|
147
|
+
/** The first asset of a release that names this shell's artifact, or null. */
|
|
148
|
+
function shellAsset(kind: ShellKind, assets: unknown): Record<string, unknown> | null {
|
|
149
|
+
if (!Array.isArray(assets)) return null;
|
|
150
|
+
|
|
151
|
+
for (const entry of assets) {
|
|
152
|
+
if (entry === null || typeof entry !== 'object') continue;
|
|
153
|
+
const asset = entry as Record<string, unknown>;
|
|
154
|
+
if (typeof asset.name === 'string' && versionFromAssetName(kind, asset.name) !== null) return asset;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const CACHE_MS = 5 * 60_000;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Where the release read goes.
|
|
164
|
+
*
|
|
165
|
+
* Overridable for the same reason the npm probe honours `NPM_CONFIG_REGISTRY`: a
|
|
166
|
+
* test and a rehearsal need to exercise the real parsing against a stub instead
|
|
167
|
+
* of a live GitHub call, and hardcoding the host makes that impossible without
|
|
168
|
+
* intercepting `fetch` globally.
|
|
169
|
+
*/
|
|
170
|
+
function githubApiBase(): string {
|
|
171
|
+
const configured = Bun.env.KABMO_GITHUB_API ?? '';
|
|
172
|
+
return configured.trim().replace(/\/+$/, '') || 'https://api.github.com';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
interface CacheSlot {
|
|
176
|
+
entry: ShellRelease | null;
|
|
177
|
+
at: number;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
type ShellCacheHost = typeof globalThis & {
|
|
181
|
+
__kabmoShellRelease?: Partial<Record<ShellKind, CacheSlot>>;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The cache slot for one shell.
|
|
186
|
+
*
|
|
187
|
+
* PER SHELL, not one shared slot: both probes read the same release list but
|
|
188
|
+
* resolve different assets, so one slot would make the second shell answer with
|
|
189
|
+
* the first one's artifact.
|
|
190
|
+
*/
|
|
191
|
+
function cacheSlot(kind: ShellKind): CacheSlot {
|
|
192
|
+
const host = globalThis as ShellCacheHost;
|
|
193
|
+
host.__kabmoShellRelease ??= {};
|
|
194
|
+
host.__kabmoShellRelease[kind] ??= { entry: null, at: 0 };
|
|
195
|
+
return host.__kabmoShellRelease[kind] as CacheSlot;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Drop the cached answer for one shell (or every shell, with no argument).
|
|
200
|
+
*
|
|
201
|
+
* The cache lives on `globalThis` so `bun --hot` cannot orphan it, which also
|
|
202
|
+
* means it outlives a test file: without this seam the second case in a suite
|
|
203
|
+
* reads the first case's answer and passes for the wrong reason (measured — the
|
|
204
|
+
* "malformed body" case returned the previous test's release). Production has
|
|
205
|
+
* no caller; it exists because the cache is process-wide by design.
|
|
206
|
+
*/
|
|
207
|
+
export function resetShellReleaseCache(kind?: ShellKind): void {
|
|
208
|
+
const host = globalThis as ShellCacheHost;
|
|
209
|
+
if (!kind) {
|
|
210
|
+
host.__kabmoShellRelease = {};
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
host.__kabmoShellRelease ??= {};
|
|
214
|
+
host.__kabmoShellRelease[kind] = { entry: null, at: 0 };
|
|
215
|
+
}
|
|
@@ -38,11 +38,11 @@ const ROOT = path.join(os.tmpdir(), `kabmo-waiting-store-${process.pid}`);
|
|
|
38
38
|
|
|
39
39
|
beforeAll(async () => {
|
|
40
40
|
fs.mkdirSync(ROOT, { recursive: true });
|
|
41
|
-
isolateDb();
|
|
41
|
+
await isolateDb();
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
afterAll(async () => {
|
|
45
|
-
releaseDb();
|
|
45
|
+
await releaseDb();
|
|
46
46
|
fs.rmSync(ROOT, { recursive: true, force: true });
|
|
47
47
|
});
|
|
48
48
|
|
|
@@ -16,7 +16,7 @@ import { readSettingsBlob } from '@/shared/lib/settings/keys';
|
|
|
16
16
|
import { resolveTheme } from '@/shared/lib/theme/catalog';
|
|
17
17
|
import { windowChromeColor } from '@/shared/lib/theme/chrome';
|
|
18
18
|
import { THEME_STYLE_ELEMENT_ID, themeStyleSheet } from '@/shared/lib/theme/css';
|
|
19
|
-
import {
|
|
19
|
+
import { isAndroidDevice, shellVersion } from '@/shared/lib/updates/shell-ua';
|
|
20
20
|
|
|
21
21
|
/** HTML-escape a string for interpolation into the failure page's `<pre>`. */
|
|
22
22
|
function escapeHtmlText(value: string): string {
|
|
@@ -157,15 +157,18 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
|
|
|
157
157
|
const theme = resolveTheme(settingsBlob.theme);
|
|
158
158
|
const userAgent = request.headers.get('user-agent') ?? '';
|
|
159
159
|
const initialIsMobile = MOBILE_UA.test(userAgent);
|
|
160
|
-
// Which
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
160
|
+
// Which shell is asking, and which build of it. Read HERE because the shell
|
|
161
|
+
// is rendered per request and the client's download control needs the answer
|
|
162
|
+
// before its first paint: a fetch after hydration would draw the button late,
|
|
163
|
+
// or draw it and then remove it.
|
|
164
164
|
//
|
|
165
|
-
// `isAndroid` is the wider fact and it exists for the INSTALL offer: a
|
|
166
|
-
// on Chrome or Brave for Android carries no shell token, and "not the
|
|
167
|
-
// alone cannot tell them from a desktop where an APK is useless.
|
|
168
|
-
|
|
165
|
+
// `isAndroid` is the wider fact and it exists for the Android INSTALL offer: a
|
|
166
|
+
// reader on Chrome or Brave for Android carries no shell token, and "not the
|
|
167
|
+
// app" alone cannot tell them from a desktop where an APK is useless. There is
|
|
168
|
+
// no `isIos`: an IPA cannot be installed from a browser, so only an installed
|
|
169
|
+
// iOS shell is ever offered an update.
|
|
170
|
+
const androidVersion = shellVersion(userAgent, 'android');
|
|
171
|
+
const iosVersion = shellVersion(userAgent, 'ios');
|
|
169
172
|
const isAndroid = isAndroidDevice(userAgent);
|
|
170
173
|
|
|
171
174
|
const rendered = await renderShell();
|
|
@@ -178,7 +181,7 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
|
|
|
178
181
|
// questions: "this browser has a session" versus "there is a session to have".
|
|
179
182
|
// A Kabmo running open answers `authenticated: true` for every visitor, so
|
|
180
183
|
// the navbar cannot use it to decide whether a sign-out control applies.
|
|
181
|
-
const bootstrap = JSON.stringify({ initialIsMobile, shellVersion, isAndroid, authenticated, authEnabled: isAuthEnabled(), production: !DEV_ASSETS_ENABLED, appSettings: settings }).replace(/</g, '\\u003c');
|
|
184
|
+
const bootstrap = JSON.stringify({ initialIsMobile, shellVersion: androidVersion, iosShellVersion: iosVersion, isAndroid, authenticated, authEnabled: isAuthEnabled(), production: !DEV_ASSETS_ENABLED, appSettings: settings }).replace(/</g, '\\u003c');
|
|
182
185
|
// The template's own `<meta name="theme-color">` is REWRITTEN, not joined by
|
|
183
186
|
// a second tag: with both present Chrome reads the first one, so an appended
|
|
184
187
|
// tag would leave a dark theme painting light browser chrome.
|
|
@@ -33,16 +33,16 @@ import { action } from '@/server/routes/chat/stream';
|
|
|
33
33
|
|
|
34
34
|
const savedMock = Bun.env.MOCK;
|
|
35
35
|
|
|
36
|
-
beforeEach(() => {
|
|
37
|
-
isolateDb();
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
await isolateDb();
|
|
38
38
|
localRuns().clear();
|
|
39
39
|
Bun.env.MOCK = 'true';
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
-
afterEach(() => {
|
|
42
|
+
afterEach(async () => {
|
|
43
43
|
Bun.env.MOCK = savedMock;
|
|
44
44
|
localRuns().clear();
|
|
45
|
-
releaseDb();
|
|
45
|
+
await releaseDb();
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
/** Resolve on the next `stream-status` write — the write's own signal. */
|
|
@@ -254,17 +254,6 @@ describe('continue_operation', () => {
|
|
|
254
254
|
});
|
|
255
255
|
|
|
256
256
|
describe('abort_operation', () => {
|
|
257
|
-
test('restores the pre-merge state', async () => {
|
|
258
|
-
const repo = makeConflictingRepo();
|
|
259
|
-
await post({ actionType: 'merge_branch', repo: '.', root: repo, branch: 'feature' });
|
|
260
|
-
|
|
261
|
-
const res = await post({ actionType: 'abort_operation', repo: '.', root: repo });
|
|
262
|
-
expect(res.body.success).toBe(true);
|
|
263
|
-
expect((await readGitState(repo)).operation).toBeNull();
|
|
264
|
-
expect(fs.readFileSync(path.join(repo, 'f.txt'), 'utf8')).toBe('a\nMAIN\n');
|
|
265
|
-
expect(git(repo, 'status', '--porcelain')).toBe('');
|
|
266
|
-
});
|
|
267
|
-
|
|
268
257
|
test('refuses when nothing is in progress', async () => {
|
|
269
258
|
const repo = makeConflictingRepo();
|
|
270
259
|
const res = await post({ actionType: 'abort_operation', repo: '.', root: repo });
|
|
@@ -2,30 +2,89 @@ import { json } from '@/server/lib/remix-compat';
|
|
|
2
2
|
import type { ActionFunctionArgs, LoaderFunctionArgs } from '@/server/lib/remix-compat';
|
|
3
3
|
import { methodNotAllowed } from '@/server/lib/route-adapter';
|
|
4
4
|
import { readModelRoles, writeModelRoles } from '@/server/lib/omp/config/roles';
|
|
5
|
+
import { readModelRoleCandidates } from '@/server/lib/model-roles/catalog.server';
|
|
6
|
+
import {
|
|
7
|
+
deleteModelRolePreset,
|
|
8
|
+
listModelRolePresets,
|
|
9
|
+
saveModelRolePreset,
|
|
10
|
+
seedPresetFromCurrentRoles,
|
|
11
|
+
} from '@/server/lib/model-roles/store.server';
|
|
12
|
+
import { normalizeRoleMap } from '@/shared/lib/omp/config/model-roles';
|
|
13
|
+
import type { ModelRolesPayload } from '@/shared/types/model-roles';
|
|
5
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Model roles: the live `modelRoles` map from config.yml, the presets Kabmo
|
|
17
|
+
* stores for it, and the catalog the role dropdowns offer.
|
|
18
|
+
*
|
|
19
|
+
* Three verbs, each with one job:
|
|
20
|
+
* - `GET` → everything the panel renders (roles, presets, candidates), and
|
|
21
|
+
* the ONE-SHOT seed that snapshots an install's existing roles
|
|
22
|
+
* into a preset the first time the panel is opened.
|
|
23
|
+
* - `PUT` → replace the whole `modelRoles` map. This is what applying a
|
|
24
|
+
* preset does, and what the per-row editors do with one entry
|
|
25
|
+
* changed; there is no per-role verb, because config.yml's map is
|
|
26
|
+
* written whole either way.
|
|
27
|
+
* - `POST` → save a preset (`{ preset: { id?, name, roles } }`).
|
|
28
|
+
* - `DELETE` → remove a preset by `?id=`.
|
|
29
|
+
*/
|
|
6
30
|
export async function loader(_args: LoaderFunctionArgs) {
|
|
7
31
|
try {
|
|
8
|
-
|
|
32
|
+
// Roles first: the seed snapshots them, and the presets list is read after
|
|
33
|
+
// it so the seeded row is in the very first payload the panel renders.
|
|
34
|
+
const [{ path, roles }, catalog] = await Promise.all([
|
|
35
|
+
readModelRoles(),
|
|
36
|
+
readModelRoleCandidates(),
|
|
37
|
+
]);
|
|
38
|
+
await seedPresetFromCurrentRoles(roles);
|
|
39
|
+
const presets = await listModelRolePresets();
|
|
40
|
+
const payload: ModelRolesPayload = {
|
|
41
|
+
path,
|
|
42
|
+
roles,
|
|
43
|
+
presets,
|
|
44
|
+
candidates: catalog.candidates,
|
|
45
|
+
candidatesError: catalog.error,
|
|
46
|
+
};
|
|
47
|
+
return json(payload);
|
|
9
48
|
} catch (error) {
|
|
10
49
|
return json({ error: String(error) }, { status: 400 });
|
|
11
50
|
}
|
|
12
51
|
}
|
|
13
52
|
|
|
14
53
|
export async function action({ request, params }: ActionFunctionArgs) {
|
|
15
|
-
if (request.method !== 'PUT') {
|
|
16
|
-
return methodNotAllowed({ request, params });
|
|
17
|
-
}
|
|
18
54
|
try {
|
|
19
|
-
|
|
20
|
-
if (
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
const roles = Object.fromEntries(Object.entries(body.roles).filter((entry): entry is [string, string] =>
|
|
24
|
-
typeof entry[1] === 'string' && entry[0].trim().length > 0 && entry[1].trim().length > 0,
|
|
25
|
-
));
|
|
26
|
-
await writeModelRoles(roles);
|
|
27
|
-
return json({ success: true, roles });
|
|
55
|
+
if (request.method === 'PUT') return await saveRoles(request);
|
|
56
|
+
if (request.method === 'POST') return await savePreset(request);
|
|
57
|
+
if (request.method === 'DELETE') return await removePreset(request);
|
|
58
|
+
return methodNotAllowed({ request, params });
|
|
28
59
|
} catch (error) {
|
|
29
|
-
return json({ error: String(error) }, { status: 400 });
|
|
60
|
+
return json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 });
|
|
30
61
|
}
|
|
31
62
|
}
|
|
63
|
+
|
|
64
|
+
async function saveRoles(request: Request): Promise<Response> {
|
|
65
|
+
const body = await request.json() as { roles?: unknown };
|
|
66
|
+
const roles = normalizeRoleMap(body.roles);
|
|
67
|
+
await writeModelRoles(roles);
|
|
68
|
+
return json({ success: true, roles });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function savePreset(request: Request): Promise<Response> {
|
|
72
|
+
const body = await request.json() as { preset?: unknown };
|
|
73
|
+
const preset = typeof body.preset === 'object' && body.preset !== null ? body.preset : null;
|
|
74
|
+
if (!preset) return json({ error: 'preset is required' }, { status: 400 });
|
|
75
|
+
const draft = preset as Record<string, unknown>;
|
|
76
|
+
const stored = await saveModelRolePreset({
|
|
77
|
+
id: typeof draft.id === 'string' && draft.id ? draft.id : null,
|
|
78
|
+
name: draft.name,
|
|
79
|
+
roles: draft.roles,
|
|
80
|
+
});
|
|
81
|
+
return json({ success: true, preset: stored, presets: await listModelRolePresets() });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function removePreset(request: Request): Promise<Response> {
|
|
85
|
+
const id = new URL(request.url).searchParams.get('id');
|
|
86
|
+
if (!id) return json({ error: 'id is required' }, { status: 400 });
|
|
87
|
+
const removed = await deleteModelRolePreset(id);
|
|
88
|
+
if (!removed) return json({ error: 'Preset not found' }, { status: 404 });
|
|
89
|
+
return json({ success: true, presets: await listModelRolePresets() });
|
|
90
|
+
}
|
|
@@ -60,19 +60,19 @@ function call(loader: (args: never) => unknown, params: Record<string, string>)
|
|
|
60
60
|
return loader({ params, request: new Request('http://local/') } as never) as Promise<Response>;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
beforeEach(() => {
|
|
63
|
+
beforeEach(async () => {
|
|
64
64
|
root = fs.mkdtempSync(join(tmpdir(), 'omc-panel-css-'));
|
|
65
65
|
bundled = fs.mkdtempSync(join(tmpdir(), 'omc-panel-store-'));
|
|
66
66
|
process.env.KABMO_MARKETPLACE_DIR = root;
|
|
67
67
|
process.env.KABMO_BUNDLED_MARKETPLACE_DIR = bundled;
|
|
68
68
|
// A database of this suite's own, BEFORE the first scan: the scan reads the
|
|
69
69
|
// disabled set, and a path set while a cached handle exists is never read.
|
|
70
|
-
isolateDb();
|
|
70
|
+
await isolateDb();
|
|
71
71
|
invalidatePanelScan();
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
afterEach(() => {
|
|
75
|
-
releaseDb();
|
|
74
|
+
afterEach(async () => {
|
|
75
|
+
await releaseDb();
|
|
76
76
|
delete process.env.KABMO_MARKETPLACE_DIR;
|
|
77
77
|
delete process.env.KABMO_BUNDLED_MARKETPLACE_DIR;
|
|
78
78
|
invalidatePanelScan();
|
|
@@ -104,19 +104,19 @@ export { getMarketplacePluginsDir };
|
|
|
104
104
|
* entry makes a later "no errors" assertion pass or fail for the wrong reason.
|
|
105
105
|
*/
|
|
106
106
|
export function usePanelRouteFixtures(): void {
|
|
107
|
-
beforeEach(() => {
|
|
107
|
+
beforeEach(async () => {
|
|
108
108
|
dirs.root = fs.mkdtempSync(join(tmpdir(), 'omc-panel-routes-'));
|
|
109
109
|
dirs.bundled = fs.mkdtempSync(join(tmpdir(), 'omc-panel-store-'));
|
|
110
110
|
process.env.KABMO_MARKETPLACE_DIR = dirs.root;
|
|
111
111
|
process.env.KABMO_BUNDLED_MARKETPLACE_DIR = dirs.bundled;
|
|
112
|
-
isolateDb();
|
|
112
|
+
await isolateDb();
|
|
113
113
|
invalidatePanelScan();
|
|
114
114
|
});
|
|
115
115
|
|
|
116
116
|
afterEach(async () => {
|
|
117
117
|
await disposeAllPlugins();
|
|
118
118
|
globalThis.__kabmoPluginLoadErrors = undefined;
|
|
119
|
-
releaseDb();
|
|
119
|
+
await releaseDb();
|
|
120
120
|
delete process.env.KABMO_MARKETPLACE_DIR;
|
|
121
121
|
delete process.env.KABMO_BUNDLED_MARKETPLACE_DIR;
|
|
122
122
|
invalidatePanelScan();
|
|
@@ -132,13 +132,6 @@ describe('the README route', () => {
|
|
|
132
132
|
|
|
133
133
|
expect((await call(readmeLoader, { plugin: 'demo', '*': '../outside.md' })).status).toBe(403);
|
|
134
134
|
});
|
|
135
|
-
|
|
136
|
-
test('404s a plugin that ships no README', async () => {
|
|
137
|
-
const dir = writePlugin('demo', getMarketplacePluginsDir());
|
|
138
|
-
fs.rmSync(join(dir, 'README.md'));
|
|
139
|
-
invalidatePanelScan();
|
|
140
|
-
expect((await call(readmeLoader, { plugin: 'demo', '*': 'README.md' })).status).toBe(404);
|
|
141
|
-
});
|
|
142
135
|
});
|
|
143
136
|
|
|
144
137
|
describe('the install route', () => {
|