History

Add local directory picker endpoint

Rhys Sullivan committed 4 months ago

Commit cf8e9371 parent 1605a3212 files · +491 −28Browse files

Unified · Split

12 files
  1. apps/host-selfhost/src/boot.test.ts +3 −0
  2. packages/plugins/apps/src/api.ts +4 −4
  3. packages/plugins/apps/src/plugin/apps-plugin.test.ts +11 −0
  4. packages/plugins/apps/src/plugin/apps-plugin.ts +13 −1
  5. packages/plugins/apps/src/plugin/handlers.ts +64 −1
  6. packages/plugins/apps/src/plugin/routes.ts +32 −1
  7. packages/plugins/apps/src/react/AddCustomToolsSource.tsx +150 −14
  8. packages/plugins/apps/src/react/custom-tools-client.ts +34 −0
  9. packages/plugins/apps/src/react/custom-tools-client.web.test.ts +33 −0
  10. packages/plugins/apps/src/react/source-panel-model.ts +28 −1
  11. packages/plugins/apps/src/source/app-source.test.ts +45 −2
  12. packages/plugins/apps/src/source/local-directory-source.ts +74 −4
apps/host-selfhost/src/boot.test.ts+3−0
@@ -112,6 +112,9 @@ test("selfhost rejects local-directory app sources over HTTP", async () => { · 111 unmodified lines
112112 }),
113113 );
114114 expect(created.status).toBe(500);
115+
116+ const dirs = await handler(new Request("http://localhost/api/apps/fs/dirs"));
117+ expect(dirs.status).toBe(404);
115118 });
116119
117120 test("apps source sync publishes and invokes a local-directory tool over HTTP", async () => {
packages/plugins/apps/src/api.ts+4−4
@@ -1,8 +1,8 @@
11 import { definePlugin } from "@executor-js/sdk/core";
22
33 import { makeAppsPlugin, type AppsPluginOptions } from "./plugin/apps-plugin";
4−import { AppsExtensionService, AppsHandlers } from "./plugin/handlers";
5−import { AppsGroup } from "./plugin/routes";
4+import { AppsExtensionService, appsHandlersForSourceKinds } from "./plugin/handlers";
5+import { appsGroupForSourceKinds } from "./plugin/routes";
66
77 export { AppsGroup } from "./plugin/routes";
88 export { AppsHandlers, AppsExtensionService } from "./plugin/handlers";
@@ -12,7 +12,7 @@ export { makeAppsPlugin, appsPlugin } from "./plugin/apps-plugin"; · 3 unmodified lines
1212
1313 export const appsHttpPlugin = definePlugin((options?: AppsPluginOptions) => ({
1414 ...makeAppsPlugin(options),
15− routes: () => AppsGroup,
16− handlers: () => AppsHandlers,
15+ routes: () => appsGroupForSourceKinds(options?.sourceKinds),
16+ handlers: () => appsHandlersForSourceKinds(options?.sourceKinds),
1717 extensionService: AppsExtensionService,
1818 }));
packages/plugins/apps/src/plugin/apps-plugin.test.ts+11−0
@@ -407,6 +407,17 @@ describe("apps source sync", () => { · 406 unmodified lines
407407 }),
408408 );
409409
410+ it.effect("rejects directory listing when local-directory sources are disabled", () =>
411+ Effect.gen(function* () {
412+ const plugin = makeAppsPlugin({ sourceKinds: ["git"] });
413+ const extension = plugin.extension!({
414+ storage: makeSyncStore(),
415+ } as never);
416+ const exit = yield* Effect.exit(extension.listDirs({ path: "/tmp" }));
417+ expect(Exit.isFailure(exit)).toBe(true);
418+ }),
419+ );
420+
410421 it.effect("syncs git sources, no-ops unchanged refs, and republishes changed refs", () =>
411422 Effect.gen(function* () {
412423 const fixture = yield* Effect.promise(() => fixtureFetch());
packages/plugins/apps/src/plugin/apps-plugin.ts+13−1
@@ -34,7 +34,10 @@ import { · 33 unmodified lines
3434 type SyncDiagnostic,
3535 } from "../source/app-source";
3636 import { checkGitAppSourceRefs, fetchGitAppSource, parseGitSourceUrl } from "../source/git-source";
37−import { fetchLocalDirectoryAppSource } from "../source/local-directory-source";
37+import {
38+ fetchLocalDirectoryAppSource,
39+ listLocalDirectoryDirs,
40+} from "../source/local-directory-source";
3841 import { AppSourceError, type AppSourceSnapshot } from "../source/app-source";
3942 import type { PublishError } from "../pipeline/publish";
4043
@@ -285,6 +288,15 @@ const makeAppsExtension = ( · 244 unmodified lines
285288 publish({ store: ctx.storage, executor: activeExecutor, bundler: activeBundler }, input),
286289 listSources: () => ctx.storage.listSources(),
287290 getSource: (slug: string) => ctx.storage.getSource(slug),
291+ listDirs: (input: { readonly path?: string; readonly includeHidden?: boolean }) =>
292+ Effect.gen(function* () {
293+ if (!sourceKinds.includes("local-directory")) {
294+ return yield* new AppPluginError({
295+ message: "app source kind is not enabled: local-directory",
296+ });
297+ }
298+ return yield* listLocalDirectoryDirs(input);
299+ }),
288300 createSource: (input: CreateAppSourceInput) =>
289301 Effect.gen(function* () {
290302 const config = sourceConfig(input);
packages/plugins/apps/src/plugin/handlers.ts+64−1
@@ -5,12 +5,13 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; · 4 unmodified lines
55 import { addGroup, capture } from "@executor-js/api";
66 import { InternalError } from "@executor-js/sdk/shared";
77 import type { AppsExtension } from "./apps-plugin";
8−import { AppsGroup } from "./routes";
8+import { AppsBaseGroup, AppsGroup } from "./routes";
99
1010 export class AppsExtensionService extends Context.Service<AppsExtensionService, AppsExtension>()(
1111 "AppsExtensionService",
1212 ) {}
1313
14+const ExecutorApiWithAppsBase = addGroup(AppsBaseGroup);
1415 const ExecutorApiWithApps = addGroup(AppsGroup);
1516
1617 const captureUnexpected = <A, E, R>(
@@ -18,6 +19,53 @@ const captureUnexpected = <A, E, R>( · 1 unmodified line
1819 ): Effect.Effect<A, InternalError, R> =>
1920 capture(effect.pipe(Effect.catch((cause: unknown) => Effect.die(cause))));
2021
22+export const AppsBaseHandlers = HttpApiBuilder.group(ExecutorApiWithAppsBase, "apps", (handlers) =>
23+ handlers
24+ .handle("listSources", () =>
25+ captureUnexpected(
26+ Effect.gen(function* () {
27+ const ext = yield* AppsExtensionService;
28+ const sources = yield* ext.listSources();
29+ return { sources };
30+ }),
31+ ),
32+ )
33+ .handle("createSource", ({ payload }) =>
34+ captureUnexpected(
35+ Effect.gen(function* () {
36+ const ext = yield* AppsExtensionService;
37+ const source = yield* ext.createSource(payload);
38+ return { source };
39+ }),
40+ ),
41+ )
42+ .handle("getSource", ({ params }) =>
43+ captureUnexpected(
44+ Effect.gen(function* () {
45+ const ext = yield* AppsExtensionService;
46+ const source = yield* ext.getSource(params.slug);
47+ return { source };
48+ }),
49+ ),
50+ )
51+ .handle("deleteSource", ({ params }) =>
52+ captureUnexpected(
53+ Effect.gen(function* () {
54+ const ext = yield* AppsExtensionService;
55+ return yield* ext.deleteSource(params.slug);
56+ }),
57+ ),
58+ )
59+ .handle("syncSource", ({ params }) =>
60+ captureUnexpected(
61+ Effect.gen(function* () {
62+ const ext = yield* AppsExtensionService;
63+ return yield* ext.syncSource(params.slug);
64+ }),
65+ ),
66+ ),
67+);
68+
2169 export const AppsHandlers = HttpApiBuilder.group(ExecutorApiWithApps, "apps", (handlers) =>
2270 handlers
2371 .handle("listSources", () =>
@@ -62,5 +110,20 @@ export const AppsHandlers = HttpApiBuilder.group(ExecutorApiWithApps, "apps", (h · 38 unmodified lines
62110 return yield* ext.syncSource(params.slug);
63111 }),
64112 ),
113+ )
114+ .handle("listDirs", ({ query }) =>
115+ captureUnexpected(
116+ Effect.gen(function* () {
117+ const ext = yield* AppsExtensionService;
118+ return yield* ext.listDirs({
119+ ...(query.path ? { path: query.path } : {}),
120+ includeHidden: query.includeHidden === "true" || query.includeHidden === "1",
121+ });
122+ }),
123+ ),
65124 ),
66125 );
126+
127+export const appsHandlersForSourceKinds = (
128+ sourceKinds: readonly ("git" | "local-directory")[] | undefined,
129+) => ((sourceKinds?.includes("local-directory") ?? true) ? AppsHandlers : AppsBaseHandlers);
packages/plugins/apps/src/plugin/routes.ts+32−1
@@ -67,9 +67,25 @@ const SyncSourceResponse = Schema.Struct({ · 66 unmodified lines
6767 errors: Schema.optional(Schema.Array(SyncDiagnostic)),
6868 });
6969
70+const ListDirsQuery = Schema.Struct({
71+ path: Schema.optional(Schema.String),
72+ includeHidden: Schema.optional(Schema.String),
73+});
74+const ListDirsResponse = Schema.Struct({
75+ path: Schema.String,
76+ parent: Schema.NullOr(Schema.String),
77+ dirs: Schema.Array(
78+ Schema.Struct({
79+ name: Schema.String,
80+ path: Schema.String,
81+ isSymlink: Schema.Boolean,
82+ }),
83+ ),
84+});
85+
7086 const DomainErrors = [InternalError] as const;
7187
72−export const AppsGroup = HttpApiGroup.make("apps")
88+export const AppsBaseGroup = HttpApiGroup.make("apps")
7389 .add(
7490 HttpApiEndpoint.get("listSources", "/apps/sources", {
7591 success: SourcesResponse,
@@ -104,3 +120,18 @@ export const AppsGroup = HttpApiGroup.make("apps") · 28 unmodified lines
104120 error: DomainErrors,
105121 }),
106122 );
123+
124+export const AppsGroup = AppsBaseGroup.add(
125+ // Local-directory browsing names directories on the host running Executor.
126+ // It is only safe for the local single-user host, and the plugin sourceKinds
127+ // gate keeps shared selfhost and cloud deployments from registering it.
128+ HttpApiEndpoint.get("listDirs", "/apps/fs/dirs", {
129+ query: ListDirsQuery,
130+ success: ListDirsResponse,
131+ error: DomainErrors,
132+ }),
133+);
134+
135+export const appsGroupForSourceKinds = (
136+ sourceKinds: readonly ("git" | "local-directory")[] | undefined,
137+) => ((sourceKinds?.includes("local-directory") ?? true) ? AppsGroup : AppsBaseGroup);
packages/plugins/apps/src/react/AddCustomToolsSource.tsx+150−14
@@ -1,4 +1,4 @@
1−import { useMemo, useState } from "react";
1+import { useEffect, useMemo, useState } from "react";
22 import { Effect, Exit } from "effect";
33
44 import { Button } from "@executor-js/react/components/button";
@@ -9,19 +9,31 @@ import { · 4 unmodified lines
99 } from "@executor-js/react/components/card-stack";
1010 import { FloatActions } from "@executor-js/react/components/float-actions";
1111 import { Input } from "@executor-js/react/components/input";
12+import { Checkbox } from "@executor-js/react/components/checkbox";
13+import {
14+ Dialog,
15+ DialogContent,
16+ DialogDescription,
17+ DialogFooter,
18+ DialogHeader,
19+ DialogTitle,
20+} from "@executor-js/react/components/dialog";
1221 import { FormErrorAlert, useSlugAlreadyExists } from "@executor-js/react/lib/integration-add";
1322
1423 import {
1524 createCustomToolSourceEffect,
1625 formatSyncErrors,
26+ listCustomToolDirectoriesEffect,
1727 parseGitSourceUrl,
1828 slugifyCustomToolsAppName,
1929 suggestCustomToolsAppName,
2030 syncCustomToolSourceEffect,
2131 validateCustomToolsAppSlug,
2232 validateGitSourceUrl,
33+ type CustomToolsDirectoryListing,
2334 type AppSourceKind,
2435 } from "./custom-tools-client";
36+import { directoryBrowserRows } from "./source-panel-model";
2537
2638 export default function AddCustomToolsSource(props: {
2739 readonly onComplete: (slug?: string) => void;
@@ -51,6 +63,7 @@ export default function AddCustomToolsSource(props: { · 23 unmodified lines
5163 const [nameError, setNameError] = useState<string | null>(null);
5264 const [syncing, setSyncing] = useState(false);
5365 const [syncError, setSyncError] = useState<string | null>(null);
66+ const [browseOpen, setBrowseOpen] = useState(false);
5467
5568 const sourceValue = kind === "git" ? url : path;
5669 const effectiveName = useMemo(
@@ -130,7 +143,7 @@ export default function AddCustomToolsSource(props: { · 73 unmodified lines
130143 {allowLocalDirectory && (
131144 <CardStackEntryField
132145 label="Source type"
133− description="- Git is portable. Local directories are self-host only."
146+ description="- Git is portable. Local directories are local-only."
134147 >
135148 <div className="flex gap-2">
136149 <Button
@@ -201,20 +214,30 @@ export default function AddCustomToolsSource(props: { · 64 unmodified lines
201214 ) : (
202215 <CardStackEntryField
203216 label="Directory path"
204− description="- Absolute path on the self-host server."
217+ description="- Absolute path on this machine."
205218 >
206219 <div className="space-y-1.5">
207− <Input
208− value={path}
209− onChange={(event) => {
210− setPath((event.target as HTMLInputElement).value);
211− setFieldError(null);
212− setSyncError(null);
213− }}
214− placeholder="/srv/executor-tools"
215− className="font-mono text-sm"
216− aria-invalid={fieldError ? true : undefined}
217− />
220+ <div className="flex gap-2">
221+ <Input
222+ value={path}
223+ onChange={(event) => {
224+ setPath((event.target as HTMLInputElement).value);
225+ setFieldError(null);
226+ setSyncError(null);
227+ }}
228+ placeholder="/Users/me/tools"
229+ className="font-mono text-sm"
230+ aria-invalid={fieldError ? true : undefined}
231+ />
232+ <Button
233+ type="button"
234+ variant="outline"
235+ onClick={() => setBrowseOpen(true)}
236+ disabled={syncing}
237+ >
238+ Browse
239+ </Button>
240+ </div>
218241 {fieldError && <p className="text-xs text-destructive">{fieldError}</p>}
219242 </div>
220243 </CardStackEntryField>
@@ -258,6 +281,119 @@ export default function AddCustomToolsSource(props: { · 37 unmodified lines
258281 Sync source
259282 </Button>
260283 </FloatActions>
284+ {browseOpen && (
285+ <DirectoryBrowserModal
286+ initialPath={path.trim() || undefined}
287+ onClose={() => setBrowseOpen(false)}
288+ onSelect={(selectedPath) => {
289+ setPath(selectedPath);
290+ setFieldError(null);
291+ setSyncError(null);
292+ setBrowseOpen(false);
293+ }}
294+ />
295+ )}
261296 </div>
262297 );
263298 }
299+
300+function DirectoryBrowserModal(props: {
301+ readonly initialPath?: string;
302+ readonly onSelect: (path: string) => void;
303+ readonly onClose: () => void;
304+}) {
305+ const [currentPath, setCurrentPath] = useState(props.initialPath);
306+ const [includeHidden, setIncludeHidden] = useState(false);
307+ const [listing, setListing] = useState<CustomToolsDirectoryListing | null>(null);
308+ const [loading, setLoading] = useState(true);
309+ const [error, setError] = useState<string | null>(null);
310+
311+ useEffect(() => {
312+ let active = true;
313+ setLoading(true);
314+ setError(null);
315+ void Effect.runPromiseExit(
316+ listCustomToolDirectoriesEffect({ path: currentPath, includeHidden }),
317+ ).then((exit) => {
318+ if (!active) return;
319+ setLoading(false);
320+ if (Exit.isFailure(exit)) {
321+ setError("Unable to read this directory.");
322+ return;
323+ }
324+ setListing(exit.value);
325+ setCurrentPath(exit.value.path);
326+ });
327+ return () => {
328+ active = false;
329+ };
330+ }, [currentPath, includeHidden]);
331+
332+ const rows = listing ? directoryBrowserRows(listing) : [];
333+ const selectedPath = listing?.path ?? currentPath ?? "";
334+
335+ return (
336+ <Dialog open onOpenChange={(open) => (open ? undefined : props.onClose())}>
337+ <DialogContent className="sm:max-w-[620px]">
338+ <DialogHeader className="border-b border-border/60 pb-4">
339+ <DialogTitle className="text-base">Choose directory</DialogTitle>
340+ <DialogDescription className="font-mono text-xs break-all">
341+ {selectedPath || "Home"}
342+ </DialogDescription>
343+ </DialogHeader>
344+ <div className="space-y-3">
345+ <label className="flex items-center gap-2 text-sm text-muted-foreground">
346+ <Checkbox
347+ checked={includeHidden}
348+ onCheckedChange={(checked) => setIncludeHidden(checked === true)}
349+ />
350+ Show hidden directories
351+ </label>
352+ <div className="max-h-[340px] overflow-auto rounded-md border border-border">
353+ {loading ? (
354+ <div className="px-3 py-8 text-center text-sm text-muted-foreground">Loading...</div>
355+ ) : error ? (
356+ <div className="px-3 py-8 text-center text-sm text-destructive">{error}</div>
357+ ) : rows.length === 0 ? (
358+ <div className="px-3 py-8 text-center text-sm text-muted-foreground">
359+ No subdirectories
360+ </div>
361+ ) : (
362+ <div role="listbox" aria-label="Directories" className="divide-y divide-border">
363+ {rows.map((row) => (
364+ <button
365+ key={`${row.kind}:${row.path}`}
366+ type="button"
367+ className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
368+ onClick={() => setCurrentPath(row.path)}
369+ >
370+ <span className="min-w-0 truncate font-mono">
371+ {row.kind === "parent" ? ".." : row.name}
372+ </span>
373+ {row.kind === "dir" && row.isSymlink && (
374+ <span className="shrink-0 font-mono text-[11px] text-muted-foreground">
375+ symlink
376+ </span>
377+ )}
378+ </button>
379+ ))}
380+ </div>
381+ )}
382+ </div>
383+ </div>
384+ <DialogFooter className="border-t border-border/60 pt-4">
385+ <Button type="button" variant="ghost" onClick={props.onClose}>
386+ Cancel
387+ </Button>
388+ <Button
389+ type="button"
390+ onClick={() => props.onSelect(selectedPath)}
391+ disabled={!selectedPath || loading || Boolean(error)}
392+ >
393+ Select
394+ </Button>
395+ </DialogFooter>
396+ </DialogContent>
397+ </Dialog>
398+ );
399+}
packages/plugins/apps/src/react/custom-tools-client.ts+34−0
@@ -92,6 +92,18 @@ export type SyncSourceResult = · 91 unmodified lines
9292
9393 export type CustomToolsFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
9494
95+export interface CustomToolsDirectoryEntry {
96+ readonly name: string;
97+ readonly path: string;
98+ readonly isSymlink: boolean;
99+}
100+
101+export interface CustomToolsDirectoryListing {
102+ readonly path: string;
103+ readonly parent: string | null;
104+ readonly dirs: readonly CustomToolsDirectoryEntry[];
105+}
106+
95107 export class CustomToolsClientError extends Data.TaggedError("CustomToolsClientError")<{
96108 readonly message: string;
97109 }> {}
@@ -242,6 +254,28 @@ export const createCustomToolSourceEffect = ( · 144 unmodified lines
242254 ),
243255 );
244256
257+export const listCustomToolDirectoriesEffect = (
258+ input: { readonly path?: string; readonly includeHidden?: boolean } = {},
259+ fetchImpl: CustomToolsFetch = fetch,
260+): Effect.Effect<CustomToolsDirectoryListing, CustomToolsClientError> =>
261+ Effect.tryPromise({
262+ try: () => {
263+ const query = new URLSearchParams();
264+ if (input.path) query.set("path", input.path);
265+ if (input.includeHidden) query.set("includeHidden", "true");
266+ const suffix = query.size > 0 ? `?${query.toString()}` : "";
267+ return fetchImpl(`/api/apps/fs/dirs${suffix}`, { credentials: "same-origin" });
268+ },
269+ catch: () => new CustomToolsClientError({ message: "Failed to browse directories." }),
270+ }).pipe(
271+ Effect.flatMap((response) =>
272+ parseJsonResponseEffect<CustomToolsDirectoryListing>(
273+ response,
274+ "Failed to browse directories.",
275+ ),
276+ ),
277+ );
278+
245279 export const syncCustomToolSourceEffect = (
246280 slug: string,
247281 fetchImpl: CustomToolsFetch = fetch,
packages/plugins/apps/src/react/custom-tools-client.web.test.ts+33−0
@@ -6,6 +6,7 @@ import appsClientPlugin, { · 5 unmodified lines
66 CUSTOM_TOOLS_PLUGIN_KEY,
77 createCustomToolSourceEffect,
88 formatSyncErrors,
9+ listCustomToolDirectoriesEffect,
910 listCustomToolSources,
1011 parseGitSourceUrl,
1112 removeCustomToolSourceEffect,
@@ -17,6 +18,7 @@ import appsClientPlugin, { · 5 unmodified lines
1718 type CustomToolsFetch,
1819 } from "./plugin-client";
1920 import { sourcePanelModel, syncNoticeFromResult } from "./source-panel-model";
21+import { directoryBrowserRows } from "./source-panel-model";
2022
2123 const jsonResponse = (body: unknown, init?: ResponseInit): Response =>
2224 new Response(JSON.stringify(body), {
@@ -99,6 +101,13 @@ describe("custom tools console client", () => { · 76 unmodified lines
99101 if (url === "/api/apps/sources/demo-tools") {
100102 return jsonResponse({ removed: true });
101103 }
104+ if (url === "/api/apps/fs/dirs?path=%2FUsers%2Fada%2Ftools&includeHidden=true") {
105+ return jsonResponse({
106+ path: "/Users/ada/tools",
107+ parent: "/Users/ada",
108+ dirs: [{ name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false }],
109+ });
110+ }
102111 return jsonResponse({ error: "not found" }, { status: 404 });
103112 };
104113
@@ -117,17 +126,24 @@ describe("custom tools console client", () => { · 12 unmodified lines
117126 const listed = await listCustomToolSources(fetchImpl);
118127 const synced = await Effect.runPromise(syncCustomToolSourceEffect("demo-tools", fetchImpl));
119128 const removed = await Effect.runPromise(removeCustomToolSourceEffect("demo-tools", fetchImpl));
129+ const dirs = await Effect.runPromise(
130+ listCustomToolDirectoriesEffect({ path: "/Users/ada/tools", includeHidden: true }, fetchImpl),
131+ );
120132
121133 expect(created.source.slug).toBe("demo-tools");
122134 expect(createBody).toContain("ghp_secret");
123135 expect(JSON.stringify(listed)).not.toContain("ghp_secret");
124136 expect(syncStatusLabel(synced)).toBe("Already up to date.");
125137 expect(removed).toEqual({ removed: true });
138+ expect(dirs.dirs).toEqual([
139+ { name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false },
140+ ]);
126141 expect(calls.map((call) => `${call.init?.method ?? "GET"} ${call.url}`)).toEqual([
127142 "POST /api/apps/sources",
128143 "GET /api/apps/sources",
129144 "POST /api/apps/sources/demo-tools/sync",
130145 "DELETE /api/apps/sources/demo-tools",
146+ "GET /api/apps/fs/dirs?path=%2FUsers%2Fada%2Ftools&includeHidden=true",
131147 ]);
132148 });
133149
@@ -154,6 +170,23 @@ describe("custom tools console client", () => { · 20 unmodified lines
154170 expect(notice.sourceRef).toBe("def456abc123");
155171 });
156172
173+ it("models the directory browser rows", () => {
174+ expect(
175+ directoryBrowserRows({
176+ path: "/Users/ada/tools",
177+ parent: "/Users/ada",
178+ dirs: [
179+ { name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false },
180+ { name: "linked", path: "/Users/ada/tools/linked", isSymlink: true },
181+ ],
182+ }),
183+ ).toEqual([
184+ { kind: "parent", name: "..", path: "/Users/ada" },
185+ { kind: "dir", name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false },
186+ { kind: "dir", name: "linked", path: "/Users/ada/tools/linked", isSymlink: true },
187+ ]);
188+ });
189+
157190 it("formats failed sync diagnostics by stage", () => {
158191 const errors = formatSyncErrors({
159192 status: "failed",
packages/plugins/apps/src/react/source-panel-model.ts+28−1
@@ -1,4 +1,8 @@
1−import type { AppSourceRecord, SyncSourceResult } from "./custom-tools-client";
1+import type {
2+ AppSourceRecord,
3+ CustomToolsDirectoryListing,
4+ SyncSourceResult,
5+} from "./custom-tools-client";
26 import {
37 formatDiagnostics,
48 formatSyncErrors,
@@ -23,6 +27,15 @@ export interface SyncNoticeModel { · 18 unmodified lines
2327 readonly sourceRef?: string;
2428 }
2529
30+export type DirectoryBrowserRow =
31+ | { readonly kind: "parent"; readonly name: ".."; readonly path: string }
32+ | {
33+ readonly kind: "dir";
34+ readonly name: string;
35+ readonly path: string;
36+ readonly isSymlink: boolean;
37+ };
38+
2639 const shortRef = (ref: string | undefined): string => (ref ? ref.slice(0, 12) : "Not synced");
2740
2841 export const formatRelativeSyncTime = (timestamp: number, now = Date.now()): string => {
@@ -86,3 +99,17 @@ export const syncNoticeFromResult = ( · 57 unmodified lines
8699 ...(result.sourceRef ? { sourceRef: result.sourceRef.slice(0, 12) } : {}),
87100 };
88101 };
102+
103+export const directoryBrowserRows = (
104+ listing: CustomToolsDirectoryListing,
105+): readonly DirectoryBrowserRow[] => [
106+ ...(listing.parent
107+ ? [{ kind: "parent" as const, name: ".." as const, path: listing.parent }]
108+ : []),
109+ ...listing.dirs.map((dir) => ({
110+ kind: "dir" as const,
111+ name: dir.name,
112+ path: dir.path,
113+ isSymlink: dir.isSymlink,
114+ })),
115+];
packages/plugins/apps/src/source/app-source.test.ts+45−2
@@ -1,7 +1,7 @@
11 /* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: tests use fixture server cleanup and hard-fail setup errors */
22 import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
33 import { mkdir, readFile, symlink, writeFile } from "node:fs/promises";
4−import { tmpdir } from "node:os";
4+import { homedir, tmpdir } from "node:os";
55 import { join } from "node:path";
66 import { deflateSync } from "node:zlib";
77
@@ -14,7 +14,7 @@ import { parsePack, walkTree } from "../git-client/packfile"; · 6 unmodified lines
1414 import { authForHost, checkRefs, uploadPack } from "../git-client/transport";
1515 import { PUBLISH_LIMITS } from "../pipeline/publish";
1616 import { checkGitAppSourceRefs, fetchGitAppSource, parseGitSourceUrl } from "./git-source";
17−import { fetchLocalDirectoryAppSource } from "./local-directory-source";
17+import { fetchLocalDirectoryAppSource, listLocalDirectoryDirs } from "./local-directory-source";
1818
1919 const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect);
2020 const textDecoder = new TextDecoder();
@@ -467,6 +467,49 @@ describe("local-directory app sources", () => { · 446 unmodified lines
467467 expect(Exit.isFailure(parent)).toBe(true);
468468 });
469469
470+ it("lists home by default and immediate subdirectories only", async () => {
471+ const result = await run(listLocalDirectoryDirs({}));
472+ expect(result.path).toBe(homedir());
473+ expect(result.dirs.every((entry) => entry.path.startsWith(result.path))).toBe(true);
474+ });
475+
476+ it("lists subdirectories, filters dotdirs, and can include hidden dirs", async () => {
477+ const root = await mkdtemp();
478+ await mkdir(join(root, "alpha"));
479+ await mkdir(join(root, ".hidden"));
480+ await writeFile(join(root, "file.txt"), "not a directory");
481+
482+ const visible = await run(listLocalDirectoryDirs({ path: root }));
483+ const hidden = await run(listLocalDirectoryDirs({ path: root, includeHidden: true }));
484+
485+ expect(visible.dirs.map((entry) => entry.name)).toEqual(["alpha"]);
486+ expect(hidden.dirs.map((entry) => entry.name)).toEqual([".hidden", "alpha"]);
487+ });
488+
489+ it("rejects parent traversal in directory listings", async () => {
490+ const exit = await Effect.runPromiseExit(listLocalDirectoryDirs({ path: "/tmp/../bad" }));
491+ expect(Exit.isFailure(exit)).toBe(true);
492+ });
493+
494+ it("marks symlinked directories and does not descend through them", async () => {
495+ const root = await mkdtemp();
496+ const target = await mkdtemp();
497+ await mkdir(join(target, "nested"));
498+ await symlink(target, join(root, "linked"));
499+
500+ const listed = await run(listLocalDirectoryDirs({ path: root }));
501+ expect(listed.dirs).toContainEqual({
502+ name: "linked",
503+ path: join(root, "linked"),
504+ isSymlink: true,
505+ });
506+
507+ const followed = await Effect.runPromiseExit(
508+ listLocalDirectoryDirs({ path: join(root, "linked") }),
509+ );
510+ expect(Exit.isFailure(followed)).toBe(true);
511+ });
512+
470513 it("does not read symlinks that escape the source root", async () => {
471514 const root = await mkdtemp();
472515 const outside = await mkdtemp();
packages/plugins/apps/src/source/local-directory-source.ts+74−4
@@ -1,8 +1,9 @@
11 /* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor, executor/no-instanceof-tagged-error -- boundary: filesystem path validation and fs errors normalize to AppSourceError */
22 import { createHash } from "node:crypto";
33 import { constants } from "node:fs";
4−import { lstat, open, readdir, realpath } from "node:fs/promises";
5−import { isAbsolute, resolve, sep } from "node:path";
4+import { lstat, open, readdir, realpath, stat } from "node:fs/promises";
5+import { homedir } from "node:os";
6+import { dirname, isAbsolute, resolve, sep } from "node:path";
67
78 import { Effect, Schema } from "effect";
89
@@ -23,10 +24,27 @@ export interface LocalDirectoryAppSourceSnapshot extends AppSourceSnapshot { · 14 unmodified lines
2324 readonly skipped: readonly SourceSkippedFile[];
2425 }
2526
27+export interface LocalDirectoryDirsInput {
28+ readonly path?: string;
29+ readonly includeHidden?: boolean;
30+}
31+
32+export interface LocalDirectoryDirEntry {
33+ readonly name: string;
34+ readonly path: string;
35+ readonly isSymlink: boolean;
36+}
37+
38+export interface LocalDirectoryDirsResult {
39+ readonly path: string;
40+ readonly parent: string | null;
41+ readonly dirs: readonly LocalDirectoryDirEntry[];
42+}
43+
2644 const sha256 = (bytes: Uint8Array | string): string =>
2745 createHash("sha256").update(bytes).digest("hex");
2846
29−const validateRoot = (path: string): Effect.Effect<string, AppSourceError> =>
47+export const validateLocalDirectoryPath = (path: string): Effect.Effect<string, AppSourceError> =>
3048 Effect.try({
3149 try: () => {
3250 if (!isAbsolute(path)) {
@@ -179,7 +197,7 @@ export const fetchLocalDirectoryAppSource = ( · 146 unmodified lines
179197 input: LocalDirectoryAppSourceInput,
180198 ): Effect.Effect<LocalDirectoryAppSourceSnapshot, AppSourceError | PublishError> =>
181199 Effect.gen(function* () {
182− const root = yield* validateRoot(input.path);
200+ const root = yield* validateLocalDirectoryPath(input.path);
183201 const rootReal = yield* Effect.tryPromise({
184202 try: () => realpath(root),
185203 catch: (cause) =>
@@ -199,6 +217,58 @@ export const fetchLocalDirectoryAppSource = ( · 13 unmodified lines
199217 };
200218 });
201219
220+export const listLocalDirectoryDirs = (
221+ input: LocalDirectoryDirsInput,
222+): Effect.Effect<LocalDirectoryDirsResult, AppSourceError> =>
223+ Effect.gen(function* () {
224+ const root = yield* validateLocalDirectoryPath(input.path?.trim() || homedir());
225+ const rootStat = yield* Effect.tryPromise({
226+ try: () => lstat(root),
227+ catch: (cause) =>
228+ new AppSourceError({
229+ message: "failed to read local-directory source",
230+ path: root,
231+ cause,
232+ }),
233+ });
234+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
235+ return yield* new AppSourceError({
236+ message: "local-directory source path must be a directory",
237+ path: root,
238+ });
239+ }
240+ const entries = yield* Effect.tryPromise({
241+ try: () => readdir(root),
242+ catch: (cause) =>
243+ new AppSourceError({
244+ message: "failed to read local-directory source",
245+ path: root,
246+ cause,
247+ }),
248+ });
249+ const dirs: LocalDirectoryDirEntry[] = [];
250+ for (const name of entries) {
251+ if (!input.includeHidden && name.startsWith(".")) continue;
252+ const child = `${root}${sep}${name}`;
253+ const entryStat = yield* Effect.promise(() => lstat(child).catch(() => null));
254+ if (!entryStat) continue;
255+ if (entryStat.isDirectory()) {
256+ dirs.push({ name, path: child, isSymlink: false });
257+ continue;
258+ }
259+ if (!entryStat.isSymbolicLink()) continue;
260+ const targetStat = yield* Effect.promise(() => stat(child).catch(() => null));
261+ if (targetStat?.isDirectory()) dirs.push({ name, path: child, isSymlink: true });
262+ }
263+ dirs.sort((a, b) => a.name.localeCompare(b.name));
264+ const parent = dirname(root);
265+ return {
266+ path: root,
267+ parent: parent === root ? null : parent,
268+ dirs,
269+ };
270+ });
271+
202272 export const makeLocalDirectoryAppSource = (input: LocalDirectoryAppSourceInput) => ({
203273 fetch: () => fetchLocalDirectoryAppSource(input),
204274 });