@kabmo-labs/kabmo 0.6.1 → 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-aq0e78gv.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 +74 -1
  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 +126 -50
  101. package/src/server/lib/updates/shell-release.ts +139 -88
  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
@@ -2,85 +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
- * 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`).
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
- * 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.
10
+ * ## Two reads, because one of them has a quota
15
11
  *
16
- * Two facts travel back, and they answer different questions:
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
- * - `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.
18
+ * ## What travels back
26
19
  *
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.
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.
30
34
  */
31
35
 
32
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';
33
39
  import type { ShellKind } from '@/shared/lib/updates/shell-ua';
34
40
 
35
41
  /** One published shell artifact, as the console needs it. */
36
42
  export interface ShellRelease {
37
- /** Asset download URL, or null when the release carries no artifact. */
43
+ /** Asset download URL, or the release page for a per-arch desktop build. */
38
44
  url: string | null;
39
- /** Version the asset name declares (`v1.2.3` → `1.2.3`), or null. */
45
+ /** Version the asset name declares (`Kabmo-1.2.3-android.apk` → `1.2.3`). */
40
46
  latest: string | null;
41
47
  /** The release page, for a "what changed" link. */
42
48
  releaseUrl: string | null;
43
49
  }
44
50
 
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
51
  /**
67
52
  * The newest release that actually carries this shell's artifact.
68
53
  *
69
54
  * 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
55
+ * repository publishes several artifacts from one push, and the packages are
56
+ * released FIRST (`packages → marketplace → android → … → app` in
72
57
  * `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
58
+ * than the app release that carries the shells. `@semantic-release/github` runs
74
59
  * in both pipelines, so those are real releases — measured against a stub that
75
60
  * answered a package release: `/releases/latest` produced
76
61
  * `{url: null, latest: null}` and the sidebar's button disappeared even though
77
62
  * the APK sat on the release directly below.
78
63
  *
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.
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.
84
67
  */
85
68
  export async function fetchShellRelease(
86
69
  kind: ShellKind,
@@ -90,25 +73,44 @@ export async function fetchShellRelease(
90
73
  const now = Date.now();
91
74
  if (cache.entry && now - cache.at < CACHE_MS) return cache.entry;
92
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> {
93
100
  try {
94
101
  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
- },
102
+ headers: { accept: 'application/vnd.github+json', ...headers },
100
103
  });
101
- if (!response.ok) return cache.entry ?? null;
104
+ if (!response.ok) return null;
102
105
 
103
106
  const body = (await response.json()) as unknown;
104
107
  // A body that is not the release list is a failed probe, not an empty
105
108
  // 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
+ // indistinguishable from "no release carries an artifact".
110
+ if (!Array.isArray(body)) return null;
109
111
 
110
112
  // Drafts are not published and prereleases are not what a console offers an
111
- // installed shell — the same two the `latest` endpoint excluded.
113
+ // installed shell.
112
114
  const published = body.filter(
113
115
  (entry): entry is Record<string, unknown> =>
114
116
  entry !== null && typeof entry === 'object' && entry.draft !== true && entry.prerelease !== true,
@@ -118,43 +120,73 @@ export async function fetchShellRelease(
118
120
  .map((entry) => ({ entry, asset: shellAsset(kind, entry.assets) }))
119
121
  .find((candidate) => candidate.asset !== null);
120
122
 
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
- }
123
+ if (!release?.asset) return { url: null, latest: null, releaseUrl: null };
127
124
 
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)),
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,
131
129
  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;
130
+ });
137
131
  } 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;
132
+ return null;
141
133
  }
142
134
  }
143
135
 
144
- /** How many releases to look back through for the artifact. */
145
- const RELEASE_SCAN = 20;
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
+ }
146
151
 
