Unified · Split
| @@ -68,7 +68,14 @@ export default defineTool({ · 67 unmodified lines | |||
| 68 | await pathInput.fill(fixtureRoot); | 68 | await pathInput.fill(fixtureRoot); |
| 69 | await page.getByRole("button", { name: "Browse" }).click(); | 69 | await page.getByRole("button", { name: "Browse" }).click(); |
| 70 | const dialog = page.getByRole("dialog", { name: "Choose directory" }); | 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 | await dialog.getByRole("button", { name: "picked-tools" }).click(); | 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 | await dialog.getByRole("button", { name: "Select" }).click(); | 79 | await dialog.getByRole("button", { name: "Select" }).click(); |
| 73 | expect(await pathInput.inputValue()).toBe(fixtureDir); | 80 | expect(await pathInput.inputValue()).toBe(fixtureDir); |
| 74 | }); | 81 | }); |
| @@ -29,6 +29,15 @@ export interface DiscoverResult { · 28 unmodified lines | |||
| 29 | const TOOL_RE = /^tools\/([a-z0-9][a-z0-9-]*)\.(ts|tsx|js|jsx)$/; | 29 | const TOOL_RE = /^tools\/([a-z0-9][a-z0-9-]*)\.(ts|tsx|js|jsx)$/; |
| 30 | const DEFERRED_RE = /^(workflows|ui|skills)\//; | 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 | export const validToolKey = (key: string): boolean => /^[a-z0-9][a-z0-9-]*$/.test(key); | 41 | export const validToolKey = (key: string): boolean => /^[a-z0-9][a-z0-9-]*$/.test(key); |
| 33 | 42 | ||
| 34 | export const discover = (files: ReadonlyMap<string, string>): DiscoverResult | PublishError => { | 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 | for (const [path] of files) { | 49 | for (const [path] of files) { |
| 41 | if (path === "executor.json") continue; | 50 | if (path === "executor.json") continue; |
| 42 | const tool = path.match(TOOL_RE); | 51 | const tool = toolKeyFromPath(path); |
| 43 | if (tool) { | 52 | if (tool) { |
| 44 | const name = tool[1]!; | 53 | const name = tool; |
| 45 | if (seen.has(name)) { | 54 | if (seen.has(name)) { |
| 46 | diagnostics.push({ path, message: `duplicate tool identity: ${name}` }); | 55 | diagnostics.push({ path, message: `duplicate tool identity: ${name}` }); |
| 47 | } else { | 56 | } else { |
| @@ -54,7 +63,7 @@ export const discover = (files: ReadonlyMap<string, string>): DiscoverResult | P · 6 unmodified lines | |||
| 54 | diagnostics.push({ path, message: "file does not match the expected layout for tools/" }); | 63 | diagnostics.push({ path, message: "file does not match the expected layout for tools/" }); |
| 55 | continue; | 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 | if (diagnostics.length > 0) { | 69 | if (diagnostics.length > 0) { |
| @@ -79,8 +79,14 @@ const ListDirsResponse = Schema.Struct({ · 78 unmodified lines | |||
| 79 | name: Schema.String, | 79 | name: Schema.String, |
| 80 | path: Schema.String, | 80 | path: Schema.String, |
| 81 | isSymlink: Schema.Boolean, | 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 | const DomainErrors = [InternalError] as const; | 92 | const DomainErrors = [InternalError] as const; |
| @@ -34,7 +34,7 @@ import { · 33 unmodified lines | |||
| 34 | type CustomToolsDirectoryListing, | 34 | type CustomToolsDirectoryListing, |
| 35 | type AppSourceKind, | 35 | type AppSourceKind, |
| 36 | } from "./custom-tools-client"; | 36 | } from "./custom-tools-client"; |
| 37 | import { directoryBrowserRows } from "./source-panel-model"; | 37 | import { directoryBrowserRows, directorySourceVerdict } from "./source-panel-model"; |
| 38 | 38 | ||
| 39 | export default function AddCustomToolsSource(props: { | 39 | export default function AddCustomToolsSource(props: { |
| 40 | readonly onComplete: (slug?: string) => void; | 40 | readonly onComplete: (slug?: string) => void; |
| @@ -65,6 +65,7 @@ export default function AddCustomToolsSource(props: { · 24 unmodified lines | |||
| 65 | const [syncing, setSyncing] = useState(false); | 65 | const [syncing, setSyncing] = useState(false); |
| 66 | const [syncError, setSyncError] = useState<string | null>(null); | 66 | const [syncError, setSyncError] = useState<string | null>(null); |
| 67 | const [browseOpen, setBrowseOpen] = useState(false); | 67 | const [browseOpen, setBrowseOpen] = useState(false); |
| 68 | const [pathListing, setPathListing] = useState<CustomToolsDirectoryListing | null>(null); | ||
| 68 | 69 | ||
| 69 | const sourceValue = kind === "git" ? url : path; | 70 | const sourceValue = kind === "git" ? url : path; |
| 70 | const effectiveName = useMemo( | 71 | const effectiveName = useMemo( |
| @@ -74,6 +75,26 @@ export default function AddCustomToolsSource(props: { · 3 unmodified lines | |||
| 74 | const slug = effectiveName; | 75 | const slug = effectiveName; |
| 75 | const slugAlreadyExists = useSlugAlreadyExists(slug); | 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 | const submit = async () => { | 98 | const submit = async () => { |
| 78 | const nextFieldError = | 99 | const nextFieldError = |
| 79 | kind === "git" | 100 | kind === "git" |
| @@ -226,6 +247,14 @@ export default function AddCustomToolsSource(props: { · 146 unmodified lines | |||
| 226 | setFieldError(null); | 247 | setFieldError(null); |
| 227 | setSyncError(null); | 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 | placeholder="/Users/me/tools" | 258 | placeholder="/Users/me/tools" |
| 230 | className="font-mono text-sm" | 259 | className="font-mono text-sm" |
| 231 | aria-invalid={fieldError ? true : undefined} | 260 | aria-invalid={fieldError ? true : undefined} |
| @@ -239,6 +268,7 @@ export default function AddCustomToolsSource(props: { · 7 unmodified lines | |||
| 239 | Browse | 268 | Browse |
| 240 | </Button> | 269 | </Button> |
| 241 | </div> | 270 | </div> |
| 271 | {pathListing && <DirectorySourceStatus listing={pathListing} />} | ||
| 242 | {fieldError && <p className="text-xs text-destructive">{fieldError}</p>} | 272 | {fieldError && <p className="text-xs text-destructive">{fieldError}</p>} |
| 243 | </div> | 273 | </div> |
| 244 | </CardStackEntryField> | 274 | </CardStackEntryField> |
| @@ -332,6 +362,8 @@ function DirectoryBrowserModal(props: { · 87 unmodified lines | |||
| 332 | 362 | ||
| 333 | const rows = listing ? directoryBrowserRows(listing) : []; | 363 | const rows = listing ? directoryBrowserRows(listing) : []; |
| 334 | const selectedPath = listing?.path ?? currentPath ?? ""; | 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 | return ( | 368 | return ( |
| 337 | <Dialog open onOpenChange={(open) => (open ? undefined : props.onClose())}> | 369 | <Dialog open onOpenChange={(open) => (open ? undefined : props.onClose())}> |
| @@ -341,6 +373,7 @@ function DirectoryBrowserModal(props: { · 3 unmodified lines | |||
| 341 | <DialogDescription className="font-mono text-xs break-all"> | 373 | <DialogDescription className="font-mono text-xs break-all"> |
| 342 | {selectedPath || "Home"} | 374 | {selectedPath || "Home"} |
| 343 | </DialogDescription> | 375 | </DialogDescription> |
| 376 | {listing && <DirectorySourceStatus listing={listing} />} | ||
| 344 | </DialogHeader> | 377 | </DialogHeader> |
| 345 | <div className="space-y-3"> | 378 | <div className="space-y-3"> |
| 346 | <Label className="flex items-center gap-2 text-sm text-muted-foreground"> | 379 | <Label className="flex items-center gap-2 text-sm text-muted-foreground"> |
| @@ -372,11 +405,16 @@ function DirectoryBrowserModal(props: { · 25 unmodified lines | |||
| 372 | <span className="min-w-0 truncate font-mono"> | 405 | <span className="min-w-0 truncate font-mono"> |
| 373 | {row.kind === "parent" ? ".." : row.name} | 406 | {row.kind === "parent" ? ".." : row.name} |
| 374 | </span> | 407 | </span> |
| 375 | {row.kind === "dir" && row.isSymlink && ( | 408 | <span className="flex shrink-0 items-center gap-2"> |
| 376 | <span className="shrink-0 font-mono text-[11px] text-muted-foreground"> | 409 | {row.kind === "dir" && row.hasTools && ( |
| 377 | symlink | 410 | <span className="rounded-sm border border-border bg-secondary px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground"> |
| 378 | </span> | 411 | tools |
| 379 | )} | 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 | </Button> | 418 | </Button> |
| 381 | ))} | 419 | ))} |
| 382 | </div> | 420 | </div> |
| @@ -387,15 +425,35 @@ function DirectoryBrowserModal(props: { · 4 unmodified lines | |||
| 387 | <Button type="button" variant="ghost" onClick={props.onClose}> | 425 | <Button type="button" variant="ghost" onClick={props.onClose}> |
| 388 | Cancel | 426 | Cancel |
| 389 | </Button> | 427 | </Button> |
| 390 | <Button | 428 | <Button type="button" onClick={() => props.onSelect(selectedPath)} disabled={!canSelect}> |
| 391 | type="button" | 429 | {hasToolFiles ? "Select" : "Select anyway"} |
| 392 | onClick={() => props.onSelect(selectedPath)} | ||
| 393 | disabled={!selectedPath || loading || Boolean(error)} | ||
| 394 | > | ||
| 395 | Select | ||
| 396 | </Button> | 430 | </Button> |
| 397 | </DialogFooter> | 431 | </DialogFooter> |
| 398 | </DialogContent> | 432 | </DialogContent> |
| 399 | </Dialog> | 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 | readonly name: string; | 119 | readonly name: string; |
| 120 | readonly path: string; | 120 | readonly path: string; |
| 121 | readonly isSymlink: boolean; | 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 | export interface CustomToolsDirectoryListing { | 131 | export interface CustomToolsDirectoryListing { |
| 125 | readonly path: string; | 132 | readonly path: string; |
| 126 | readonly parent: string | null; | 133 | readonly parent: string | null; |
| 127 | readonly dirs: readonly CustomToolsDirectoryEntry[]; | 134 | readonly dirs: readonly CustomToolsDirectoryEntry[]; |
| 135 | readonly source: CustomToolsDirectorySourceShape; | ||
| 128 | } | 136 | } |
| 129 | 137 | ||
| 130 | export class CustomToolsClientError extends Data.TaggedError("CustomToolsClientError")<{ | 138 | export class CustomToolsClientError extends Data.TaggedError("CustomToolsClientError")<{ |
| @@ -17,7 +17,11 @@ import appsClientPlugin, { · 16 unmodified lines | |||
| 17 | type AppSourceRecord, | 17 | type AppSourceRecord, |
| 18 | type CustomToolsFetch, | 18 | type CustomToolsFetch, |
| 19 | } from "./plugin-client"; | 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 | import { directoryBrowserRows } from "./source-panel-model"; | 25 | import { directoryBrowserRows } from "./source-panel-model"; |
| 22 | 26 | ||
| 23 | const jsonResponse = (body: unknown, init?: ResponseInit): Response => | 27 | const jsonResponse = (body: unknown, init?: ResponseInit): Response => |
| @@ -105,7 +109,10 @@ describe("custom tools console client", () => { · 81 unmodified lines | |||
| 105 | return jsonResponse({ | 109 | return jsonResponse({ |
| 106 | path: "/Users/ada/tools", | 110 | path: "/Users/ada/tools", |
| 107 | parent: "/Users/ada", | 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 | return jsonResponse({ error: "not found" }, { status: 404 }); | 118 | return jsonResponse({ error: "not found" }, { status: 404 }); |
| @@ -136,7 +143,7 @@ describe("custom tools console client", () => { · 24 unmodified lines | |||
| 136 | expect(syncStatusLabel(synced)).toBe("Already up to date."); | 143 | expect(syncStatusLabel(synced)).toBe("Already up to date."); |
| 137 | expect(removed).toEqual({ removed: true }); | 144 | expect(removed).toEqual({ removed: true }); |
| 138 | expect(dirs.dirs).toEqual([ | 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 | expect(calls.map((call) => `${call.init?.method ?? "GET"} ${call.url}`)).toEqual([ | 148 | expect(calls.map((call) => `${call.init?.method ?? "GET"} ${call.url}`)).toEqual([ |
| 142 | "POST /api/apps/sources", | 149 | "POST /api/apps/sources", |
| @@ -176,17 +183,72 @@ describe("custom tools console client", () => { · 33 unmodified lines | |||
| 176 | path: "/Users/ada/tools", | 183 | path: "/Users/ada/tools", |
| 177 | parent: "/Users/ada", | 184 | parent: "/Users/ada", |
| 178 | dirs: [ | 185 | dirs: [ |
| 179 | { name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false }, | 186 | { name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false, hasTools: false }, |
| 180 | { name: "linked", path: "/Users/ada/tools/linked", isSymlink: true }, | 187 | { name: "linked", path: "/Users/ada/tools/linked", isSymlink: true, hasTools: false }, |
| 181 | ], | 188 | ], |
| 189 | source: { toolFiles: [], skipped: [], hasPackageJson: false }, | ||
| 182 | }), | 190 | }), |
| 183 | ).toEqual([ | 191 | ).toEqual([ |
| 184 | { kind: "parent", name: "..", path: "/Users/ada" }, | 192 | { kind: "parent", name: "..", path: "/Users/ada" }, |
| 185 | { kind: "dir", name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false }, | 193 | { |
| 186 | { kind: "dir", name: "linked", path: "/Users/ada/tools/linked", isSymlink: true }, | 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 | it("formats failed sync diagnostics by stage", () => { | 252 | it("formats failed sync diagnostics by stage", () => { |
| 191 | const errors = formatSyncErrors({ | 253 | const errors = formatSyncErrors({ |
| 192 | status: "failed", | 254 | status: "failed", |
| @@ -34,8 +34,19 @@ export type DirectoryBrowserRow = · 33 unmodified lines | |||
| 34 | readonly name: string; | 34 | readonly name: string; |
| 35 | readonly path: string; | 35 | readonly path: string; |
| 36 | readonly isSymlink: boolean; | 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 | const shortRef = (ref: string | undefined): string => (ref ? ref.slice(0, 12) : "Not synced"); | 50 | const shortRef = (ref: string | undefined): string => (ref ? ref.slice(0, 12) : "Not synced"); |
| 40 | 51 | ||
| 41 | export const formatRelativeSyncTime = (timestamp: number, now = Date.now()): string => { | 52 | export const formatRelativeSyncTime = (timestamp: number, now = Date.now()): string => { |
| @@ -111,5 +122,35 @@ export const directoryBrowserRows = ( · 69 unmodified lines | |||
| 111 | name: dir.name, | 122 | name: dir.name, |
| 112 | path: dir.path, | 123 | path: dir.path, |
| 113 | isSymlink: dir.isSymlink, | 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 | expect(hidden.dirs.map((entry) => entry.name)).toEqual([".hidden", "alpha"]); | 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 | it("rejects parent traversal in directory listings", async () => { | 537 | it("rejects parent traversal in directory listings", async () => { |
| 490 | const exit = await Effect.runPromiseExit(listLocalDirectoryDirs({ path: "/tmp/../bad" })); | 538 | const exit = await Effect.runPromiseExit(listLocalDirectoryDirs({ path: "/tmp/../bad" })); |
| 491 | expect(Exit.isFailure(exit)).toBe(true); | 539 | expect(Exit.isFailure(exit)).toBe(true); |
| @@ -502,6 +550,7 @@ describe("local-directory app sources", () => { · 10 unmodified lines | |||
| 502 | name: "linked", | 550 | name: "linked", |
| 503 | path: join(root, "linked"), | 551 | path: join(root, "linked"), |
| 504 | isSymlink: true, | 552 | isSymlink: true, |
| 553 | hasTools: false, | ||
| 505 | }); | 554 | }); |
| 506 | 555 | ||
| 507 | const followed = await Effect.runPromiseExit( | 556 | const followed = await Effect.runPromiseExit( |
| @@ -7,6 +7,7 @@ import { dirname, isAbsolute, resolve, sep } from "node:path"; · 6 unmodified lines | |||
| 7 | 7 | ||
| 8 | import { Effect, Schema } from "effect"; | 8 | import { Effect, Schema } from "effect"; |
| 9 | 9 | ||
| 10 | import { RESERVED_ARTIFACT_DIRS, toolKeyFromPath } from "../pipeline/discover"; | ||
| 10 | import { PublishError, enforcePublishLimits, type PublishFile } from "../pipeline/publish"; | 11 | import { PublishError, enforcePublishLimits, type PublishFile } from "../pipeline/publish"; |
| 11 | import { AppSourceError, type AppSourceSnapshot } from "./app-source"; | 12 | import { AppSourceError, type AppSourceSnapshot } from "./app-source"; |
| 12 | import { | 13 | import { |
| @@ -33,12 +34,20 @@ export interface LocalDirectoryDirEntry { · 20 unmodified lines | |||
| 33 | readonly name: string; | 34 | readonly name: string; |
| 34 | readonly path: string; | 35 | readonly path: string; |
| 35 | readonly isSymlink: boolean; | 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 | export interface LocalDirectoryDirsResult { | 46 | export interface LocalDirectoryDirsResult { |
| 39 | readonly path: string; | 47 | readonly path: string; |
| 40 | readonly parent: string | null; | 48 | readonly parent: string | null; |
| 41 | readonly dirs: readonly LocalDirectoryDirEntry[]; | 49 | readonly dirs: readonly LocalDirectoryDirEntry[]; |
| 50 | readonly source: LocalDirectorySourceShape; | ||
| 42 | } | 51 | } |
| 43 | 52 | ||
| 44 | const sha256 = (bytes: Uint8Array | string): string => | 53 | const sha256 = (bytes: Uint8Array | string): string => |
| @@ -193,6 +202,60 @@ const descriptionFor = ( · 148 unmodified lines | |||
| 193 | return typeof description === "string" ? description : undefined; | 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 | export const fetchLocalDirectoryAppSource = ( | 259 | export const fetchLocalDirectoryAppSource = ( |
| 197 | input: LocalDirectoryAppSourceInput, | 260 | input: LocalDirectoryAppSourceInput, |
| 198 | ): Effect.Effect<LocalDirectoryAppSourceSnapshot, AppSourceError | PublishError> => | 261 | ): Effect.Effect<LocalDirectoryAppSourceSnapshot, AppSourceError | PublishError> => |
| @@ -247,6 +310,7 @@ export const listLocalDirectoryDirs = ( · 48 unmodified lines | |||
| 247 | }), | 310 | }), |
| 248 | }); | 311 | }); |
| 249 | const dirs: LocalDirectoryDirEntry[] = []; | 312 | const dirs: LocalDirectoryDirEntry[] = []; |
| 313 | const source = yield* listLocalDirectorySourceShape(root); | ||
| 250 | for (const name of entries) { | 314 | for (const name of entries) { |
| 251 | if (!input.includeHidden && name.startsWith(".")) continue; | 315 | if (!input.includeHidden && name.startsWith(".")) continue; |
| 252 | const child = `${root}${sep}${name}`; | 316 | const child = `${root}${sep}${name}`; |
| @@ -261,7 +325,12 @@ export const listLocalDirectoryDirs = ( · 8 unmodified lines | |||
| 261 | }).pipe(Effect.catch(() => Effect.succeed(null))); | 325 | }).pipe(Effect.catch(() => Effect.succeed(null))); |
| 262 | if (!entryStat) continue; | 326 | if (!entryStat) continue; |
| 263 | if (entryStat.isDirectory()) { | 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 | continue; | 334 | continue; |
| 266 | } | 335 | } |
| 267 | if (!entryStat.isSymbolicLink()) continue; | 336 | if (!entryStat.isSymbolicLink()) continue; |
| @@ -274,7 +343,9 @@ export const listLocalDirectoryDirs = ( · 6 unmodified lines | |||
| 274 | cause, | 343 | cause, |
| 275 | }), | 344 | }), |
| 276 | }).pipe(Effect.catch(() => Effect.succeed(null))); | 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 | dirs.sort((a, b) => a.name.localeCompare(b.name)); | 350 | dirs.sort((a, b) => a.name.localeCompare(b.name)); |
| 280 | const parent = dirname(root); | 351 | const parent = dirname(root); |
| @@ -282,6 +353,7 @@ export const listLocalDirectoryDirs = ( · 1 unmodified line | |||
| 282 | path: root, | 353 | path: root, |
| 283 | parent: parent === root ? null : parent, | 354 | parent: parent === root ? null : parent, |
| 284 | dirs, | 355 | dirs, |
| 356 | source, | ||
| 285 | }; | 357 | }; |
| 286 | }); | 358 | }); |
| 287 | 359 | ||