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
112 }),112 }),
113 );113 );
114 expect(created.status).toBe(500);114 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);
115});118});
116 119
117test("apps source sync publishes and invokes a local-directory tool over HTTP", async () => {120test("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 @@
1import { definePlugin } from "@executor-js/sdk/core";1import { definePlugin } from "@executor-js/sdk/core";
2 2
3import { makeAppsPlugin, type AppsPluginOptions } from "./plugin/apps-plugin";3import { makeAppsPlugin, type AppsPluginOptions } from "./plugin/apps-plugin";
4import { AppsExtensionService, AppsHandlers } from "./plugin/handlers";4import { AppsExtensionService, appsHandlersForSourceKinds } from "./plugin/handlers";
5import { AppsGroup } from "./plugin/routes";5import { appsGroupForSourceKinds } from "./plugin/routes";
6 6
7export { AppsGroup } from "./plugin/routes";7export { AppsGroup } from "./plugin/routes";
8export { AppsHandlers, AppsExtensionService } from "./plugin/handlers";8export { AppsHandlers, AppsExtensionService } from "./plugin/handlers";
@@ -12,7 +12,7 @@ export { makeAppsPlugin, appsPlugin } from "./plugin/apps-plugin"; · 3 unmodified lines
12 12
13export const appsHttpPlugin = definePlugin((options?: AppsPluginOptions) => ({13export const appsHttpPlugin = definePlugin((options?: AppsPluginOptions) => ({
14 ...makeAppsPlugin(options),14 ...makeAppsPlugin(options),
15 routes: () => AppsGroup,15 routes: () => appsGroupForSourceKinds(options?.sourceKinds),
16 handlers: () => AppsHandlers,16 handlers: () => appsHandlersForSourceKinds(options?.sourceKinds),
17 extensionService: AppsExtensionService,17 extensionService: AppsExtensionService,
18}));18}));
packages/plugins/apps/src/plugin/apps-plugin.test.ts+11−0
@@ -407,6 +407,17 @@ describe("apps source sync", () => { · 406 unmodified lines
407 }),407 }),
408 );408 );
409 409
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
410 it.effect("syncs git sources, no-ops unchanged refs, and republishes changed refs", () =>421 it.effect("syncs git sources, no-ops unchanged refs, and republishes changed refs", () =>
411 Effect.gen(function* () {422 Effect.gen(function* () {
412 const fixture = yield* Effect.promise(() => fixtureFetch());423 const fixture = yield* Effect.promise(() => fixtureFetch());
packages/plugins/apps/src/plugin/apps-plugin.ts+13−1
@@ -34,7 +34,10 @@ import { · 33 unmodified lines
34 type SyncDiagnostic,34 type SyncDiagnostic,
35} from "../source/app-source";35} from "../source/app-source";
36import { checkGitAppSourceRefs, fetchGitAppSource, parseGitSourceUrl } from "../source/git-source";36import { checkGitAppSourceRefs, fetchGitAppSource, parseGitSourceUrl } from "../source/git-source";
37import { fetchLocalDirectoryAppSource } from "../source/local-directory-source";37import {
38 fetchLocalDirectoryAppSource,
39 listLocalDirectoryDirs,
40} from "../source/local-directory-source";
38import { AppSourceError, type AppSourceSnapshot } from "../source/app-source";41import { AppSourceError, type AppSourceSnapshot } from "../source/app-source";
39import type { PublishError } from "../pipeline/publish";42import type { PublishError } from "../pipeline/publish";
40 43
@@ -285,6 +288,15 @@ const makeAppsExtension = ( · 244 unmodified lines
285 publish({ store: ctx.storage, executor: activeExecutor, bundler: activeBundler }, input),288 publish({ store: ctx.storage, executor: activeExecutor, bundler: activeBundler }, input),
286 listSources: () => ctx.storage.listSources(),289 listSources: () => ctx.storage.listSources(),
287 getSource: (slug: string) => ctx.storage.getSource(slug),290 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 }),
288 createSource: (input: CreateAppSourceInput) =>300 createSource: (input: CreateAppSourceInput) =>
289 Effect.gen(function* () {301 Effect.gen(function* () {
290 const config = sourceConfig(input);302 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
5import { addGroup, capture } from "@executor-js/api";5import { addGroup, capture } from "@executor-js/api";
6import { InternalError } from "@executor-js/sdk/shared";6import { InternalError } from "@executor-js/sdk/shared";
7import type { AppsExtension } from "./apps-plugin";7import type { AppsExtension } from "./apps-plugin";
8import { AppsGroup } from "./routes";8import { AppsBaseGroup, AppsGroup } from "./routes";
9 9
10export class AppsExtensionService extends Context.Service<AppsExtensionService, AppsExtension>()(10export class AppsExtensionService extends Context.Service<AppsExtensionService, AppsExtension>()(
11 "AppsExtensionService",11 "AppsExtensionService",
12) {}12) {}
13 13
14const ExecutorApiWithAppsBase = addGroup(AppsBaseGroup);
14const ExecutorApiWithApps = addGroup(AppsGroup);15const ExecutorApiWithApps = addGroup(AppsGroup);
15 16
16const captureUnexpected = <A, E, R>(17const captureUnexpected = <A, E, R>(
@@ -18,6 +19,53 @@ const captureUnexpected = <A, E, R>( · 1 unmodified line
18): Effect.Effect<A, InternalError, R> =>19): Effect.Effect<A, InternalError, R> =>
19 capture(effect.pipe(Effect.catch((cause: unknown) => Effect.die(cause))));20 capture(effect.pipe(Effect.catch((cause: unknown) => Effect.die(cause))));
20 21
22export 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
21export const AppsHandlers = HttpApiBuilder.group(ExecutorApiWithApps, "apps", (handlers) =>69export const AppsHandlers = HttpApiBuilder.group(ExecutorApiWithApps, "apps", (handlers) =>
22 handlers70 handlers
23 .handle("listSources", () =>71 .handle("listSources", () =>
@@ -62,5 +110,20 @@ export const AppsHandlers = HttpApiBuilder.group(ExecutorApiWithApps, "apps", (h · 38 unmodified lines
62 return yield* ext.syncSource(params.slug);110 return yield* ext.syncSource(params.slug);
63 }),111 }),
64 ),112 ),
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 ),
65 ),124 ),
66);125);
126
127export 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
67 errors: Schema.optional(Schema.Array(SyncDiagnostic)),67 errors: Schema.optional(Schema.Array(SyncDiagnostic)),
68});68});
69 69
70const ListDirsQuery = Schema.Struct({
71 path: Schema.optional(Schema.String),
72 includeHidden: Schema.optional(Schema.String),
73});
74const 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
70const DomainErrors = [InternalError] as const;86const DomainErrors = [InternalError] as const;
71 87
72export const AppsGroup = HttpApiGroup.make("apps")88export const AppsBaseGroup = HttpApiGroup.make("apps")
73 .add(89 .add(
74 HttpApiEndpoint.get("listSources", "/apps/sources", {90 HttpApiEndpoint.get("listSources", "/apps/sources", {
75 success: SourcesResponse,91 success: SourcesResponse,
@@ -104,3 +120,18 @@ export const AppsGroup = HttpApiGroup.make("apps") · 28 unmodified lines
104 error: DomainErrors,120 error: DomainErrors,
105 }),121 }),
106 );122 );
123
124export 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
135export 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 @@
1import { useMemo, useState } from "react";1import { useEffect, useMemo, useState } from "react";
2import { Effect, Exit } from "effect";2import { Effect, Exit } from "effect";
3 3
4import { Button } from "@executor-js/react/components/button";4import { Button } from "@executor-js/react/components/button";
@@ -9,19 +9,31 @@ import { · 4 unmodified lines
9} from "@executor-js/react/components/card-stack";9} from "@executor-js/react/components/card-stack";
10import { FloatActions } from "@executor-js/react/components/float-actions";10import { FloatActions } from "@executor-js/react/components/float-actions";
11import { Input } from "@executor-js/react/components/input";11import { Input } from "@executor-js/react/components/input";
12import { Checkbox } from "@executor-js/react/components/checkbox";
13import {
14 Dialog,
15 DialogContent,
16 DialogDescription,
17 DialogFooter,
18 DialogHeader,
19 DialogTitle,
20} from "@executor-js/react/components/dialog";
12import { FormErrorAlert, useSlugAlreadyExists } from "@executor-js/react/lib/integration-add";21import { FormErrorAlert, useSlugAlreadyExists } from "@executor-js/react/lib/integration-add";
13 22
14import {23import {
15 createCustomToolSourceEffect,24 createCustomToolSourceEffect,
16 formatSyncErrors,25 formatSyncErrors,
26 listCustomToolDirectoriesEffect,
17 parseGitSourceUrl,27 parseGitSourceUrl,
18 slugifyCustomToolsAppName,28 slugifyCustomToolsAppName,
19 suggestCustomToolsAppName,29 suggestCustomToolsAppName,
20 syncCustomToolSourceEffect,30 syncCustomToolSourceEffect,
21 validateCustomToolsAppSlug,31 validateCustomToolsAppSlug,
22 validateGitSourceUrl,32 validateGitSourceUrl,
33 type CustomToolsDirectoryListing,
23 type AppSourceKind,34 type AppSourceKind,
24} from "./custom-tools-client";35} from "./custom-tools-client";
36import { directoryBrowserRows } from "./source-panel-model";
25 37
26export default function AddCustomToolsSource(props: {38export default function AddCustomToolsSource(props: {
27 readonly onComplete: (slug?: string) => void;39 readonly onComplete: (slug?: string) => void;
@@ -51,6 +63,7 @@ export default function AddCustomToolsSource(props: { · 23 unmodified lines
51 const [nameError, setNameError] = useState<string | null>(null);63 const [nameError, setNameError] = useState<string | null>(null);
52 const [syncing, setSyncing] = useState(false);64 const [syncing, setSyncing] = useState(false);
53 const [syncError, setSyncError] = useState<string | null>(null);65 const [syncError, setSyncError] = useState<string | null>(null);
66 const [browseOpen, setBrowseOpen] = useState(false);
54 67
55 const sourceValue = kind === "git" ? url : path;68 const sourceValue = kind === "git" ? url : path;
56 const effectiveName = useMemo(69 const effectiveName = useMemo(
@@ -130,7 +143,7 @@ export default function AddCustomToolsSource(props: { · 73 unmodified lines
130 {allowLocalDirectory && (143 {allowLocalDirectory && (
131 <CardStackEntryField144 <CardStackEntryField
132 label="Source type"145 label="Source type"
133 description="- Git is portable. Local directories are self-host only."146 description="- Git is portable. Local directories are local-only."
134 >147 >
135 <div className="flex gap-2">148 <div className="flex gap-2">
136 <Button149 <Button
@@ -201,20 +214,30 @@ export default function AddCustomToolsSource(props: { · 64 unmodified lines
201 ) : (214 ) : (
202 <CardStackEntryField215 <CardStackEntryField
203 label="Directory path"216 label="Directory path"
204 description="- Absolute path on the self-host server."217 description="- Absolute path on this machine."
205 >218 >
206 <div className="space-y-1.5">219 <div className="space-y-1.5">
207 <Input220 <div className="flex gap-2">
208 value={path}221 <Input
209 onChange={(event) => {222 value={path}
210 setPath((event.target as HTMLInputElement).value);223 onChange={(event) => {
211 setFieldError(null);224 setPath((event.target as HTMLInputElement).value);
212 setSyncError(null);225 setFieldError(null);
213 }}226 setSyncError(null);
214 placeholder="/srv/executor-tools"227 }}
215 className="font-mono text-sm"228 placeholder="/Users/me/tools"
216 aria-invalid={fieldError ? true : undefined}229 className="font-mono text-sm"
217 />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>
218 {fieldError && <p className="text-xs text-destructive">{fieldError}</p>}241 {fieldError && <p className="text-xs text-destructive">{fieldError}</p>}
219 </div>242 </div>
220 </CardStackEntryField>243 </CardStackEntryField>
@@ -258,6 +281,119 @@ export default function AddCustomToolsSource(props: { · 37 unmodified lines
258 Sync source281 Sync source
259 </Button>282 </Button>
260 </FloatActions>283 </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 )}
261 </div>296 </div>
262 );297 );
263}298}
299
300function 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
92 92
93export type CustomToolsFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;93export type CustomToolsFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
94 94
95export interface CustomToolsDirectoryEntry {
96 readonly name: string;
97 readonly path: string;
98 readonly isSymlink: boolean;
99}
100
101export interface CustomToolsDirectoryListing {
102 readonly path: string;
103 readonly parent: string | null;
104 readonly dirs: readonly CustomToolsDirectoryEntry[];
105}
106
95export class CustomToolsClientError extends Data.TaggedError("CustomToolsClientError")<{107export class CustomToolsClientError extends Data.TaggedError("CustomToolsClientError")<{
96 readonly message: string;108 readonly message: string;
97}> {}109}> {}
@@ -242,6 +254,28 @@ export const createCustomToolSourceEffect = ( · 144 unmodified lines
242 ),254 ),
243 );255 );
244 256
257export 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
245export const syncCustomToolSourceEffect = (279export const syncCustomToolSourceEffect = (
246 slug: string,280 slug: string,
247 fetchImpl: CustomToolsFetch = fetch,281 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
6 CUSTOM_TOOLS_PLUGIN_KEY,6 CUSTOM_TOOLS_PLUGIN_KEY,
7 createCustomToolSourceEffect,7 createCustomToolSourceEffect,
8 formatSyncErrors,8 formatSyncErrors,
9 listCustomToolDirectoriesEffect,
9 listCustomToolSources,10 listCustomToolSources,
10 parseGitSourceUrl,11 parseGitSourceUrl,
11 removeCustomToolSourceEffect,12 removeCustomToolSourceEffect,
@@ -17,6 +18,7 @@ import appsClientPlugin, { · 5 unmodified lines
17 type CustomToolsFetch,18 type CustomToolsFetch,
18} from "./plugin-client";19} from "./plugin-client";
19import { sourcePanelModel, syncNoticeFromResult } from "./source-panel-model";20import { sourcePanelModel, syncNoticeFromResult } from "./source-panel-model";
21import { directoryBrowserRows } from "./source-panel-model";
20 22
21const jsonResponse = (body: unknown, init?: ResponseInit): Response =>23const jsonResponse = (body: unknown, init?: ResponseInit): Response =>
22 new Response(JSON.stringify(body), {24 new Response(JSON.stringify(body), {
@@ -99,6 +101,13 @@ describe("custom tools console client", () => { · 76 unmodified lines
99 if (url === "/api/apps/sources/demo-tools") {101 if (url === "/api/apps/sources/demo-tools") {
100 return jsonResponse({ removed: true });102 return jsonResponse({ removed: true });
101 }103 }
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 }
102 return jsonResponse({ error: "not found" }, { status: 404 });111 return jsonResponse({ error: "not found" }, { status: 404 });
103 };112 };
104 113
@@ -117,17 +126,24 @@ describe("custom tools console client", () => { · 12 unmodified lines
117 const listed = await listCustomToolSources(fetchImpl);126 const listed = await listCustomToolSources(fetchImpl);
118 const synced = await Effect.runPromise(syncCustomToolSourceEffect("demo-tools", fetchImpl));127 const synced = await Effect.runPromise(syncCustomToolSourceEffect("demo-tools", fetchImpl));
119 const removed = await Effect.runPromise(removeCustomToolSourceEffect("demo-tools", fetchImpl));128 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 );
120 132
121 expect(created.source.slug).toBe("demo-tools");133 expect(created.source.slug).toBe("demo-tools");
122 expect(createBody).toContain("ghp_secret");134 expect(createBody).toContain("ghp_secret");
123 expect(JSON.stringify(listed)).not.toContain("ghp_secret");135 expect(JSON.stringify(listed)).not.toContain("ghp_secret");
124 expect(syncStatusLabel(synced)).toBe("Already up to date.");136 expect(syncStatusLabel(synced)).toBe("Already up to date.");
125 expect(removed).toEqual({ removed: true });137 expect(removed).toEqual({ removed: true });
138 expect(dirs.dirs).toEqual([
139 { name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false },
140 ]);
126 expect(calls.map((call) => `${call.init?.method ?? "GET"} ${call.url}`)).toEqual([141 expect(calls.map((call) => `${call.init?.method ?? "GET"} ${call.url}`)).toEqual([
127 "POST /api/apps/sources",142 "POST /api/apps/sources",
128 "GET /api/apps/sources",143 "GET /api/apps/sources",
129 "POST /api/apps/sources/demo-tools/sync",144 "POST /api/apps/sources/demo-tools/sync",
130 "DELETE /api/apps/sources/demo-tools",145 "DELETE /api/apps/sources/demo-tools",
146 "GET /api/apps/fs/dirs?path=%2FUsers%2Fada%2Ftools&includeHidden=true",
131 ]);147 ]);
132 });148 });
133 149
@@ -154,6 +170,23 @@ describe("custom tools console client", () => { · 20 unmodified lines
154 expect(notice.sourceRef).toBe("def456abc123");170 expect(notice.sourceRef).toBe("def456abc123");
155 });171 });
156 172
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
157 it("formats failed sync diagnostics by stage", () => {190 it("formats failed sync diagnostics by stage", () => {
158 const errors = formatSyncErrors({191 const errors = formatSyncErrors({
159 status: "failed",192 status: "failed",
packages/plugins/apps/src/react/source-panel-model.ts+28−1
@@ -1,4 +1,8 @@
1import type { AppSourceRecord, SyncSourceResult } from "./custom-tools-client";1import type {
2 AppSourceRecord,
3 CustomToolsDirectoryListing,
4 SyncSourceResult,
5} from "./custom-tools-client";
2import {6import {
3 formatDiagnostics,7 formatDiagnostics,
4 formatSyncErrors,8 formatSyncErrors,
@@ -23,6 +27,15 @@ export interface SyncNoticeModel { · 18 unmodified lines
23 readonly sourceRef?: string;27 readonly sourceRef?: string;
24}28}
25 29
30export 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
26const shortRef = (ref: string | undefined): string => (ref ? ref.slice(0, 12) : "Not synced");39const shortRef = (ref: string | undefined): string => (ref ? ref.slice(0, 12) : "Not synced");
27 40
28export const formatRelativeSyncTime = (timestamp: number, now = Date.now()): string => {41export const formatRelativeSyncTime = (timestamp: number, now = Date.now()): string => {
@@ -86,3 +99,17 @@ export const syncNoticeFromResult = ( · 57 unmodified lines
86 ...(result.sourceRef ? { sourceRef: result.sourceRef.slice(0, 12) } : {}),99 ...(result.sourceRef ? { sourceRef: result.sourceRef.slice(0, 12) } : {}),
87 };100 };
88};101};
102
103export 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 @@
1/* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: tests use fixture server cleanup and hard-fail setup errors */1/* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: tests use fixture server cleanup and hard-fail setup errors */
2import { createServer, type IncomingMessage, type ServerResponse } from "node:http";2import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
3import { mkdir, readFile, symlink, writeFile } from "node:fs/promises";3import { mkdir, readFile, symlink, writeFile } from "node:fs/promises";
4import { tmpdir } from "node:os";4import { homedir, tmpdir } from "node:os";
5import { join } from "node:path";5import { join } from "node:path";
6import { deflateSync } from "node:zlib";6import { deflateSync } from "node:zlib";
7 7
@@ -14,7 +14,7 @@ import { parsePack, walkTree } from "../git-client/packfile"; · 6 unmodified lines
14import { authForHost, checkRefs, uploadPack } from "../git-client/transport";14import { authForHost, checkRefs, uploadPack } from "../git-client/transport";
15import { PUBLISH_LIMITS } from "../pipeline/publish";15import { PUBLISH_LIMITS } from "../pipeline/publish";
16import { checkGitAppSourceRefs, fetchGitAppSource, parseGitSourceUrl } from "./git-source";16import { checkGitAppSourceRefs, fetchGitAppSource, parseGitSourceUrl } from "./git-source";
17import { fetchLocalDirectoryAppSource } from "./local-directory-source";17import { fetchLocalDirectoryAppSource, listLocalDirectoryDirs } from "./local-directory-source";
18 18
19const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect);19const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect);
20const textDecoder = new TextDecoder();20const textDecoder = new TextDecoder();
@@ -467,6 +467,49 @@ describe("local-directory app sources", () => { · 446 unmodified lines
467 expect(Exit.isFailure(parent)).toBe(true);467 expect(Exit.isFailure(parent)).toBe(true);
468 });468 });
469 469
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
470 it("does not read symlinks that escape the source root", async () => {513 it("does not read symlinks that escape the source root", async () => {
471 const root = await mkdtemp();514 const root = await mkdtemp();
472 const outside = await mkdtemp();515 const outside = await mkdtemp();
packages/plugins/apps/src/source/local-directory-source.ts+74−4
@@ -1,8 +1,9 @@
1/* 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 */1/* 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 */
2import { createHash } from "node:crypto";2import { createHash } from "node:crypto";
3import { constants } from "node:fs";3import { constants } from "node:fs";
4import { lstat, open, readdir, realpath } from "node:fs/promises";4import { lstat, open, readdir, realpath, stat } from "node:fs/promises";
5import { isAbsolute, resolve, sep } from "node:path";5import { homedir } from "node:os";
6import { dirname, isAbsolute, resolve, sep } from "node:path";
6 7
7import { Effect, Schema } from "effect";8import { Effect, Schema } from "effect";
8 9
@@ -23,10 +24,27 @@ export interface LocalDirectoryAppSourceSnapshot extends AppSourceSnapshot { · 14 unmodified lines
23 readonly skipped: readonly SourceSkippedFile[];24 readonly skipped: readonly SourceSkippedFile[];
24}25}
25 26
27export interface LocalDirectoryDirsInput {
28 readonly path?: string;
29 readonly includeHidden?: boolean;
30}
31
32export interface LocalDirectoryDirEntry {
33 readonly name: string;
34 readonly path: string;
35 readonly isSymlink: boolean;
36}
37
38export interface LocalDirectoryDirsResult {
39 readonly path: string;
40 readonly parent: string | null;
41 readonly dirs: readonly LocalDirectoryDirEntry[];
42}
43
26const sha256 = (bytes: Uint8Array | string): string =>44const sha256 = (bytes: Uint8Array | string): string =>
27 createHash("sha256").update(bytes).digest("hex");45 createHash("sha256").update(bytes).digest("hex");
28 46
29const validateRoot = (path: string): Effect.Effect<string, AppSourceError> =>47export const validateLocalDirectoryPath = (path: string): Effect.Effect<string, AppSourceError> =>
30 Effect.try({48 Effect.try({
31 try: () => {49 try: () => {
32 if (!isAbsolute(path)) {50 if (!isAbsolute(path)) {
@@ -179,7 +197,7 @@ export const fetchLocalDirectoryAppSource = ( · 146 unmodified lines
179 input: LocalDirectoryAppSourceInput,197 input: LocalDirectoryAppSourceInput,
180): Effect.Effect<LocalDirectoryAppSourceSnapshot, AppSourceError | PublishError> =>198): Effect.Effect<LocalDirectoryAppSourceSnapshot, AppSourceError | PublishError> =>
181 Effect.gen(function* () {199 Effect.gen(function* () {
182 const root = yield* validateRoot(input.path);200 const root = yield* validateLocalDirectoryPath(input.path);
183 const rootReal = yield* Effect.tryPromise({201 const rootReal = yield* Effect.tryPromise({
184 try: () => realpath(root),202 try: () => realpath(root),
185 catch: (cause) =>203 catch: (cause) =>
@@ -199,6 +217,58 @@ export const fetchLocalDirectoryAppSource = ( · 13 unmodified lines
199 };217 };
200 });218 });
201 219
220export 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
202export const makeLocalDirectoryAppSource = (input: LocalDirectoryAppSourceInput) => ({272export const makeLocalDirectoryAppSource = (input: LocalDirectoryAppSourceInput) => ({
203 fetch: () => fetchLocalDirectoryAppSource(input),273 fetch: () => fetchLocalDirectoryAppSource(input),
204});274});