Unified · Split
| @@ -68,7 +68,14 @@ export default defineTool({ · 67 unmodified lines | ||
| 68 | 68 | await pathInput.fill(fixtureRoot); |
| 69 | 69 | await page.getByRole("button", { name: "Browse" }).click(); |
| 70 | 70 | const dialog = page.getByRole("dialog", { name: "Choose directory" }); |
| 71 | + await dialog | |
| 72 | + .getByRole("button", { name: /picked-tools/ }) | |
| 73 | + .locator("span") | |
| 74 | + .filter({ hasText: "tools" }) | |
| 75 | + .waitFor({ timeout: 30_000 }); | |
| 71 | 76 | await dialog.getByRole("button", { name: "picked-tools" }).click(); |
| 77 | + await dialog.getByText("1 tool found:").waitFor({ timeout: 30_000 }); | |
| 78 | + await dialog.getByText("echo", { exact: true }).waitFor({ timeout: 30_000 }); | |
| 72 | 79 | await dialog.getByRole("button", { name: "Select" }).click(); |
| 73 | 80 | expect(await pathInput.inputValue()).toBe(fixtureDir); |
| 74 | 81 | }); |
| @@ -29,6 +29,15 @@ export interface DiscoverResult { · 28 unmodified lines | ||
| 29 | 29 | const TOOL_RE = /^tools\/([a-z0-9][a-z0-9-]*)\.(ts|tsx|js|jsx)$/; |
| 30 | 30 | const DEFERRED_RE = /^(workflows|ui|skills)\//; |
| 31 | 31 | |
| 32 | +/** Reserved top-level app source folders whose contents are deferred by discovery. */ | |
| 33 | +export const RESERVED_ARTIFACT_DIRS = ["workflows", "ui", "skills"] as const; | |
| 34 | + | |
| 35 | +/** Returns the discovered tool key for a path when it matches the canonical tool layout. */ | |
| 36 | +export const toolKeyFromPath = (path: string): string | null => path.match(TOOL_RE)?.[1] ?? null; | |
| 37 | + | |
| 38 | +/** Returns true when a path is under a reserved app source folder. */ | |
| 39 | +export const isReservedArtifactPath = (path: string): boolean => DEFERRED_RE.test(path); | |
| 40 | + | |
| 32 | 41 | export const validToolKey = (key: string): boolean => /^[a-z0-9][a-z0-9-]*$/.test(key); |
| 33 | 42 | |
| 34 | 43 | export const discover = (files: ReadonlyMap<string, string>): DiscoverResult | PublishError => { |
| @@ -39,9 +48,9 @@ export const discover = (files: ReadonlyMap<string, string>): DiscoverResult | P · 4 unmodified lines | ||
| 39 | 48 | |
| 40 | 49 | for (const [path] of files) { |
| 41 | 50 | if (path === "executor.json") continue; |
| 42 | − const tool = path.match(TOOL_RE); | |
| 51 | + const tool = toolKeyFromPath(path); | |
| 43 | 52 | if (tool) { |
| 44 | − const name = tool[1]!; | |
| 53 | + const name = tool; | |
| 45 | 54 | if (seen.has(name)) { |
| 46 | 55 | diagnostics.push({ path, message: `duplicate tool identity: ${name}` }); |
| 47 | 56 | } else { |
| @@ -54,7 +63,7 @@ export const discover = (files: ReadonlyMap<string, string>): DiscoverResult | P · 6 unmodified lines | ||
| 54 | 63 | diagnostics.push({ path, message: "file does not match the expected layout for tools/" }); |
| 55 | 64 | continue; |
| 56 | 65 | } |
| 57 | − if (DEFERRED_RE.test(path)) skipped.push({ path, reason: "not supported yet" }); | |
| 66 | + if (isReservedArtifactPath(path)) skipped.push({ path, reason: "not supported yet" }); | |
| 58 | 67 | } |
| 59 | 68 | |
| 60 | 69 | if (diagnostics.length > 0) { |
| @@ -79,8 +79,14 @@ const ListDirsResponse = Schema.Struct({ · 78 unmodified lines | ||
| 79 | 79 | name: Schema.String, |
| 80 | 80 | path: Schema.String, |
| 81 | 81 | isSymlink: Schema.Boolean, |
| 82 | + hasTools: Schema.Boolean, | |
| 82 | 83 | }), |
| 83 | 84 | ), |
| 85 | + source: Schema.Struct({ | |
| 86 | + toolFiles: Schema.Array(Schema.String), | |
| 87 | + skipped: Schema.Array(Schema.String), | |
| 88 | + hasPackageJson: Schema.Boolean, | |
| 89 | + }), | |
| 84 | 90 | }); |
| 85 | 91 | |
| 86 | 92 | const DomainErrors = [InternalError] as const; |
| @@ -34,7 +34,7 @@ import { · 33 unmodified lines | ||
| 34 | 34 | type CustomToolsDirectoryListing, |
| 35 | 35 | type AppSourceKind, |
| 36 | 36 | } from "./custom-tools-client"; |
| 37 | −import { directoryBrowserRows } from "./source-panel-model"; | |
| 37 | +import { directoryBrowserRows, directorySourceVerdict } from "./source-panel-model"; | |
| 38 | 38 | |
| 39 | 39 | export default function AddCustomToolsSource(props: { |
| 40 | 40 | readonly onComplete: (slug?: string) => void; |
| @@ -65,6 +65,7 @@ export default function AddCustomToolsSource(props: { · 24 unmodified lines | ||
| 65 | 65 | const [syncing, setSyncing] = useState(false); |
| 66 | 66 | const [syncError, setSyncError] = useState<string | null>(null); |
| 67 | 67 | const [browseOpen, setBrowseOpen] = useState(false); |
| 68 | + const [pathListing, setPathListing] = useState<CustomToolsDirectoryListing | null>(null); | |
| 68 | 69 | |
| 69 | 70 | const sourceValue = kind === "git" ? url : path; |
| 70 | 71 | const effectiveName = useMemo( |
| @@ -74,6 +75,26 @@ export default function AddCustomToolsSource(props: { · 3 unmodified lines | ||
| 74 | 75 | const slug = effectiveName; |
| 75 | 76 | const slugAlreadyExists = useSlugAlreadyExists(slug); |
| 76 | 77 | |
| 78 | + useEffect(() => { | |
| 79 | + if (kind !== "local-directory" || !path.trim().startsWith("/")) { | |
| 80 | + setPathListing(null); | |
| 81 | + return; | |
| 82 | + } | |
| 83 | + let active = true; | |
| 84 | + const timeout = window.setTimeout(() => { | |
| 85 | + void Effect.runPromiseExit(listCustomToolDirectoriesEffect({ path: path.trim() })).then( | |
| 86 | + (exit) => { | |
| 87 | + if (!active) return; | |
| 88 | + setPathListing(Exit.isSuccess(exit) ? exit.value : null); | |
| 89 | + }, | |
| 90 | + ); | |
| 91 | + }, 300); | |
| 92 | + return () => { | |
| 93 | + active = false; | |
| 94 | + window.clearTimeout(timeout); | |
| 95 | + }; | |
| 96 | + }, [kind, path]); | |
| 97 | + | |
| 77 | 98 | const submit = async () => { |
| 78 | 99 | const nextFieldError = |
| 79 | 100 | kind === "git" |
| @@ -226,6 +247,14 @@ export default function AddCustomToolsSource(props: { · 146 unmodified lines | ||
| 226 | 247 | setFieldError(null); |
| 227 | 248 | setSyncError(null); |
| 228 | 249 | }} |
| 250 | + onBlur={() => { | |
| 251 | + if (!path.trim().startsWith("/")) return; | |
| 252 | + void Effect.runPromiseExit( | |
| 253 | + listCustomToolDirectoriesEffect({ path: path.trim() }), | |
| 254 | + ).then((exit) => { | |
| 255 | + setPathListing(Exit.isSuccess(exit) ? exit.value : null); | |
| 256 | + }); | |
| 257 | + }} | |
| 229 | 258 | placeholder="/Users/me/tools" |
| 230 | 259 | className="font-mono text-sm" |
| 231 | 260 | aria-invalid={fieldError ? true : undefined} |
| @@ -239,6 +268,7 @@ export default function AddCustomToolsSource(props: { · 7 unmodified lines | ||
| 239 | 268 | Browse |
| 240 | 269 | </Button> |
| 241 | 270 | </div> |
| 271 | + {pathListing && <DirectorySourceStatus listing={pathListing} />} | |
| 242 | 272 | {fieldError && <p className="text-xs text-destructive">{fieldError}</p>} |
| 243 | 273 | </div> |
| 244 | 274 | </CardStackEntryField> |
| @@ -332,6 +362,8 @@ function DirectoryBrowserModal(props: { · 87 unmodified lines | ||
| 332 | 362 | |
| 333 | 363 | const rows = listing ? directoryBrowserRows(listing) : []; |
| 334 | 364 | const selectedPath = listing?.path ?? currentPath ?? ""; |
| 365 | + const canSelect = Boolean(selectedPath) && !loading && !error; | |
| 366 | + const hasToolFiles = (listing?.source.toolFiles.length ?? 0) > 0; | |
| 335 | 367 | |
| 336 | 368 | return ( |
| 337 | 369 | <Dialog open onOpenChange={(open) => (open ? undefined : props.onClose())}> |
| @@ -341,6 +373,7 @@ function DirectoryBrowserModal(props: { · 3 unmodified lines | ||
| 341 | 373 | <DialogDescription className="font-mono text-xs break-all"> |
| 342 | 374 | {selectedPath || "Home"} |
| 343 | 375 | </DialogDescription> |
| 376 | + {listing && <DirectorySourceStatus listing={listing} />} | |
| 344 | 377 | </DialogHeader> |
| 345 | 378 | <div className="space-y-3"> |
| 346 | 379 | <Label className="flex items-center gap-2 text-sm text-muted-foreground"> |
| @@ -372,11 +405,16 @@ function DirectoryBrowserModal(props: { · 25 unmodified lines | ||
| 372 | 405 | <span className="min-w-0 truncate font-mono"> |
| 373 | 406 | {row.kind === "parent" ? ".." : row.name} |
| 374 | 407 | </span> |
| 375 | − {row.kind === "dir" && row.isSymlink && ( | |
| 376 | − <span className="shrink-0 font-mono text-[11px] text-muted-foreground"> | |
| 377 | − symlink | |
| 378 | − </span> | |
| 379 | − )} | |
| 408 | + <span className="flex shrink-0 items-center gap-2"> | |
| 409 | + {row.kind === "dir" && row.hasTools && ( | |
| 410 | + <span className="rounded-sm border border-border bg-secondary px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground"> | |
| 411 | + tools | |
| 412 | + </span> | |
| 413 | + )} | |
| 414 | + {row.kind === "dir" && row.isSymlink && ( | |
| 415 | + <span className="font-mono text-[11px] text-muted-foreground">symlink</span> | |
| 416 | + )} | |
| 417 | + </span> | |
| 380 | 418 | </Button> |
| 381 | 419 | ))} |
| 382 | 420 | </div> |
| @@ -387,15 +425,35 @@ function DirectoryBrowserModal(props: { · 4 unmodified lines | ||
| 387 | 425 | <Button type="button" variant="ghost" onClick={props.onClose}> |
| 388 | 426 | Cancel |
| 389 | 427 | </Button> |
| 390 | − <Button | |
| 391 | − type="button" | |
| 392 | − onClick={() => props.onSelect(selectedPath)} | |
| 393 | − disabled={!selectedPath || loading || Boolean(error)} | |
| 394 | − > | |
| 395 | − Select | |
| 428 | + <Button type="button" onClick={() => props.onSelect(selectedPath)} disabled={!canSelect}> | |
| 429 | + {hasToolFiles ? "Select" : "Select anyway"} | |
| 396 | 430 | </Button> |
| 397 | 431 | </DialogFooter> |
| 398 | 432 | </DialogContent> |
| 399 | 433 | </Dialog> |
| 400 | 434 | ); |
| 401 | 435 | } |
| 436 | + | |
| 437 | +function DirectorySourceStatus(props: { readonly listing: CustomToolsDirectoryListing }) { | |
| 438 | + const verdict = directorySourceVerdict(props.listing); | |
| 439 | + if (verdict.type === "valid") { | |
| 440 | + return ( | |
| 441 | + <p className="mt-2 text-xs text-foreground"> | |
| 442 | + {verdict.message}:{" "} | |
| 443 | + {verdict.visibleTools.map((tool, index) => ( | |
| 444 | + <span key={tool}> | |
| 445 | + {index > 0 ? ", " : ""} | |
| 446 | + <span className="font-mono">{tool}</span> | |
| 447 | + </span> | |
| 448 | + ))} | |
| 449 | + {verdict.moreCount > 0 && ( | |
| 450 | + <> | |
| 451 | + {", "} | |
| 452 | + <span className="font-mono">+{verdict.moreCount} more</span> | |
| 453 | + </> | |
| 454 | + )} | |
| 455 | + </p> | |
| 456 | + ); | |
| 457 | + } | |
| 458 | + return <p className="mt-2 text-xs text-muted-foreground">{verdict.message}</p>; | |
| 459 | +} | |
| @@ -119,12 +119,20 @@ export interface CustomToolsDirectoryEntry { · 118 unmodified lines | ||
| 119 | 119 | readonly name: string; |
| 120 | 120 | readonly path: string; |
| 121 | 121 | readonly isSymlink: boolean; |
| 122 | + readonly hasTools: boolean; | |
| 123 | +} | |
| 124 | + | |
| 125 | +export interface CustomToolsDirectorySourceShape { | |
| 126 | + readonly toolFiles: readonly string[]; | |
| 127 | + readonly skipped: readonly string[]; | |
| 128 | + readonly hasPackageJson: boolean; | |
| 122 | 129 | } |
| 123 | 130 | |
| 124 | 131 | export interface CustomToolsDirectoryListing { |
| 125 | 132 | readonly path: string; |
| 126 | 133 | readonly parent: string | null; |
| 127 | 134 | readonly dirs: readonly CustomToolsDirectoryEntry[]; |
| 135 | + readonly source: CustomToolsDirectorySourceShape; | |
| 128 | 136 | } |
| 129 | 137 | |
| 130 | 138 | export class CustomToolsClientError extends Data.TaggedError("CustomToolsClientError")<{ |
| @@ -17,7 +17,11 @@ import appsClientPlugin, { · 16 unmodified lines | ||
| 17 | 17 | type AppSourceRecord, |
| 18 | 18 | type CustomToolsFetch, |
| 19 | 19 | } from "./plugin-client"; |
| 20 | −import { sourcePanelModel, syncNoticeFromResult } from "./source-panel-model"; | |
| 20 | +import { | |
| 21 | + directorySourceVerdict, | |
| 22 | + sourcePanelModel, | |
| 23 | + syncNoticeFromResult, | |
| 24 | +} from "./source-panel-model"; | |
| 21 | 25 | import { directoryBrowserRows } from "./source-panel-model"; |
| 22 | 26 | |
| 23 | 27 | const jsonResponse = (body: unknown, init?: ResponseInit): Response => |
| @@ -105,7 +109,10 @@ describe("custom tools console client", () => { · 81 unmodified lines | ||
| 105 | 109 | return jsonResponse({ |
| 106 | 110 | path: "/Users/ada/tools", |
| 107 | 111 | parent: "/Users/ada", |
| 108 | − dirs: [{ name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false }], | |
| 112 | + dirs: [ | |
| 113 | + { name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false, hasTools: true }, | |
| 114 | + ], | |
| 115 | + source: { toolFiles: ["greet.ts"], skipped: [], hasPackageJson: true }, | |
| 109 | 116 | }); |
| 110 | 117 | } |
| 111 | 118 | return jsonResponse({ error: "not found" }, { status: 404 }); |
| @@ -136,7 +143,7 @@ describe("custom tools console client", () => { · 24 unmodified lines | ||
| 136 | 143 | expect(syncStatusLabel(synced)).toBe("Already up to date."); |
| 137 | 144 | expect(removed).toEqual({ removed: true }); |
| 138 | 145 | expect(dirs.dirs).toEqual([ |
| 139 | − { name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false }, | |
| 146 | + { name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false, hasTools: true }, | |
| 140 | 147 | ]); |
| 141 | 148 | expect(calls.map((call) => `${call.init?.method ?? "GET"} ${call.url}`)).toEqual([ |
| 142 | 149 | "POST /api/apps/sources", |
| @@ -176,17 +183,72 @@ describe("custom tools console client", () => { · 33 unmodified lines | ||
| 176 | 183 | path: "/Users/ada/tools", |
| 177 | 184 | parent: "/Users/ada", |
| 178 | 185 | dirs: [ |
| 179 | − { name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false }, | |
| 180 | − { name: "linked", path: "/Users/ada/tools/linked", isSymlink: true }, | |
| 186 | + { name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false, hasTools: false }, | |
| 187 | + { name: "linked", path: "/Users/ada/tools/linked", isSymlink: true, hasTools: false }, | |
| 181 | 188 | ], |
| 189 | + source: { toolFiles: [], skipped: [], hasPackageJson: false }, | |
| 182 | 190 | }), |
| 183 | 191 | ).toEqual([ |
| 184 | 192 | { 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 }, | |
| 193 | + { | |
| 194 | + kind: "dir", | |
| 195 | + name: "alpha", | |
| 196 | + path: "/Users/ada/tools/alpha", | |
| 197 | + isSymlink: false, | |
| 198 | + hasTools: false, | |
| 199 | + }, | |
| 200 | + { | |
| 201 | + kind: "dir", | |
| 202 | + name: "linked", | |
| 203 | + path: "/Users/ada/tools/linked", | |
| 204 | + isSymlink: true, | |
| 205 | + hasTools: false, | |
| 206 | + }, | |
| 187 | 207 | ]); |
| 188 | 208 | }); |
| 189 | 209 | |
| 210 | + it("models directory source verdicts", () => { | |
| 211 | + expect( | |
| 212 | + directorySourceVerdict({ | |
| 213 | + path: "/repo", | |
| 214 | + parent: null, | |
| 215 | + dirs: [{ name: "tools", path: "/repo/tools", isSymlink: false, hasTools: false }], | |
| 216 | + source: { toolFiles: [], skipped: [], hasPackageJson: false }, | |
| 217 | + }), | |
| 218 | + ).toEqual({ | |
| 219 | + type: "empty-tools", | |
| 220 | + message: "tools/ has no tool files (tools/<name>.ts)", | |
| 221 | + }); | |
| 222 | + expect( | |
| 223 | + directorySourceVerdict({ | |
| 224 | + path: "/repo", | |
| 225 | + parent: null, | |
| 226 | + dirs: [], | |
| 227 | + source: { toolFiles: [], skipped: [], hasPackageJson: false }, | |
| 228 | + }), | |
| 229 | + ).toEqual({ | |
| 230 | + type: "missing-tools", | |
| 231 | + message: "No tools/ folder. Pick a folder containing tools/<name>.ts files.", | |
| 232 | + }); | |
| 233 | + expect( | |
| 234 | + directorySourceVerdict({ | |
| 235 | + path: "/repo", | |
| 236 | + parent: null, | |
| 237 | + dirs: [], | |
| 238 | + source: { | |
| 239 | + toolFiles: ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"], | |
| 240 | + skipped: [], | |
| 241 | + hasPackageJson: true, | |
| 242 | + }, | |
| 243 | + }), | |
| 244 | + ).toEqual({ | |
| 245 | + type: "valid", | |
| 246 | + message: "6 tools found", | |
| 247 | + visibleTools: ["a", "b", "c", "d", "e"], | |
| 248 | + moreCount: 1, | |
| 249 | + }); | |
| 250 | + }); | |
| 251 | + | |
| 190 | 252 | it("formats failed sync diagnostics by stage", () => { |
| 191 | 253 | const errors = formatSyncErrors({ |
| 192 | 254 | status: "failed", |
| @@ -34,8 +34,19 @@ export type DirectoryBrowserRow = · 33 unmodified lines | ||
| 34 | 34 | readonly name: string; |
| 35 | 35 | readonly path: string; |
| 36 | 36 | readonly isSymlink: boolean; |
| 37 | + readonly hasTools: boolean; | |
| 37 | 38 | }; |
| 38 | 39 | |
| 40 | +export type DirectorySourceVerdict = | |
| 41 | + | { | |
| 42 | + readonly type: "valid"; | |
| 43 | + readonly message: string; | |
| 44 | + readonly visibleTools: readonly string[]; | |
| 45 | + readonly moreCount: number; | |
| 46 | + } | |
| 47 | + | { readonly type: "empty-tools"; readonly message: string } | |
| 48 | + | { readonly type: "missing-tools"; readonly message: string }; | |
| 49 | + | |
| 39 | 50 | const shortRef = (ref: string | undefined): string => (ref ? ref.slice(0, 12) : "Not synced"); |
| 40 | 51 | |
| 41 | 52 | export const formatRelativeSyncTime = (timestamp: number, now = Date.now()): string => { |
| @@ -111,5 +122,35 @@ export const directoryBrowserRows = ( · 69 unmodified lines | ||
| 111 | 122 | name: dir.name, |
| 112 | 123 | path: dir.path, |
| 113 | 124 | isSymlink: dir.isSymlink, |
| 125 | + hasTools: dir.hasTools, | |
| 114 | 126 | })), |
| 115 | 127 | ]; |
| 128 | + | |
| 129 | +const toolNameFromFile = (file: string): string => file.replace(/\.[^.]+$/, ""); | |
| 130 | + | |
| 131 | +export const directorySourceVerdict = ( | |
| 132 | + listing: CustomToolsDirectoryListing, | |
| 133 | +): DirectorySourceVerdict => { | |
| 134 | + const source = listing.source; | |
| 135 | + if (source.toolFiles.length > 0) { | |
| 136 | + const toolNames = source.toolFiles.map(toolNameFromFile); | |
| 137 | + const visibleTools = toolNames.slice(0, 5); | |
| 138 | + const moreCount = Math.max(0, toolNames.length - visibleTools.length); | |
| 139 | + return { | |
| 140 | + type: "valid", | |
| 141 | + message: `${toolsCountLabel(source.toolFiles.length)} found`, | |
| 142 | + visibleTools, | |
| 143 | + moreCount, | |
| 144 | + }; | |
| 145 | + } | |
| 146 | + if (listing.dirs.some((dir) => dir.name === "tools" && !dir.isSymlink)) { | |
| 147 | + return { | |
| 148 | + type: "empty-tools", | |
| 149 | + message: "tools/ has no tool files (tools/<name>.ts)", | |
| 150 | + }; | |
| 151 | + } | |
| 152 | + return { | |
| 153 | + type: "missing-tools", | |
| 154 | + message: "No tools/ folder. Pick a folder containing tools/<name>.ts files.", | |
| 155 | + }; | |
| 156 | +}; | |
| @@ -486,6 +486,54 @@ describe("local-directory app sources", () => { · 485 unmodified lines | ||
| 486 | 486 | expect(hidden.dirs.map((entry) => entry.name)).toEqual([".hidden", "alpha"]); |
| 487 | 487 | }); |
| 488 | 488 | |
| 489 | + it("reports app source shape and subdirectories with tools folders", async () => { | |
| 490 | + const root = await mkdtemp(); | |
| 491 | + const valid = join(root, "valid-app"); | |
| 492 | + const emptyTools = join(root, "empty-tools"); | |
| 493 | + const noTools = join(root, "no-tools"); | |
| 494 | + const nested = join(root, "parent-with-tools"); | |
| 495 | + await mkdir(join(valid, "tools"), { recursive: true }); | |
| 496 | + await mkdir(join(valid, "workflows"), { recursive: true }); | |
| 497 | + await mkdir(join(valid, "ui"), { recursive: true }); | |
| 498 | + await mkdir(join(valid, "skills"), { recursive: true }); | |
| 499 | + await mkdir(join(emptyTools, "tools"), { recursive: true }); | |
| 500 | + await mkdir(noTools, { recursive: true }); | |
| 501 | + await mkdir(join(nested, "tools"), { recursive: true }); | |
| 502 | + await writeFile(join(valid, "package.json"), "{}"); | |
| 503 | + await writeFile(join(valid, "tools", "a.ts"), "export default {};"); | |
| 504 | + await writeFile(join(valid, "tools", "b.tsx"), "export default {};"); | |
| 505 | + await writeFile(join(valid, "tools", "bad_name.ts"), "export default {};"); | |
| 506 | + | |
| 507 | + const parentListing = await run(listLocalDirectoryDirs({ path: root })); | |
| 508 | + const validListing = await run(listLocalDirectoryDirs({ path: valid })); | |
| 509 | + const emptyListing = await run(listLocalDirectoryDirs({ path: emptyTools })); | |
| 510 | + const noToolsListing = await run(listLocalDirectoryDirs({ path: noTools })); | |
| 511 | + | |
| 512 | + expect(parentListing.dirs).toEqual( | |
| 513 | + expect.arrayContaining([ | |
| 514 | + expect.objectContaining({ name: "valid-app", hasTools: true }), | |
| 515 | + expect.objectContaining({ name: "empty-tools", hasTools: true }), | |
| 516 | + expect.objectContaining({ name: "no-tools", hasTools: false }), | |
| 517 | + expect.objectContaining({ name: "parent-with-tools", hasTools: true }), | |
| 518 | + ]), | |
| 519 | + ); | |
| 520 | + expect(validListing.source).toEqual({ | |
| 521 | + toolFiles: ["a.ts", "b.tsx"], | |
| 522 | + skipped: ["workflows", "ui", "skills"], | |
| 523 | + hasPackageJson: true, | |
| 524 | + }); | |
| 525 | + expect(emptyListing.source).toEqual({ | |
| 526 | + toolFiles: [], | |
| 527 | + skipped: [], | |
| 528 | + hasPackageJson: false, | |
| 529 | + }); | |
| 530 | + expect(noToolsListing.source).toEqual({ | |
| 531 | + toolFiles: [], | |
| 532 | + skipped: [], | |
| 533 | + hasPackageJson: false, | |
| 534 | + }); | |
| 535 | + }); | |
| 536 | + | |
| 489 | 537 | it("rejects parent traversal in directory listings", async () => { |
| 490 | 538 | const exit = await Effect.runPromiseExit(listLocalDirectoryDirs({ path: "/tmp/../bad" })); |
| 491 | 539 | expect(Exit.isFailure(exit)).toBe(true); |
| @@ -502,6 +550,7 @@ describe("local-directory app sources", () => { · 10 unmodified lines | ||
| 502 | 550 | name: "linked", |
| 503 | 551 | path: join(root, "linked"), |
| 504 | 552 | isSymlink: true, |
| 553 | + hasTools: false, | |
| 505 | 554 | }); |
| 506 | 555 | |
| 507 | 556 | const followed = await Effect.runPromiseExit( |
| @@ -7,6 +7,7 @@ import { dirname, isAbsolute, resolve, sep } from "node:path"; · 6 unmodified lines | ||
| 7 | 7 | |
| 8 | 8 | import { Effect, Schema } from "effect"; |
| 9 | 9 | |
| 10 | +import { RESERVED_ARTIFACT_DIRS, toolKeyFromPath } from "../pipeline/discover"; | |
| 10 | 11 | import { PublishError, enforcePublishLimits, type PublishFile } from "../pipeline/publish"; |
| 11 | 12 | import { AppSourceError, type AppSourceSnapshot } from "./app-source"; |
| 12 | 13 | import { |
| @@ -33,12 +34,20 @@ export interface LocalDirectoryDirEntry { · 20 unmodified lines | ||
| 33 | 34 | readonly name: string; |
| 34 | 35 | readonly path: string; |
| 35 | 36 | readonly isSymlink: boolean; |
| 37 | + readonly hasTools: boolean; | |
| 38 | +} | |
| 39 | + | |
| 40 | +export interface LocalDirectorySourceShape { | |
| 41 | + readonly toolFiles: readonly string[]; | |
| 42 | + readonly skipped: readonly string[]; | |
| 43 | + readonly hasPackageJson: boolean; | |
| 36 | 44 | } |
| 37 | 45 | |
| 38 | 46 | export interface LocalDirectoryDirsResult { |
| 39 | 47 | readonly path: string; |
| 40 | 48 | readonly parent: string | null; |
| 41 | 49 | readonly dirs: readonly LocalDirectoryDirEntry[]; |
| 50 | + readonly source: LocalDirectorySourceShape; | |
| 42 | 51 | } |
| 43 | 52 | |
| 44 | 53 | const sha256 = (bytes: Uint8Array | string): string => |
| @@ -193,6 +202,60 @@ const descriptionFor = ( · 148 unmodified lines | ||
| 193 | 202 | return typeof description === "string" ? description : undefined; |
| 194 | 203 | }); |
| 195 | 204 | |
| 205 | +const lstatOrNull = (path: string): Effect.Effect<Awaited<ReturnType<typeof lstat>> | null> => | |
| 206 | + Effect.tryPromise({ | |
| 207 | + try: () => lstat(path), | |
| 208 | + catch: (cause) => | |
| 209 | + new AppSourceError({ message: "failed to stat local-directory path", path, cause }), | |
| 210 | + }).pipe(Effect.catch(() => Effect.succeed(null))); | |
| 211 | + | |
| 212 | +const hasNonSymlinkDirectory = (path: string): Effect.Effect<boolean> => | |
| 213 | + Effect.gen(function* () { | |
| 214 | + const entry = yield* lstatOrNull(path); | |
| 215 | + return entry !== null && entry.isDirectory() && !entry.isSymbolicLink(); | |
| 216 | + }); | |
| 217 | + | |
| 218 | +const hasNonSymlinkFile = (path: string): Effect.Effect<boolean> => | |
| 219 | + Effect.gen(function* () { | |
| 220 | + const entry = yield* lstatOrNull(path); | |
| 221 | + return entry !== null && entry.isFile() && !entry.isSymbolicLink(); | |
| 222 | + }); | |
| 223 | + | |
| 224 | +const listLocalDirectorySourceShape = ( | |
| 225 | + root: string, | |
| 226 | +): Effect.Effect<LocalDirectorySourceShape, AppSourceError> => | |
| 227 | + Effect.gen(function* () { | |
| 228 | + const toolsPath = `${root}${sep}tools`; | |
| 229 | + const toolsStat = yield* lstatOrNull(toolsPath); | |
| 230 | + const toolFiles: string[] = []; | |
| 231 | + if (toolsStat?.isDirectory() && !toolsStat.isSymbolicLink()) { | |
| 232 | + const toolEntries = yield* Effect.tryPromise({ | |
| 233 | + try: () => readdir(toolsPath, { withFileTypes: true }), | |
| 234 | + catch: (cause) => | |
| 235 | + new AppSourceError({ | |
| 236 | + message: "failed to read local-directory tools folder", | |
| 237 | + path: toolsPath, | |
| 238 | + cause, | |
| 239 | + }), | |
| 240 | + }); | |
| 241 | + for (const entry of toolEntries) { | |
| 242 | + if (!entry.isFile()) continue; | |
| 243 | + if (toolKeyFromPath(`tools/${entry.name}`) === null) continue; | |
| 244 | + toolFiles.push(entry.name); | |
| 245 | + } | |
| 246 | + toolFiles.sort((a, b) => a.localeCompare(b)); | |
| 247 | + } | |
| 248 | + const skipped: string[] = []; | |
| 249 | + for (const name of RESERVED_ARTIFACT_DIRS) { | |
| 250 | + if (yield* hasNonSymlinkDirectory(`${root}${sep}${name}`)) skipped.push(name); | |
| 251 | + } | |
| 252 | + return { | |
| 253 | + toolFiles: toolFiles.slice(0, 20), | |
| 254 | + skipped, | |
| 255 | + hasPackageJson: yield* hasNonSymlinkFile(`${root}${sep}package.json`), | |
| 256 | + }; | |
| 257 | + }); | |
| 258 | + | |
| 196 | 259 | export const fetchLocalDirectoryAppSource = ( |
| 197 | 260 | input: LocalDirectoryAppSourceInput, |
| 198 | 261 | ): Effect.Effect<LocalDirectoryAppSourceSnapshot, AppSourceError | PublishError> => |
| @@ -247,6 +310,7 @@ export const listLocalDirectoryDirs = ( · 48 unmodified lines | ||
| 247 | 310 | }), |
| 248 | 311 | }); |
| 249 | 312 | const dirs: LocalDirectoryDirEntry[] = []; |
| 313 | + const source = yield* listLocalDirectorySourceShape(root); | |
| 250 | 314 | for (const name of entries) { |
| 251 | 315 | if (!input.includeHidden && name.startsWith(".")) continue; |
| 252 | 316 | const child = `${root}${sep}${name}`; |
| @@ -261,7 +325,12 @@ export const listLocalDirectoryDirs = ( · 8 unmodified lines | ||
| 261 | 325 | }).pipe(Effect.catch(() => Effect.succeed(null))); |
| 262 | 326 | if (!entryStat) continue; |
| 263 | 327 | if (entryStat.isDirectory()) { |
| 264 | − dirs.push({ name, path: child, isSymlink: false }); | |
| 328 | + dirs.push({ | |
| 329 | + name, | |
| 330 | + path: child, | |
| 331 | + isSymlink: false, | |
| 332 | + hasTools: yield* hasNonSymlinkDirectory(`${child}${sep}tools`), | |
| 333 | + }); | |
| 265 | 334 | continue; |
| 266 | 335 | } |
| 267 | 336 | if (!entryStat.isSymbolicLink()) continue; |
| @@ -274,7 +343,9 @@ export const listLocalDirectoryDirs = ( · 6 unmodified lines | ||
| 274 | 343 | cause, |
| 275 | 344 | }), |
| 276 | 345 | }).pipe(Effect.catch(() => Effect.succeed(null))); |
| 277 | − if (targetStat?.isDirectory()) dirs.push({ name, path: child, isSymlink: true }); | |
| 346 | + if (targetStat?.isDirectory()) { | |
| 347 | + dirs.push({ name, path: child, isSymlink: true, hasTools: false }); | |
| 348 | + } | |
| 278 | 349 | } |
| 279 | 350 | dirs.sort((a, b) => a.name.localeCompare(b.name)); |
| 280 | 351 | const parent = dirname(root); |
| @@ -282,6 +353,7 @@ export const listLocalDirectoryDirs = ( · 1 unmodified line | ||
| 282 | 353 | path: root, |
| 283 | 354 | parent: parent === root ? null : parent, |
| 284 | 355 | dirs, |
| 356 | + source, | |
| 285 | 357 | }; |
| 286 | 358 | }); |
| 287 | 359 | |