147
- /** The first asset of a release that names this shell's artifact, or null. */
152
+ /**
153
+ * The asset of a release that names this shell's artifact, or null.
154
+ *
155
+ * The CURRENT scheme wins over the legacy one, and that is not cosmetic. A
156
+ * release that carries both — which happens the moment a rename lands and
157
+ * `--clobber` has not yet replaced the old asset, or a hand-upload adds one
158
+ * beside the other — would otherwise resolve to whichever the asset list
159
+ * happened to order first. Measured: a release holding both
160
+ * `kabmo-android-v0.4.0.apk` and `Kabmo-0.6.0-android.apk` answered `0.4.0`,
161
+ * i.e. the console offered an update to a version it already had and hid the
162
+ * newer one.
163
+ */
148
164
  function shellAsset(kind: ShellKind, assets: unknown): Record<string, unknown> | null {
149
165
  if (!Array.isArray(assets)) return null;
150
166
 
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
- }
167
+ const named = assets.filter(
168
+ (entry): entry is Record<string, unknown> =>
169
+ entry !== null &&
170
+ typeof entry === 'object' &&
171
+ typeof (entry as Record<string, unknown>).name === 'string' &&
172
+ versionFromAssetName(kind, String((entry as Record<string, unknown>).name)) !== null,
173
+ );
156
174
 
157
- return null;
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
+ };
158
190
  }
159
191
 
160
192
  const CACHE_MS = 5 * 60_000;
@@ -172,6 +204,25 @@ function githubApiBase(): string {
172
204
  return configured.trim().replace(/\/+$/, '') || 'https://api.github.com';
173
205
  }
