@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.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +403 -384
- package/dist/client/static/js/index-gtbpewr7.css +1 -0
- package/dist/client/static/js/{index-0wvbca0b.js → index-qwpvd9fv.js} +779 -779
- package/package.json +2 -1
- package/src/client/components/common/header-controls/index.tsx +24 -1
- package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
- package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
- package/src/client/components/layout/desktop-layout/index.tsx +8 -2
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
- package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
- package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
- package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +22 -16
- package/src/client/components/mobile/RightSidebar.tsx +21 -10
- package/src/client/components/mobile/SessionSidebar.tsx +8 -0
- package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
- package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
- package/src/client/components/mobile/worktree-session.ts +51 -0
- package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
- package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +140 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +29 -56
- package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
- package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +227 -0
- package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +260 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +24 -6
- package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
- package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
- package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
- package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
- package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
- package/src/client/components/workspace/file-explorer/filter.ts +47 -0
- package/src/client/components/workspace/file-explorer/index.tsx +77 -64
- package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
- package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
- package/src/client/hooks/chat/timeline/index.ts +9 -5
- package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.ts +11 -1
- package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
- package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
- package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
- package/src/client/hooks/ui/long-press.test.ts +174 -0
- package/src/client/hooks/ui/long-press.ts +112 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +73 -0
- package/src/client/hooks/ui/updates/shell-download.ts +112 -28
- package/src/client/hooks/ui/updates/shell-offer.test.ts +187 -0
- package/src/client/hooks/workspace/file-create.ts +41 -11
- package/src/client/hooks/workspace/file-listing.ts +56 -1
- package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
- package/src/client/hooks/workspace/worktree/index.ts +150 -0
- package/src/client/tailwind.css +19 -0
- package/src/server/index.ts +7 -2
- package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
- package/src/server/lib/db/schema.ts +4 -0
- package/src/server/lib/db/worktree-registry.server.ts +91 -0
- package/src/server/lib/fs/canonical.ts +52 -0
- package/src/server/lib/fs/relative-path.ts +48 -0
- package/src/server/lib/fs/root-worktree.test.ts +116 -0
- package/src/server/lib/fs/root.ts +39 -10
- package/src/server/lib/fs/worktree/carry.ts +134 -0
- package/src/server/lib/fs/worktree/create.test.ts +249 -0
- package/src/server/lib/fs/worktree/create.ts +263 -0
- package/src/server/lib/fs/worktree/errors.ts +43 -0
- package/src/server/lib/fs/worktree/list.test.ts +207 -0
- package/src/server/lib/fs/worktree/list.ts +129 -0
- package/src/server/lib/fs/worktree/location.test.ts +206 -0
- package/src/server/lib/fs/worktree/location.ts +114 -0
- package/src/server/lib/fs/worktree/project-id.ts +53 -0
- package/src/server/lib/fs/worktree/remove.test.ts +242 -0
- package/src/server/lib/fs/worktree/remove.ts +152 -0
- package/src/server/lib/fs/worktree/settings.server.ts +56 -0
- package/src/server/lib/fs/worktree/topology.ts +142 -0
- package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
- package/src/server/lib/panels/local-edits.server.ts +33 -0
- package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
- package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
- package/src/server/lib/panels/sync.server.test.ts +125 -147
- package/src/server/lib/panels/sync.server.ts +153 -48
- package/src/server/lib/updates/shell-assets.test.ts +114 -0
- package/src/server/lib/updates/shell-assets.ts +109 -0
- package/src/server/lib/updates/shell-pages.test.ts +156 -0
- package/src/server/lib/updates/shell-pages.ts +145 -0
- package/src/server/lib/updates/shell-release.test.ts +75 -70
- package/src/server/lib/updates/shell-release.ts +120 -136
- package/src/server/routes/fs/action-create.test.ts +18 -13
- package/src/server/routes/fs/action-nested.test.ts +184 -0
- package/src/server/routes/fs/action.ts +47 -16
- package/src/server/routes/index.ts +2 -0
- package/src/server/routes/updates/desktop.ts +37 -0
- package/src/server/routes/updates/index.ts +2 -0
- package/src/server/routes/worktrees/create.ts +138 -0
- package/src/server/routes/worktrees/index.ts +25 -0
- package/src/server/routes/worktrees/list.ts +124 -0
- package/src/server/routes/worktrees/remove.ts +102 -0
- package/src/server/routes/worktrees/routes.test.ts +309 -0
- package/src/server/routes/worktrees/shared.ts +90 -0
- package/src/shared/lib/fs/file-tree.test.ts +183 -0
- package/src/shared/lib/fs/file-tree.ts +159 -0
- package/src/shared/lib/fs/paths.test.ts +24 -1
- package/src/shared/lib/fs/paths.ts +17 -0
- package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
- package/src/shared/lib/omp/session/prewarm.ts +7 -0
- package/src/shared/lib/updates/shell-ua.ts +24 -4
- package/src/shared/lib/workspace/active-project.ts +23 -0
- package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
- package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
- package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
- package/src/shared/lib/workspace/worktree/types.ts +104 -0
- package/src/shared/types/fs.ts +10 -0
- package/src/shared/types/workspace.ts +14 -0
- package/src/test-support/file-explorer-disk.test-util.ts +76 -0
- package/src/test-support/file-explorer.test-util.ts +71 -40
- package/dist/client/static/js/index-nzyjf92m.css +0 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The PUBLIC release pages, for when the GitHub API is unavailable.
|
|
3
|
+
*
|
|
4
|
+
* Split from `shell-release.ts` because it is a second, independent read: the API
|
|
5
|
+
* path parses JSON, this one parses an Atom feed and an HTML fragment, and the two
|
|
6
|
+
* share nothing but the asset-naming rules in `shell-assets`.
|
|
7
|
+
*
|
|
8
|
+
* ## Why it exists
|
|
9
|
+
*
|
|
10
|
+
* The REST API is unauthenticated at 60 requests an hour **per source IP** — and
|
|
11
|
+
* on a mobile network that IP is shared with an entire carrier NAT. Measured on
|
|
12
|
+
* the author's machine: `remaining: 0`, so the probe answered `null` and the
|
|
13
|
+
* console drew no update button for an APK two versions behind the published one.
|
|
14
|
+
* A missing button is indistinguishable from "you are up to date", which is why
|
|
15
|
+
* that failed silently for as long as it did.
|
|
16
|
+
*
|
|
17
|
+
* `github.com/<repo>/releases.atom` is a plain Atom feed of published releases
|
|
18
|
+
* (newest first) and `github.com/<repo>/releases/expanded_assets/<tag>` is the
|
|
19
|
+
* HTML fragment the release page itself loads. Both are served without an API
|
|
20
|
+
* budget — verified live: 200 on both, and the fragment carries every asset link.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { REPO } from '@/server/lib/updates/release-url';
|
|
24
|
+
import { SHELL_ASSETS, versionFromAssetName } from '@/server/lib/updates/shell-assets';
|
|
25
|
+
import type { ShellKind } from '@/shared/lib/updates/shell-ua';
|
|
26
|
+
|
|
27
|
+
/** How many tags to walk before giving up. */
|
|
28
|
+
const PAGE_SCAN = 6;
|
|
29
|
+
|
|
30
|
+
/** A tag from the Atom feed, with the release page it names. */
|
|
31
|
+
export interface ReleaseTag {
|
|
32
|
+
name: string;
|
|
33
|
+
url: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One asset found in a release's fragment. */
|
|
37
|
+
export interface PageAsset {
|
|
38
|
+
name: string;
|
|
39
|
+
downloadUrl: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What the pages read concluded.
|
|
44
|
+
*
|
|
45
|
+
* THREE cases, not two, because "the feed could not be read" and "the feed was
|
|
46
|
+
* read and carries no artifact of this shell" are different facts: the first
|
|
47
|
+
* keeps the last good answer, the second is a cacheable empty result. Collapsing
|
|
48
|
+
* them makes a momentary outage look like "no update published", which is the
|
|
49
|
+
* same silence the API path's `null` exists to avoid.
|
|
50
|
+
*/
|
|
51
|
+
export type PagesResult =
|
|
52
|
+
| { kind: 'found'; asset: PageAsset; releaseUrl: string | null }
|
|
53
|
+
| { kind: 'none' }
|
|
54
|
+
| { kind: 'unreachable' };
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The newest tag that carries this shell's artifact.
|
|
58
|
+
*
|
|
59
|
+
* The FIRST tag with a matching asset wins, because the feed is newest-first and
|
|
60
|
+
* a release that carries no artifact of this shell (a `kabmo-sdk/v*` package
|
|
61
|
+
* release, which the pipeline publishes before the app) must not stop the walk.
|
|
62
|
+
*/
|
|
63
|
+
export async function findAssetInPages(
|
|
64
|
+
kind: ShellKind,
|
|
65
|
+
fetchImpl: typeof fetch,
|
|
66
|
+
headers: Record<string, string>,
|
|
67
|
+
): Promise<PagesResult> {
|
|
68
|
+
const feed = await fetchImpl(`https://github.com/${REPO}/releases.atom`, {
|
|
69
|
+
headers: { accept: 'application/atom+xml, text/xml', ...headers },
|
|
70
|
+
});
|
|
71
|
+
if (!feed.ok) return { kind: 'unreachable' };
|
|
72
|
+
|
|
73
|
+
const tags = releaseTags(await feed.text());
|
|
74
|
+
if (tags.length === 0) return { kind: 'none' };
|
|
75
|
+
|
|
76
|
+
for (const tag of tags.slice(0, PAGE_SCAN)) {
|
|
77
|
+
const page = await fetchImpl(
|
|
78
|
+
`https://github.com/${REPO}/releases/expanded_assets/${encodeURIComponent(tag.name)}`,
|
|
79
|
+
{ headers: { accept: 'text/html', ...headers } },
|
|
80
|
+
);
|
|
81
|
+
if (!page.ok) continue;
|
|
82
|
+
|
|
83
|
+
const asset = shellAssetFromPage(kind, await page.text());
|
|
84
|
+
if (!asset) continue;
|
|
85
|
+
|
|
86
|
+
return { kind: 'found', asset, releaseUrl: tag.url };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { kind: 'none' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The feed's entries, in the order GitHub lists them (newest first).
|
|
94
|
+
*
|
|
95
|
+
* Parsed with two regexes rather than an XML parser: the feed is a fixed shape
|
|
96
|
+
* GitHub emits, and a dependency for two fields would be the larger risk. The
|
|
97
|
+
* entity decode covers the one escape that appears in practice.
|
|
98
|
+
*/
|
|
99
|
+
export function releaseTags(xml: string): ReleaseTag[] {
|
|
100
|
+
const tags: ReleaseTag[] = [];
|
|
101
|
+
const entries = xml.split('<entry>').slice(1);
|
|
102
|
+
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
const title = /<title>([^<]*)<\/title>/.exec(entry)?.[1];
|
|
105
|
+
if (!title) continue;
|
|
106
|
+
const href = /<link[^>]*rel="alternate"[^>]*href="([^"]+)"/.exec(entry)?.[1] ?? null;
|
|
107
|
+
tags.push({ name: decodeEntities(title.trim()), url: href ? decodeEntities(href) : null });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return tags;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The asset links inside a release's `expanded_assets` fragment.
|
|
115
|
+
*
|
|
116
|
+
* The name is read out of the DOWNLOAD URL rather than the link's text: the
|
|
117
|
+
* fragment wraps it in a `<span class="text-bold">` whose markup has changed
|
|
118
|
+
* before, while the URL is the release's own path and carries the same name.
|
|
119
|
+
*
|
|
120
|
+
* The CURRENT naming scheme wins over the legacy one, for the reason
|
|
121
|
+
* `shell-release`'s API path documents: a release carrying both spellings must
|
|
122
|
+
* not resolve to the older version.
|
|
123
|
+
*/
|
|
124
|
+
export function shellAssetFromPage(kind: ShellKind, html: string): PageAsset | null {
|
|
125
|
+
const found: PageAsset[] = [];
|
|
126
|
+
const pattern = /href="(\/[^"]+\/releases\/download\/[^"]+)"/g;
|
|
127
|
+
|
|
128
|
+
for (const match of html.matchAll(pattern)) {
|
|
129
|
+
const path = decodeEntities(match[1] ?? '');
|
|
130
|
+
const name = decodeURIComponent(path.slice(path.lastIndexOf('/') + 1));
|
|
131
|
+
if (versionFromAssetName(kind, name) !== null) found.push({ name, downloadUrl: `https://github.com${path}` });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return found.find((asset) => SHELL_ASSETS[kind].current.test(asset.name)) ?? found[0] ?? null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The entities that appear in a feed title or a link. */
|
|
138
|
+
function decodeEntities(text: string): string {
|
|
139
|
+
return text
|
|
140
|
+
.replace(/</g, '<')
|
|
141
|
+
.replace(/>/g, '>')
|
|
142
|
+
.replace(/"/g, '"')
|
|
143
|
+
.replace(/'/g, "'")
|
|
144
|
+
.replace(/&/g, '&');
|
|
145
|
+
}
|
|
@@ -5,75 +5,16 @@
|
|
|
5
5
|
|
|
6
6
|
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
7
7
|
|
|
8
|
-
import { fetchShellRelease, resetShellReleaseCache
|
|
8
|
+
import { fetchShellRelease, resetShellReleaseCache } from '@/server/lib/updates/shell-release';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
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.
|
|
11
|
+
* A fetch double that answers one body, and counts its calls.
|
|
14
12
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* `text()` is provided as well as `json()`: the public-pages fallback reads Atom
|
|
14
|
+
* and HTML, and a stub that only answered `json()` would throw inside it and make
|
|
15
|
+
* a broken parse look like a clean "no release" — the exact confusion these
|
|
16
|
+
* tests exist to prevent.
|
|
17
17
|
*/
|
|
18
|
-
describe('versionFromAssetName', () => {
|
|
19
|
-
test('reads the version the release workflows write', () => {
|
|
20
|
-
expect(versionFromAssetName('android', 'Kabmo-0.5.0-android.apk')).toBe('0.5.0');
|
|
21
|
-
expect(versionFromAssetName('ios', 'Kabmo-0.1.0-ios.ipa')).toBe('0.1.0');
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* The old spelling is still READ, and that is not sentiment.
|
|
26
|
-
*
|
|
27
|
-
* These names are the only record of a published shell, and every release
|
|
28
|
-
* already on GitHub carries `kabmo-android-v<v>.apk`. A parser that only knew
|
|
29
|
-
* the new scheme would answer "no update" for an installed shell the moment
|
|
30
|
-
* this shipped — silently, because a name that does not match reads exactly
|
|
31
|
-
* like a release with no artifact.
|
|
32
|
-
*/
|
|
33
|
-
test('still reads the legacy spelling every published release carries', () => {
|
|
34
|
-
expect(versionFromAssetName('android', 'kabmo-android-v0.5.0.apk')).toBe('0.5.0');
|
|
35
|
-
expect(versionFromAssetName('ios', 'kabmo-ios-v0.1.0.ipa')).toBe('0.1.0');
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test('ignores anything that is not that shell’s artifact', () => {
|
|
39
|
-
expect(versionFromAssetName('android', 'Kabmo-1.2.3-android.apk.zip')).toBeNull();
|
|
40
|
-
expect(versionFromAssetName('android', 'source.tar.gz')).toBeNull();
|
|
41
|
-
expect(versionFromAssetName('android', 'Kabmo-latest-android.apk')).toBeNull();
|
|
42
|
-
expect(versionFromAssetName('android', 'Kabmo--android.apk')).toBeNull();
|
|
43
|
-
expect(versionFromAssetName('android', '')).toBeNull();
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* The desktop assets share the `Kabmo-` prefix, so the EXTENSION is what keeps
|
|
48
|
-
* them out. A probe that matched on the prefix alone would offer a Mac `.dmg`
|
|
49
|
-
* to an Android phone.
|
|
50
|
-
*/
|
|
51
|
-
test('does not read a desktop asset as a mobile one', () => {
|
|
52
|
-
for (const name of [
|
|
53
|
-
'Kabmo-0.1.2-mac-arm64.zip',
|
|
54
|
-
'Kabmo-0.1.2-mac-arm64.dmg',
|
|
55
|
-
'Kabmo-0.1.0-win-x64-setup.exe',
|
|
56
|
-
'Kabmo-0.1.0-linux-x86_64.tar.gz',
|
|
57
|
-
'Kabmo-0.1.0-linux-amd64.deb',
|
|
58
|
-
'Kabmo-0.1.0-linux-x86_64.AppImage',
|
|
59
|
-
]) {
|
|
60
|
-
expect(versionFromAssetName('android', name), name).toBeNull();
|
|
61
|
-
expect(versionFromAssetName('ios', name), name).toBeNull();
|
|
62
|
-
}
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test('does not accept one shell’s asset for the other', () => {
|
|
66
|
-
// The two probes read the SAME release list, so a parser that ignored the
|
|
67
|
-
// kind would let an iOS probe answer with the APK (and offer an `.apk` to an
|
|
68
|
-
// iPhone).
|
|
69
|
-
expect(versionFromAssetName('ios', 'Kabmo-0.5.0-android.apk')).toBeNull();
|
|
70
|
-
expect(versionFromAssetName('android', 'Kabmo-0.1.0-ios.ipa')).toBeNull();
|
|
71
|
-
expect(versionFromAssetName('ios', 'kabmo-android-v1.2.3.apk')).toBeNull();
|
|
72
|
-
expect(versionFromAssetName('android', 'kabmo-ios-v1.2.3.ipa')).toBeNull();
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
/** A fetch double that answers one body, and counts its calls. */
|
|
77
18
|
function stubFetch(body: unknown, ok = true) {
|
|
78
19
|
const calls: string[] = [];
|
|
79
20
|
const impl = (async (input: string | URL | Request) => {
|
|
@@ -81,11 +22,45 @@ function stubFetch(body: unknown, ok = true) {
|
|
|
81
22
|
return {
|
|
82
23
|
ok,
|
|
83
24
|
json: async () => body,
|
|
25
|
+
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
|
|
84
26
|
} as Response;
|
|
85
27
|
}) as unknown as typeof fetch;
|
|
86
28
|
return { impl, calls };
|
|
87
29
|
}
|
|
88
30
|
|
|
31
|
+
/**
|
|
32
|
+
* A double that routes by URL, for the two-path probe.
|
|
33
|
+
*
|
|
34
|
+
* `api` answers the REST read (or fails, which is what a spent quota does) and
|
|
35
|
+
* `pages` answers the Atom feed and each release's asset fragment. Returning
|
|
36
|
+
* `null` for a route means "this request fails", which is how the fallback is
|
|
37
|
+
* entered at all.
|
|
38
|
+
*/
|
|
39
|
+
function routedFetch(routes: {
|
|
40
|
+
api?: unknown;
|
|
41
|
+
atom?: string;
|
|
42
|
+
assets?: Record<string, string>;
|
|
43
|
+
}) {
|
|
44
|
+
const calls: string[] = [];
|
|
45
|
+
const impl = (async (input: string | URL | Request) => {
|
|
46
|
+
const url = String(input);
|
|
47
|
+
calls.push(url);
|
|
48
|
+
|
|
49
|
+
if (url.includes('/repos/')) {
|
|
50
|
+
if (routes.api === undefined) return { ok: false, json: async () => ({}), text: async () => '' } as Response;
|
|
51
|
+
return { ok: true, json: async () => routes.api, text: async () => JSON.stringify(routes.api) } as Response;
|
|
52
|
+
}
|
|
53
|
+
if (url.endsWith('/releases.atom')) {
|
|
54
|
+
const body = routes.atom ?? '';
|
|
55
|
+
return { ok: Boolean(routes.atom), json: async () => ({}), text: async () => body } as Response;
|
|
56
|
+
}
|
|
57
|
+
const tag = url.slice(url.lastIndexOf('/') + 1);
|
|
58
|
+
const body = routes.assets?.[decodeURIComponent(tag)];
|
|
59
|
+
return { ok: body !== undefined, json: async () => ({}), text: async () => body ?? '' } as Response;
|
|
60
|
+
}) as unknown as typeof fetch;
|
|
61
|
+
return { impl, calls };
|
|
62
|
+
}
|
|
63
|
+
|
|
89
64
|
describe('fetchShellRelease', () => {
|
|
90
65
|
// The cache is process-wide (see `resetShellReleaseCache`), so every case
|
|
91
66
|
// starts from a cold one or it reads the previous case's answer.
|
|
@@ -241,15 +216,45 @@ describe('fetchShellRelease', () => {
|
|
|
241
216
|
});
|
|
242
217
|
|
|
243
218
|
test('answers null when the probe fails', async () => {
|
|
244
|
-
// An offline box
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
|
|
219
|
+
// An offline box must not take the sidebar down, and a failure must not be
|
|
220
|
+
// read as "no newer version" either — it is simply no offer. BOTH paths are
|
|
221
|
+
// down here: the API refuses and the feed is unreachable, which is the only
|
|
222
|
+
// shape that means "cannot ask" rather than "nothing published".
|
|
223
|
+
const { impl } = routedFetch({});
|
|
248
224
|
expect(await fetchShellRelease('android', impl)).toBeNull();
|
|
249
225
|
});
|
|
250
226
|
|
|
251
227
|
test('answers null when the body is malformed', async () => {
|
|
252
|
-
|
|
228
|
+
// The API answers 200 with something that is not a release list, and the
|
|
229
|
+
// feed is unreachable — the API's answer is a failed probe, not an empty
|
|
230
|
+
// repository.
|
|
231
|
+
const { impl } = routedFetch({ api: { assets: 'nope' } });
|
|
253
232
|
expect(await fetchShellRelease('android', impl)).toBeNull();
|
|
254
233
|
});
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The desktop trio reads the same release list and answers the RELEASE PAGE.
|
|
237
|
+
*
|
|
238
|
+
* Every desktop artifact is per-architecture, and this server cannot know which
|
|
239
|
+
* architecture the window asking is running on — it may be a remote console on
|
|
240
|
+
* another machine. Naming one arch's file would hand half the readers a build
|
|
241
|
+
* that will not launch, so the link is the page that lists all of them.
|
|
242
|
+
*/
|
|
243
|
+
test('a desktop probe answers the release page, never one architecture', async () => {
|
|
244
|
+
const { impl } = stubFetch([
|
|
245
|
+
{
|
|
246
|
+
html_url: 'https://x/app',
|
|
247
|
+
assets: [
|
|
248
|
+
{ name: 'Kabmo-0.2.0-mac-arm64.zip', browser_download_url: 'https://x/mac-arm.zip' },
|
|
249
|
+
{ name: 'Kabmo-0.2.0-mac-x64.dmg', browser_download_url: 'https://x/mac-x64.dmg' },
|
|
250
|
+
],
|
|
251
|
+
},
|
|
252
|
+
]);
|
|
253
|
+
|
|
254
|
+
expect(await fetchShellRelease('macos', impl)).toEqual({
|
|
255
|
+
url: 'https://x/app',
|
|
256
|
+
latest: '0.2.0',
|
|
257
|
+
releaseUrl: 'https://x/app',
|
|
258
|
+
});
|
|
259
|
+
});
|
|
255
260
|
});
|
|
@@ -2,128 +2,68 @@
|
|
|
2
2
|
* Which published native-shell artifact a browser or an installed shell should
|
|
3
3
|
* download, and whether the installed one is behind.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* not `/releases/latest`, which answers the newest release of ANY kind (see
|
|
10
|
-
* `fetchShellRelease`).
|
|
5
|
+
* No shell is an npm package (see `.github/workflows/publish-{android,ios,macos,
|
|
6
|
+
* windows,linux}.yml`), so the npm probe that answers for the app and for `omp`
|
|
7
|
+
* cannot answer here. The only record of a published shell is a GitHub release
|
|
8
|
+
* asset.
|
|
11
9
|
*
|
|
12
|
-
*
|
|
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.
|
|
10
|
+
* ## Two reads, because one of them has a quota
|
|
15
11
|
*
|
|
16
|
-
*
|
|
12
|
+
* The REST API is the precise read and the FIRST one tried. It is also
|
|
13
|
+
* unauthenticated at 60 requests an hour **per source IP**, and a phone on mobile
|
|
14
|
+
* data shares that IP with an entire carrier NAT — so `shell-pages` is the second
|
|
15
|
+
* path, and it is why the button appears at all on a bad day. See that module's
|
|
16
|
+
* own comment for the measured failure.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
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 shell's
|
|
21
|
-
* version (`Kabmo-0.5.0-android.apk`) — a name built here would be a second
|
|
22
|
-
* copy of the workflow's naming rule, and the first release that renames it
|
|
23
|
-
* 404s.
|
|
24
|
-
* - `latest` is the version that asset ships, read from the asset name. The
|
|
25
|
-
* release TAG cannot be used: the artifact is attached to the app's release,
|
|
26
|
-
* whose tag names the SERVER version, and the two move independently.
|
|
18
|
+
* ## What travels back
|
|
27
19
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
20
|
+
* - `url` is what to download. For the mobile pair it is the artifact itself (an
|
|
21
|
+
* APK the shell installs, an IPA the release page explains). For the DESKTOP
|
|
22
|
+
* trio it is the RELEASE PAGE, deliberately: every desktop artifact is
|
|
23
|
+
* per-architecture (`Kabmo-0.2.0-mac-arm64.zip`), and this server cannot know
|
|
24
|
+
* which architecture the window asking is running on — it may be a remote
|
|
25
|
+
* console on another machine entirely. Naming one arch's file would hand half
|
|
26
|
+
* the readers a build that will not launch.
|
|
27
|
+
* - `latest` is the version that asset ships, read from the asset NAME (see
|
|
28
|
+
* `shell-assets`). The release TAG cannot be used: the artifact is attached to
|
|
29
|
+
* the app's release, whose tag names the SERVER version.
|
|
30
|
+
*
|
|
31
|
+
* Failure is a normal answer here (`null`), never a throw: an offline box must
|
|
32
|
+
* not take the sidebar down, and a button that appears because a probe failed
|
|
33
|
+
* would offer a download that does not exist.
|
|
31
34
|
*/
|
|
32
35
|
|
|
33
36
|
import { REPO } from '@/server/lib/updates/release-url';
|
|
37
|
+
import { SHELL_ASSETS, versionFromAssetName } from '@/server/lib/updates/shell-assets';
|
|
38
|
+
import { findAssetInPages } from '@/server/lib/updates/shell-pages';
|
|
34
39
|
import type { ShellKind } from '@/shared/lib/updates/shell-ua';
|
|
35
40
|
|
|
36
41
|
/** One published shell artifact, as the console needs it. */
|
|
37
42
|
export interface ShellRelease {
|
|
38
|
-
/** Asset download URL, or
|
|
43
|
+
/** Asset download URL, or the release page for a per-arch desktop build. */
|
|
39
44
|
url: string | null;
|
|
40
|
-
/** Version the asset name declares (`
|
|
45
|
+
/** Version the asset name declares (`Kabmo-1.2.3-android.apk` → `1.2.3`). */
|
|
41
46
|
latest: string | null;
|
|
42
47
|
/** The release page, for a "what changed" link. */
|
|
43
48
|
releaseUrl: string | null;
|
|
44
49
|
}
|
|
45
50
|
|
|
46
|
-
/**
|
|
47
|
-
* How each shell's asset is named by its publish workflow, plus the prefix it
|
|
48
|
-
* carried BEFORE the rename.
|
|
49
|
-
*
|
|
50
|
-
* ONE scheme for every shell in the project — `<App>-<version>-<platform>.<ext>`:
|
|
51
|
-
* `Kabmo-0.5.0-android.apk`, `Kabmo-0.1.0-ios.ipa`, and the desktop trio's
|
|
52
|
-
* `Kabmo-0.1.2-mac-arm64.dmg` / `-win-x64-setup.exe` / `-linux-x86_64.tar.gz`.
|
|
53
|
-
* The mobile pair carries no architecture because neither artifact is per-arch:
|
|
54
|
-
* the APK is one universal build (the shell is pure Kotlin with no native
|
|
55
|
-
* libraries, so there is nothing to split), and the IPA contains only the device
|
|
56
|
-
* slice (`arm64-apple-ios`). A `-arm64` in those names would claim a distinction
|
|
57
|
-
* that does not exist and would make the parser look for a second artifact.
|
|
58
|
-
*
|
|
59
|
-
* **`legacyPrefix` is not dead code.** These names are PARSED to answer "is a
|
|
60
|
-
* newer shell published", and every release already on GitHub carries the old
|
|
61
|
-
* spelling. Dropping it would make the console answer "no update" for an
|
|
62
|
-
* installed shell the moment this ships — silently, because a name that does not
|
|
63
|
-
* match reads exactly like a release with no artifact. The probe tries the
|
|
64
|
-
* current prefix first, then the old one; new releases only ever write the
|
|
65
|
-
* current one.
|
|
66
|
-
*/
|
|
67
|
-
export const SHELL_ASSETS: Record<
|
|
68
|
-
ShellKind,
|
|
69
|
-
{ prefix: string; extension: string; legacyPrefix: string; legacyExtension: string }
|
|
70
|
-
> = {
|
|
71
|
-
android: {
|
|
72
|
-
prefix: 'Kabmo-',
|
|
73
|
-
extension: '-android.apk',
|
|
74
|
-
legacyPrefix: 'kabmo-android-v',
|
|
75
|
-
legacyExtension: '.apk',
|
|
76
|
-
},
|
|
77
|
-
ios: { prefix: 'Kabmo-', extension: '-ios.ipa', legacyPrefix: 'kabmo-ios-v', legacyExtension: '.ipa' },
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* `Kabmo-0.5.0-android.apk` → `0.5.0` (and the `.ipa` twin), plus the legacy
|
|
82
|
-
* `kabmo-android-v0.5.0.apk` spelling every published release still carries.
|
|
83
|
-
*
|
|
84
|
-
* The version is the MIDDLE of the name in the current scheme and the TAIL of the
|
|
85
|
-
* legacy one, so the two are parsed by different rules rather than by one that
|
|
86
|
-
* tries to cover both.
|
|
87
|
-
*/
|
|
88
|
-
export function versionFromAssetName(kind: ShellKind, name: string): string | null {
|
|
89
|
-
const { prefix, extension, legacyPrefix, legacyExtension } = SHELL_ASSETS[kind];
|
|
90
|
-
|
|
91
|
-
// Current: `<prefix><version><extension>`, where the prefix is the app's name
|
|
92
|
-
// and the extension names the platform.
|
|
93
|
-
if (name.startsWith(prefix) && name.endsWith(extension)) {
|
|
94
|
-
const middle = name.slice(prefix.length, -extension.length);
|
|
95
|
-
if (/^\d+\.\d+\.\d+$/.test(middle)) return middle;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Legacy: `kabmo-android-v<version>.apk`. Kept for the releases already
|
|
99
|
-
// published; no new release writes it.
|
|
100
|
-
if (name.startsWith(legacyPrefix) && name.endsWith(legacyExtension)) {
|
|
101
|
-
const middle = name.slice(legacyPrefix.length, -legacyExtension.length);
|
|
102
|
-
const version = middle.startsWith('v') ? middle.slice(1) : middle;
|
|
103
|
-
return /^\d+\.\d+\.\d+$/.test(version) ? version : null;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
return null;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
51
|
/**
|
|
110
52
|
* The newest release that actually carries this shell's artifact.
|
|
111
53
|
*
|
|
112
54
|
* NOT `/releases/latest`, which answers the newest release of ANY kind. This
|
|
113
|
-
* repository publishes
|
|
114
|
-
* released FIRST (`packages → marketplace → android →
|
|
55
|
+
* repository publishes several artifacts from one push, and the packages are
|
|
56
|
+
* released FIRST (`packages → marketplace → android → … → app` in
|
|
115
57
|
* `release.yml`), so their `kabmo-sdk/v*` / `kabmo-ui/v*` releases are newer
|
|
116
|
-
* than the app release that carries the
|
|
58
|
+
* than the app release that carries the shells. `@semantic-release/github` runs
|
|
117
59
|
* in both pipelines, so those are real releases — measured against a stub that
|
|
118
60
|
* answered a package release: `/releases/latest` produced
|
|
119
61
|
* `{url: null, latest: null}` and the sidebar's button disappeared even though
|
|
120
62
|
* the APK sat on the release directly below.
|
|
121
63
|
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* unauthenticated reads an hour per address, and the sidebar asks on every
|
|
126
|
-
* mount.
|
|
64
|
+
* Cached for five minutes on `globalThis` for the same reason the database
|
|
65
|
+
* handle is anchored there: `bun --hot` re-evaluates this module while the cache
|
|
66
|
+
* it would otherwise hold lives on.
|
|
127
67
|
*/
|
|
128
68
|
export async function fetchShellRelease(
|
|
129
69
|
kind: ShellKind,
|
|
@@ -133,25 +73,44 @@ export async function fetchShellRelease(
|
|
|
133
73
|
const now = Date.now();
|
|
134
74
|
if (cache.entry && now - cache.at < CACHE_MS) return cache.entry;
|
|
135
75
|
|
|
76
|
+
const headers = gitHubHeaders();
|
|
77
|
+
const resolved =
|
|
78
|
+
(await readFromApi(kind, fetchImpl, headers)) ?? (await readFromPages(kind, fetchImpl, headers));
|
|
79
|
+
if (!resolved) return cache.entry ?? null;
|
|
80
|
+
|
|
81
|
+
cache.entry = resolved;
|
|
82
|
+
cache.at = now;
|
|
83
|
+
return resolved;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** How many releases to look back through for the artifact. */
|
|
87
|
+
const RELEASE_SCAN = 20;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The REST read, or null when it did not answer with a usable release list.
|
|
91
|
+
*
|
|
92
|
+
* A non-2xx is not an error to report — it is the quota, and the caller has a
|
|
93
|
+
* second path.
|
|
94
|
+
*/
|
|
95
|
+
async function readFromApi(
|
|
96
|
+
kind: ShellKind,
|
|
97
|
+
fetchImpl: typeof fetch,
|
|
98
|
+
headers: Record<string, string>,
|
|
99
|
+
): Promise<ShellRelease | null> {
|
|
136
100
|
try {
|
|
137
101
|
const response = await fetchImpl(`${githubApiBase()}/repos/${REPO}/releases?per_page=${RELEASE_SCAN}`, {
|
|
138
|
-
headers: {
|
|
139
|
-
accept: 'application/vnd.github+json',
|
|
140
|
-
// GitHub rejects a request with no User-Agent.
|
|
141
|
-
'user-agent': 'kabmo-console',
|
|
142
|
-
},
|
|
102
|
+
headers: { accept: 'application/vnd.github+json', ...headers },
|
|
143
103
|
});
|
|
144
|
-
if (!response.ok) return
|
|
104
|
+
if (!response.ok) return null;
|
|
145
105
|
|
|
146
106
|
const body = (await response.json()) as unknown;
|
|
147
107
|
// A body that is not the release list is a failed probe, not an empty
|
|
148
108
|
// repository: answering `{url: null, latest: null}` here would be
|
|
149
|
-
// indistinguishable from "no release carries an artifact"
|
|
150
|
-
|
|
151
|
-
if (!Array.isArray(body)) return cache.entry ?? null;
|
|
109
|
+
// indistinguishable from "no release carries an artifact".
|
|
110
|
+
if (!Array.isArray(body)) return null;
|
|
152
111
|
|
|
153
112
|
// Drafts are not published and prereleases are not what a console offers an
|
|
154
|
-
// installed shell
|
|
113
|
+
// installed shell.
|
|
155
114
|
const published = body.filter(
|
|
156
115
|
(entry): entry is Record<string, unknown> =>
|
|
157
116
|
entry !== null && typeof entry === 'object' && entry.draft !== true && entry.prerelease !== true,
|
|
@@ -161,31 +120,34 @@ export async function fetchShellRelease(
|
|
|
161
120
|
.map((entry) => ({ entry, asset: shellAsset(kind, entry.assets) }))
|
|
162
121
|
.find((candidate) => candidate.asset !== null);
|
|
163
122
|
|
|
164
|
-
if (!release?.asset) {
|
|
165
|
-
const fallback: ShellRelease = { url: null, latest: null, releaseUrl: null };
|
|
166
|
-
cache.entry = fallback;
|
|
167
|
-
cache.at = now;
|
|
168
|
-
return fallback;
|
|
169
|
-
}
|
|
123
|
+
if (!release?.asset) return { url: null, latest: null, releaseUrl: null };
|
|
170
124
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
125
|
+
return resolvedRelease(kind, {
|
|
126
|
+
name: String(release.asset.name),
|
|
127
|
+
downloadUrl:
|
|
128
|
+
typeof release.asset.browser_download_url === 'string' ? release.asset.browser_download_url : null,
|
|
174
129
|
releaseUrl: typeof release.entry.html_url === 'string' ? release.entry.html_url : null,
|
|
175
|
-
};
|
|
176
|
-
|
|
177
|
-
cache.entry = resolved;
|
|
178
|
-
cache.at = now;
|
|
179
|
-
return resolved;
|
|
130
|
+
});
|
|
180
131
|
} catch {
|
|
181
|
-
|
|
182
|
-
// momentary outage must not remove a download button that was valid.
|
|
183
|
-
return cache.entry ?? null;
|
|
132
|
+
return null;
|
|
184
133
|
}
|
|
185
134
|
}
|
|
186
135
|
|
|
187
|
-
/**
|
|
188
|
-
|
|
136
|
+
/** The public-pages read, wrapped so a failure is the caller's `null`. */
|
|
137
|
+
async function readFromPages(
|
|
138
|
+
kind: ShellKind,
|
|
139
|
+
fetchImpl: typeof fetch,
|
|
140
|
+
headers: Record<string, string>,
|
|
141
|
+
): Promise<ShellRelease | null> {
|
|
142
|
+
try {
|
|
143
|
+
const found = await findAssetInPages(kind, fetchImpl, headers);
|
|
144
|
+
if (found.kind === 'unreachable') return null;
|
|
145
|
+
if (found.kind === 'none') return { url: null, latest: null, releaseUrl: null };
|
|
146
|
+
return resolvedRelease(kind, { ...found.asset, releaseUrl: found.releaseUrl });
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
189
151
|
|
|
190
152
|
/**
|
|
191
153
|
* The asset of a release that names this shell's artifact, or null.
|
|
@@ -198,9 +160,6 @@ const RELEASE_SCAN = 20;
|
|
|
198
160
|
* `kabmo-android-v0.4.0.apk` and `Kabmo-0.6.0-android.apk` answered `0.4.0`,
|
|
199
161
|
* i.e. the console offered an update to a version it already had and hid the
|
|
200
162
|
* newer one.
|
|
201
|
-
*
|
|
202
|
-
* Both schemes are still ACCEPTED (a release with only the old spelling must
|
|
203
|
-
* keep working), so the preference is a second pass rather than a filter.
|
|
204
163
|
*/
|
|
205
164
|
function shellAsset(kind: ShellKind, assets: unknown): Record<string, unknown> | null {
|
|
206
165
|
if (!Array.isArray(assets)) return null;
|
|
@@ -213,15 +172,21 @@ function shellAsset(kind: ShellKind, assets: unknown): Record<string, unknown> |
|
|
|
213
172
|
versionFromAssetName(kind, String((entry as Record<string, unknown>).name)) !== null,
|
|
214
173
|
);
|
|
215
174
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
175
|
+
return named.find((asset) => SHELL_ASSETS[kind].current.test(String(asset.name))) ?? named[0] ?? null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Turn one resolved asset into the payload, applying the per-kind link rule. */
|
|
179
|
+
function resolvedRelease(
|
|
180
|
+
kind: ShellKind,
|
|
181
|
+
asset: { name: string; downloadUrl: string | null; releaseUrl: string | null },
|
|
182
|
+
): ShellRelease {
|
|
183
|
+
return {
|
|
184
|
+
// A per-arch desktop build cannot be named from here — see the module
|
|
185
|
+
// comment — so the link is the page that lists every architecture.
|
|
186
|
+
url: SHELL_ASSETS[kind].directDownload ? asset.downloadUrl : asset.releaseUrl,
|
|
187
|
+
latest: versionFromAssetName(kind, asset.name),
|
|
188
|
+
releaseUrl: asset.releaseUrl,
|
|
189
|
+
};
|
|
225
190
|
}
|
|
226
191
|
|
|
227
192
|
const CACHE_MS = 5 * 60_000;
|
|
@@ -239,6 +204,25 @@ function githubApiBase(): string {
|
|
|
239
204
|
return configured.trim().replace(/\/+$/, '') || 'https://api.github.com';
|
|
240
205
|
}
|
|
241
206
|
|
|
207
|
+
/**
|
|
208
|
+
* A token, when the operator has one, which lifts the API quota from 60 an hour
|
|
209
|
+
* to 5000 — the difference between the precise read working on a shared address
|
|
210
|
+
* and falling through to the pages every time.
|
|
211
|
+
*
|
|
212
|
+
* Read from the environment rather than a setting: it is a deployment fact, it
|
|
213
|
+
* must never reach the browser, and `kabmo serve` already passes its own
|
|
214
|
+
* environment through.
|
|
215
|
+
*/
|
|
216
|
+
function gitHubHeaders(): Record<string, string> {
|
|
217
|
+
const token = (Bun.env.KABMO_GITHUB_TOKEN ?? Bun.env.GITHUB_TOKEN ?? '').trim();
|
|
218
|
+
const headers: Record<string, string> = {
|
|
219
|
+
// GitHub rejects a request with no User-Agent.
|
|
220
|
+
'user-agent': 'kabmo-console',
|
|
221
|
+
};
|
|
222
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
223
|
+
return headers;
|
|
224
|
+
}
|
|
225
|
+
|
|
242
226
|
interface CacheSlot {
|
|
243
227
|
entry: ShellRelease | null;
|
|
244
228
|
at: number;
|