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