174
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
+
175
226
  interface CacheSlot {
176
227
  entry: ShellRelease | null;
177
228
  at: number;
@@ -6,12 +6,12 @@
6
6
  /**
7
7
  * The Files panel's "New file" branch.
8
8
  *
9
- * Two rules are load-bearing and neither is visible from the happy path: the
10
- * name may not carry a path separator (the folder is named by `path`, and a
11
- * separator would turn a name into an arbitrary write under the root), and an
12
- * existing file is REFUSED rather than truncated. The second one is the one a
13
- * naive `Bun.write` gets wrong: `Bun.write` creates or truncates, so a name
14
- * that collides with a real file would silently blank it.
9
+ * Two rules are load-bearing and neither is visible from the happy path. The
10
+ * name may carry SEGMENTS (that is the nested-path grammar, and its refusals
11
+ * live in `action-nested.test.ts`), and an existing file is REFUSED rather
12
+ * than truncated — the second is the one a naive `Bun.write` gets wrong:
13
+ * `Bun.write` creates or truncates, so a name that collides with a real file
14
+ * would silently blank it.
15
15
  */
16
16
 
17
17
  import { afterEach, describe, expect, test } from 'bun:test';
@@ -34,7 +34,10 @@ async function create(folderPath: string, name: string) {
34
34
  form.append('path', folderPath);
35
35
  form.append('name', name);
36
36
  const res = await action({ request: new Request('http://localhost/api/fs/action', { method: 'POST', body: form }) } as never);
37
- return { status: res.status, body: (await res.json()) as { success?: boolean; path?: string; error?: string } };
37
+ return {
38
+ status: res.status,
39
+ body: (await res.json()) as { success?: boolean; kind?: string; path?: string; error?: string },
40
+ };
38
41
  }
39
42
 
40
43
  describe('fs create', () => {
@@ -43,7 +46,7 @@ describe('fs create', () => {
43
46
  const { status, body } = await create('tmp-create-probe', 'notes.md');
44
47
 
45
48
  expect(status).toBe(200);
46
- expect(body).toEqual({ success: true, path: 'tmp-create-probe/notes.md' });
49
+ expect(body).toEqual({ success: true, kind: 'file', path: 'tmp-create-probe/notes.md' });
47
50
  expect(fs.readFileSync(path.join(DIR, 'notes.md'), 'utf8')).toBe('');
48
51
  });
49
52
 
@@ -67,12 +70,14 @@ describe('fs create', () => {
67
70
  expect(fs.readFileSync(path.join(DIR, 'keep.txt'), 'utf8')).toBe('important');
68
71
  });
69
72
 
70
- test('refuses a name carrying a path separator', async () => {
73
+ test('a path separator is a NESTED path, and `..` is still refused', async () => {
71
74
  fs.mkdirSync(DIR, { recursive: true });
72
- for (const name of ['../escape.txt', 'sub/escape.txt', 'sub\\escape.txt']) {
73
- const { status } = await create('tmp-create-probe', name);
74
- expect(status).toBe(400);
75
- }
75
+ // A forward or back separator names a nested destination, which the dialog
76
+ // is for — the escape itself is the `..` segment, and that stays refused.
77
+ expect((await create('tmp-create-probe', 'sub/inside.txt')).status).toBe(200);
78
+ expect((await create('tmp-create-probe', 'sub\\also-inside.txt')).status).toBe(200);
79
+ expect((await create('tmp-create-probe', '../escape.txt')).status).toBe(400);
80
+ expect(fs.existsSync(path.join(DIR, 'sub/inside.txt'))).toBe(true);
76
81
  expect(fs.existsSync(path.join(process.cwd(), 'escape.txt'))).toBe(false);
77
82
  });
78
83
 
@@ -0,0 +1,184 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Nested paths at the route: a create and a rename that name SEGMENTS.
8
+ *
9
+ * The Files panel's dialogs used to accept one segment, so `lib/parse.ts` was
10
+ * unreachable from the UI — the user had to create `lib`, expand it, then the
11
+ * file. Both actions now take a relative path, and the rules that keep that
12
+ * inside the folder the user named are asserted here because they are the whole
13
+ * reason a nested path is safe:
14
+ *
15
+ * - a `..` SEGMENT is refused even when it would land back inside the root
16
+ * (`a/../b.ts` resolves there), so the refusal is about the path the user
17
+ * typed rather than about where it happened to land;
18
+ * - an ABSOLUTE path is refused, because `path.join(dir, abs)` discards the
19
+ * folder the dialog showed;
20
+ * - a rename resolves against the LISTED root, not the workspace root — a
21
+ * rename inside a nested repo used to resolve to a path outside the repo;
22
+ * - a rename never overwrites an existing destination, and never moves a folder
23
+ * into its own subtree.
24
+ */
25
+
26
+ import { afterEach, describe, expect, test } from 'bun:test';
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+ import { action } from '@/server/routes/fs/action';
30
+
31
+ /** Probes live in the repo root — the one directory the root allow-list always accepts. */
32
+ const DIR = path.join(process.cwd(), 'tmp-nested-probe');
33
+
34
+ function reset(files: Record<string, string> = {}): void {
35
+ fs.rmSync(DIR, { recursive: true, force: true });
36
+ fs.mkdirSync(DIR, { recursive: true });
37
+ for (const [rel, content] of Object.entries(files)) {
38
+ const target = path.join(DIR, rel);
39
+ fs.mkdirSync(path.dirname(target), { recursive: true });
40
+ fs.writeFileSync(target, content);
41
+ }
42
+ }
43
+
44
+ afterEach(() => {
45
+ fs.rmSync(DIR, { recursive: true, force: true });
46
+ });
47
+
48
+ async function post(fields: Record<string, string>) {
49
+ const form = new FormData();
50
+ form.append('root', process.cwd());
51
+ for (const [key, value] of Object.entries(fields)) form.append(key, value);
52
+ const res = await action({ request: new Request('http://localhost/api/fs/action', { method: 'POST', body: form }) } as never);
53
+ return { status: res.status, body: (await res.json()) as { success?: boolean; kind?: string; path?: string; previousPath?: string; error?: string } };
54
+ }
55
+
56
+ describe('fs create with a nested path', () => {
57
+ test('creates every directory that leads to the file', async () => {
58
+ reset();
59
+
60
+ const { status, body } = await post({ actionType: 'create', path: 'tmp-nested-probe', name: 'lib/deep/parse.ts' });
61
+
62
+ expect(status).toBe(200);
63
+ expect(body).toEqual({ success: true, kind: 'file', path: 'tmp-nested-probe/lib/deep/parse.ts' });
64
+ expect(fs.readFileSync(path.join(DIR, 'lib/deep/parse.ts'), 'utf8')).toBe('');
65
+ });
66
+
67
+ test('creates a FOLDER when asked, and reports the kind back', async () => {
68
+ reset();
69
+
70
+ const { status, body } = await post({ actionType: 'create', path: 'tmp-nested-probe', name: 'a/b', kind: 'folder' });
71
+
72
+ expect(status).toBe(200);
73
+ expect(body).toEqual({ success: true, kind: 'folder', path: 'tmp-nested-probe/a/b' });
74
+ expect(fs.statSync(path.join(DIR, 'a/b')).isDirectory()).toBe(true);
75
+ });
76
+
77
+ test('normalizes a backslash and repeated separators into one path', async () => {
78
+ reset();
79
+
80
+ const { body } = await post({ actionType: 'create', path: 'tmp-nested-probe', name: 'x\\\\y//z.ts' });
81
+
82
+ expect(body.path).toBe('tmp-nested-probe/x/y/z.ts');
83
+ expect(fs.existsSync(path.join(DIR, 'x/y/z.ts'))).toBe(true);
84
+ });
85
+
86
+ test('refuses a .. segment even when it would land inside the root', async () => {
87
+ reset({ 'keep/.gitkeep': '' });
88
+
89
+ // `keep/../escape.ts` resolves inside the probe directory, so a containment
90
+ // check alone would accept it — the refusal is about the path as typed.
91
+ const { status, body } = await post({ actionType: 'create', path: 'tmp-nested-probe', name: 'keep/../escape.ts' });
92
+
93
+ expect(status).toBe(400);
94
+ expect(body.error).toContain('..');
95
+ expect(fs.existsSync(path.join(DIR, 'escape.ts'))).toBe(false);
96
+ });
97
+
98
+ test('refuses an absolute path', async () => {
99
+ reset();
100
+
101
+ for (const name of ['/tmp/absolute.ts', 'C:/absolute.ts', 'C:relative.ts']) {
102
+ expect((await post({ actionType: 'create', path: 'tmp-nested-probe', name })).status).toBe(400);
103
+ }
104
+ });
105
+
106
+ test('refuses a nested path whose final entry already exists', async () => {
107
+ reset({ 'lib/parse.ts': 'keep me' });
108
+
109
+ const { status, body } = await post({ actionType: 'create', path: 'tmp-nested-probe', name: 'lib/parse.ts' });
110
+
111
+ expect(status).toBe(409);
112
+ expect(body.error).toContain('already exists');
113
+ expect(fs.readFileSync(path.join(DIR, 'lib/parse.ts'), 'utf8')).toBe('keep me');
114
+ });
115
+ });
116
+
117
+ describe('fs rename with a nested path', () => {
118
+ test('renames into a new nested folder, creating its parents', async () => {
119
+ reset({ 'a.ts': 'A' });
120
+
121
+ const { status, body } = await post({ actionType: 'rename', path: 'tmp-nested-probe/a.ts', newPath: 'tmp-nested-probe/lib/deep/a.ts' });
122
+
123
+ expect(status).toBe(200);
124
+ expect(body).toEqual({ success: true, path: 'tmp-nested-probe/lib/deep/a.ts', previousPath: 'tmp-nested-probe/a.ts' });
125
+ expect(fs.existsSync(path.join(DIR, 'a.ts'))).toBe(false);
126
+ expect(fs.readFileSync(path.join(DIR, 'lib/deep/a.ts'), 'utf8')).toBe('A');
127
+ });
128
+
129
+ test('a plain rename inside one folder keeps its siblings', async () => {
130
+ reset({ 'src/a.ts': 'A', 'src/b.ts': 'B' });
131
+
132
+ const { body } = await post({ actionType: 'rename', path: 'tmp-nested-probe/src/a.ts', newPath: 'tmp-nested-probe/src/c.ts' });
133
+
134
+ expect(body.path).toBe('tmp-nested-probe/src/c.ts');
135
+ expect(fs.existsSync(path.join(DIR, 'src/b.ts'))).toBe(true);
136
+ });
137
+
138
+ test('refuses an existing destination instead of clobbering it', async () => {
139
+ reset({ 'a.ts': 'A', 'b.ts': 'B' });
140
+
141
+ const { status, body } = await post({ actionType: 'rename', path: 'tmp-nested-probe/a.ts', newPath: 'tmp-nested-probe/b.ts' });
142
+
143
+ expect(status).toBe(409);
144
+ expect(body.error).toContain('already exists');
145
+ expect(fs.readFileSync(path.join(DIR, 'b.ts'), 'utf8')).toBe('B');
146
+ });
147
+
148
+ test('refuses moving a folder into its own subtree', async () => {
149
+ reset({ 'src/a.ts': 'A' });
150
+
151
+ const { status, body } = await post({ actionType: 'rename', path: 'tmp-nested-probe/src', newPath: 'tmp-nested-probe/src/deep' });
152
+
153
+ expect(status).toBe(400);
154
+ expect(body.error).toContain('inside itself');
155
+ expect(fs.existsSync(path.join(DIR, 'src/a.ts'))).toBe(true);
156
+ });
157
+
158
+ test('refuses a .. segment and an absolute destination', async () => {
159
+ reset({ 'a.ts': 'A' });
160
+
161
+ expect((await post({ actionType: 'rename', path: 'tmp-nested-probe/a.ts', newPath: 'tmp-nested-probe/../out.ts' })).status).toBe(400);
162
+ expect((await post({ actionType: 'rename', path: 'tmp-nested-probe/a.ts', newPath: '/tmp/out.ts' })).status).toBe(400);
163
+ expect(fs.existsSync(path.join(DIR, 'a.ts'))).toBe(true);
164
+ });
165
+
166
+ test('renaming to the same path is a no-op that still reports both ends', async () => {
167
+ reset({ 'a.ts': 'A' });
168
+
169
+ const { status, body } = await post({ actionType: 'rename', path: 'tmp-nested-probe/a.ts', newPath: 'tmp-nested-probe/a.ts' });
170
+
171
+ expect(status).toBe(200);
172
+ expect(body.path).toBe('tmp-nested-probe/a.ts');
173
+ expect(fs.readFileSync(path.join(DIR, 'a.ts'), 'utf8')).toBe('A');
174
+ });
175
+
176
+ test('a missing source is refused rather than creating an empty entry', async () => {
177
+ reset();
178
+
179
+ const { status } = await post({ actionType: 'rename', path: 'tmp-nested-probe/gone.ts', newPath: 'tmp-nested-probe/here.ts' });
180
+
181
+ expect(status).toBe(404);
182
+ expect(fs.existsSync(path.join(DIR, 'here.ts'))).toBe(false);
183
+ });
184
+ });
@@ -3,10 +3,11 @@ import { errorResponse } from '@/server/lib/route-adapter';
3
3
  import fs from 'fs';
4
4
  import path from 'path';
5
5
  import { isMockMode } from '@/server/mock.server';
6
- import { getDefaultFsRoot, resolveRoot, resolveWithinRoot } from '@/server/lib/fs/root';
6
+ import { getDefaultFsRoot, isWithinRoot, resolveRoot, resolveWithinRoot } from '@/server/lib/fs/root';
7
7
  import { runShell } from '@/server/lib/fs/shell';
8
8
  import { revealInFileManager } from '@/server/lib/fs/reveal';
9
- import { duplicateEntry, nextDuplicateName, pasteEntry } from '@/server/lib/fs/transfer';
9
+ import { duplicateEntry, nextDuplicateName, pasteEntry, toRelPath } from '@/server/lib/fs/transfer';
10
+ import { normalizeRelativePath } from '@/server/lib/fs/relative-path';
10
11
  import { transferRefusal } from '@/shared/lib/fs/clipboard';
11
12
  import { toDiskText } from '@/shared/lib/code/line-endings';
12
13
 
@@ -104,13 +105,15 @@ export async function action({ request }: ActionFunctionArgs) {
104
105
  await Bun.write(fullPath, content);
105
106
  return json({ success: true });
106
107
  } else if (actionType === 'create') {
107
- // The Files panel's "New file": an empty file beside the folder that was
108
- // right-clicked. Only a NAME is accepted, never a path — a separator
109
- // would let the panel write anywhere under the root, and the folder is
110
- // already named by `path`.
111
- const name = ((formData.get('name') as string) ?? '').trim();
112
- if (!name || name === '.' || name === '..' || /[/\\\0]/.test(name)) {
113
- return json({ error: 'Enter a file name without slashes' }, { status: 400 });
108
+ // The Files panel's "New file"/"New folder": an empty entry beside the
109
+ // folder that was right-clicked. The NAME may carry segments
110
+ // (`lib/parse.ts`), so one dialog creates a nested path and every
111
+ // intermediate directory with it — the grammar that keeps it inside the
112
+ // named folder lives in `normalizeRelativePath`, not in a slash test here.
113
+ const kind = formData.get('kind') === 'folder' ? 'folder' : 'file';
114
+ const name = normalizeRelativePath((formData.get('name') as string) ?? '');
115
+ if (!name) {
116
+ return json({ error: 'Enter a name without .. or a leading slash' }, { status: 400 });
114
117
  }
115
118
  const target = resolveWithinRoot(scopedRoot, path.join(filePath || '.', name));
116
119
  if (!target) {
@@ -122,21 +125,49 @@ export async function action({ request }: ActionFunctionArgs) {
122
125
  if ((await Bun.file(target).stat().catch(() => null)) !== null) {
123
126
  return json({ error: `${name} already exists` }, { status: 409 });
124
127
  }
125
- await fs.promises.mkdir(path.dirname(target), { recursive: true });
126
- await Bun.write(target, '');
128
+ if (kind === 'folder') await fs.promises.mkdir(target, { recursive: true });
129
+ else {
130
+ await fs.promises.mkdir(path.dirname(target), { recursive: true });
131
+ await Bun.write(target, '');
132
+ }
127
133
  // Relative to the LISTED root, which is the identity the tree renders by.
128
- return json({ success: true, path: path.relative(scopedRoot, target).split(path.sep).join('/') });
134
+ return json({ success: true, kind, path: toRelPath(scopedRoot, target) });
129
135
  } else if (actionType === 'delete') {
130
136
  await fs.promises.rm(fullPath, { recursive: true, force: true });
131
137
  return json({ success: true });
132
138
  } else if (actionType === 'rename') {
133
- const newPath = formData.get('newPath') as string;
134
- const fullNewPath = path.resolve(rootDir, newPath);
135
- if (fullNewPath !== rootDir && !fullNewPath.startsWith(rootDir + path.sep)) {
139
+ // `newPath` is relative to the LISTED root, exactly like `path` is. It
140
+ // used to be resolved against `rootDir`, so renaming inside a nested repo
141
+ // resolved to a path under the workspace root instead of the repo — a
142
+ // move out of the tree the row belongs to.
143
+ const newPath = normalizeRelativePath((formData.get('newPath') as string) ?? '');
144
+ if (!newPath) {
145
+ return json({ error: 'Enter a name without .. or a leading slash' }, { status: 400 });
146
+ }
147
+ const fullNewPath = resolveWithinRoot(scopedRoot, newPath);
148
+ if (!fullNewPath) {
136
149
  return json({ error: 'Invalid new path' }, { status: 403 });
137
150
  }
151
+ if (fullNewPath === fullPath) return json({ success: true, path: newPath, previousPath: filePath });
152
+ const source = await Bun.file(fullPath).stat().catch(() => null);
153
+ if (!source) return json({ error: 'That entry no longer exists' }, { status: 404 });
154
+ // A folder cannot be moved into itself: `rename(a, a/b)` fails with
155
+ // EINVAL on every platform, but with an errno the panel cannot explain —
156
+ // and the destination directory may not exist yet, so the check has to be
157
+ // on the PATHS rather than on the filesystem.
158
+ if (source.isDirectory() && isWithinRoot(fullPath, fullNewPath)) {
159
+ return json({ error: 'A folder cannot be moved inside itself' }, { status: 400 });
160
+ }
161
+ // `rename` clobbers silently, so an existing destination is refused: the
162
+ // entry the user meant to keep would otherwise disappear without a word.
163
+ if ((await Bun.file(fullNewPath).stat().catch(() => null)) !== null) {
164
+ return json({ error: `${newPath} already exists` }, { status: 409 });
165
+ }
166
+ // The destination's parents are created, which is what makes a rename
167
+ // INTO a new folder work — the same nested grammar the create accepts.
168
+ await fs.promises.mkdir(path.dirname(fullNewPath), { recursive: true });
138
169
  await fs.promises.rename(fullPath, fullNewPath);
139
- return json({ success: true });
170
+ return json({ success: true, path: newPath, previousPath: filePath });
140
171
  } else if (actionType === 'open_explorer') {
141
172
  const isDir = (await Bun.file(fullPath).stat().catch(() => null))?.isDirectory() ?? false;
142
173
  // A file reveals its containing directory — there is no portable "select