Unified · Split
| @@ -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 | ||
| 117 | test("apps source sync publishes and invokes a local-directory tool over HTTP", async () => { | 120 | test("apps source sync publishes and invokes a local-directory tool over HTTP", async () => { |
| @@ -1,8 +1,8 @@ | |||
| 1 | import { definePlugin } from "@executor-js/sdk/core"; | 1 | import { definePlugin } from "@executor-js/sdk/core"; |
| 2 | 2 | ||
| 3 | import { makeAppsPlugin, type AppsPluginOptions } from "./plugin/apps-plugin"; | 3 | import { makeAppsPlugin, type AppsPluginOptions } from "./plugin/apps-plugin"; |
| 4 | import { AppsExtensionService, AppsHandlers } from "./plugin/handlers"; | 4 | import { AppsExtensionService, appsHandlersForSourceKinds } from "./plugin/handlers"; |
| 5 | import { AppsGroup } from "./plugin/routes"; | 5 | import { appsGroupForSourceKinds } from "./plugin/routes"; |
| 6 | 6 | ||
| 7 | export { AppsGroup } from "./plugin/routes"; | 7 | export { AppsGroup } from "./plugin/routes"; |
| 8 | export { AppsHandlers, AppsExtensionService } from "./plugin/handlers"; | 8 | export { AppsHandlers, AppsExtensionService } from "./plugin/handlers"; |
| @@ -12,7 +12,7 @@ export { makeAppsPlugin, appsPlugin } from "./plugin/apps-plugin"; · 3 unmodified lines | |||
| 12 | 12 | ||
| 13 | export const appsHttpPlugin = definePlugin((options?: AppsPluginOptions) => ({ | 13 | export 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 | })); |
| @@ -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()); |
| @@ -34,7 +34,10 @@ import { · 33 unmodified lines | |||
| 34 | type SyncDiagnostic, | 34 | type SyncDiagnostic, |
| 35 | } from "../source/app-source"; | 35 | } from "../source/app-source"; |
| 36 | import { checkGitAppSourceRefs, fetchGitAppSource, parseGitSourceUrl } from "../source/git-source"; | 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 | import { AppSourceError, type AppSourceSnapshot } from "../source/app-source"; | 41 | import { AppSourceError, type AppSourceSnapshot } from "../source/app-source"; |
| 39 | import type { PublishError } from "../pipeline/publish"; | 42 | import 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); |
| @@ -5,12 +5,13 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; · 4 unmodified lines | |||
| 5 | import { addGroup, capture } from "@executor-js/api"; | 5 | import { addGroup, capture } from "@executor-js/api"; |
| 6 | import { InternalError } from "@executor-js/sdk/shared"; | 6 | import { InternalError } from "@executor-js/sdk/shared"; |
| 7 | import type { AppsExtension } from "./apps-plugin"; | 7 | import type { AppsExtension } from "./apps-plugin"; |
| 8 | import { AppsGroup } from "./routes"; | 8 | import { AppsBaseGroup, AppsGroup } from "./routes"; |
| 9 | 9 | ||
| 10 | export class AppsExtensionService extends Context.Service<AppsExtensionService, AppsExtension>()( | 10 | export class AppsExtensionService extends Context.Service<AppsExtensionService, AppsExtension>()( |
| 11 | "AppsExtensionService", | 11 | "AppsExtensionService", |
| 12 | ) {} | 12 | ) {} |
| 13 | 13 | ||
| 14 | const ExecutorApiWithAppsBase = addGroup(AppsBaseGroup); | ||
| 14 | const ExecutorApiWithApps = addGroup(AppsGroup); | 15 | const ExecutorApiWithApps = addGroup(AppsGroup); |
| 15 | 16 | ||
| 16 | const captureUnexpected = <A, E, R>( | 17 | const 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 | ||
| 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 | export const AppsHandlers = HttpApiBuilder.group(ExecutorApiWithApps, "apps", (handlers) => | 69 | export const AppsHandlers = HttpApiBuilder.group(ExecutorApiWithApps, "apps", (handlers) => |
| 22 | handlers | 70 | 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 | |||
| 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 | errors: Schema.optional(Schema.Array(SyncDiagnostic)), | 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 | const DomainErrors = [InternalError] as const; | 86 | const DomainErrors = [InternalError] as const; |
| 71 | 87 | ||
| 72 | export const AppsGroup = HttpApiGroup.make("apps") | 88 | export 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 | |||
| 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 | import { Effect, Exit } from "effect"; | 2 | import { Effect, Exit } from "effect"; |
| 3 | 3 | ||
| 4 | import { Button } from "@executor-js/react/components/button"; | 4 | import { 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"; |
| 10 | import { FloatActions } from "@executor-js/react/components/float-actions"; | 10 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 11 | import { Input } from "@executor-js/react/components/input"; | 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 | import { FormErrorAlert, useSlugAlreadyExists } from "@executor-js/react/lib/integration-add"; | 21 | import { FormErrorAlert, useSlugAlreadyExists } from "@executor-js/react/lib/integration-add"; |
| 13 | 22 | ||
| 14 | import { | 23 | import { |
| 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"; |
| 36 | import { directoryBrowserRows } from "./source-panel-model"; | ||
| 25 | 37 | ||
| 26 | export default function AddCustomToolsSource(props: { | 38 | export 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 | <CardStackEntryField | 144 | <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 | <Button | 149 | <Button |
| @@ -201,20 +214,30 @@ export default function AddCustomToolsSource(props: { · 64 unmodified lines | |||
| 201 | ) : ( | 214 | ) : ( |
| 202 | <CardStackEntryField | 215 | <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 | <Input | 220 | <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 source | 281 | 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 | |||
| 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 | export type CustomToolsFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>; | 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 | export class CustomToolsClientError extends Data.TaggedError("CustomToolsClientError")<{ | 107 | export 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 | ||
| 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 | export const syncCustomToolSourceEffect = ( | 279 | export const syncCustomToolSourceEffect = ( |
| 246 | slug: string, | 280 | slug: string, |
| 247 | fetchImpl: CustomToolsFetch = fetch, | 281 | fetchImpl: CustomToolsFetch = fetch, |
| @@ -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"; |
| 19 | import { sourcePanelModel, syncNoticeFromResult } from "./source-panel-model"; | 20 | import { sourcePanelModel, syncNoticeFromResult } from "./source-panel-model"; |
| 21 | import { directoryBrowserRows } from "./source-panel-model"; | ||
| 20 | 22 | ||
| 21 | const jsonResponse = (body: unknown, init?: ResponseInit): Response => | 23 | const 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", |
| @@ -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 | import { | 6 | import { |
| 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 | ||
| 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 | const shortRef = (ref: string | undefined): string => (ref ? ref.slice(0, 12) : "Not synced"); | 39 | const shortRef = (ref: string | undefined): string => (ref ? ref.slice(0, 12) : "Not synced"); |
| 27 | 40 | ||
| 28 | export const formatRelativeSyncTime = (timestamp: number, now = Date.now()): string => { | 41 | export 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 | |||
| 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 | /* 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 */ |
| 2 | import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; | 2 | import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; |
| 3 | import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; | 3 | import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; |
| 4 | import { tmpdir } from "node:os"; | 4 | import { homedir, tmpdir } from "node:os"; |
| 5 | import { join } from "node:path"; | 5 | import { join } from "node:path"; |
| 6 | import { deflateSync } from "node:zlib"; | 6 | import { deflateSync } from "node:zlib"; |
| 7 | 7 | ||
| @@ -14,7 +14,7 @@ import { parsePack, walkTree } from "../git-client/packfile"; · 6 unmodified lines | |||
| 14 | import { authForHost, checkRefs, uploadPack } from "../git-client/transport"; | 14 | import { authForHost, checkRefs, uploadPack } from "../git-client/transport"; |
| 15 | import { PUBLISH_LIMITS } from "../pipeline/publish"; | 15 | import { PUBLISH_LIMITS } from "../pipeline/publish"; |
| 16 | import { checkGitAppSourceRefs, fetchGitAppSource, parseGitSourceUrl } from "./git-source"; | 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 | const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect); | 19 | const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect); |
| 20 | const textDecoder = new TextDecoder(); | 20 | const 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(); |
| @@ -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 */ |
| 2 | import { createHash } from "node:crypto"; | 2 | import { createHash } from "node:crypto"; |
| 3 | import { constants } from "node:fs"; | 3 | import { constants } from "node:fs"; |
| 4 | import { lstat, open, readdir, realpath } from "node:fs/promises"; | 4 | import { lstat, open, readdir, realpath, stat } from "node:fs/promises"; |
| 5 | import { isAbsolute, resolve, sep } from "node:path"; | 5 | import { homedir } from "node:os"; |
| 6 | import { dirname, isAbsolute, resolve, sep } from "node:path"; | ||
| 6 | 7 | ||
| 7 | import { Effect, Schema } from "effect"; | 8 | import { 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 | ||
| 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 | const sha256 = (bytes: Uint8Array | string): string => | 44 | const sha256 = (bytes: Uint8Array | string): string => |
| 27 | createHash("sha256").update(bytes).digest("hex"); | 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 | 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 | ||
| 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 | export const makeLocalDirectoryAppSource = (input: LocalDirectoryAppSourceInput) => ({ | 272 | export const makeLocalDirectoryAppSource = (input: LocalDirectoryAppSourceInput) => ({ |
| 203 | fetch: () => fetchLocalDirectoryAppSource(input), | 273 | fetch: () => fetchLocalDirectoryAppSource(input), |
| 204 | }); | 274 | }); |