History

Show local app source shape in directory picker

Rhys Sullivan committed 4 months ago

Commit 1ef67be1 parent 07d4d4f9 files · +336 −24Browse files

Unified · Split

9 files
  1. e2e/local/custom-tools-directory-picker.test.ts +7 −0
  2. packages/plugins/apps/src/pipeline/discover.ts +12 −3
  3. packages/plugins/apps/src/plugin/routes.ts +6 −0
  4. packages/plugins/apps/src/react/AddCustomToolsSource.tsx +70 −12
  5. packages/plugins/apps/src/react/custom-tools-client.ts +8 −0
  6. packages/plugins/apps/src/react/custom-tools-client.web.test.ts +69 −7
  7. packages/plugins/apps/src/react/source-panel-model.ts +41 −0
  8. packages/plugins/apps/src/source/app-source.test.ts +49 −0
  9. packages/plugins/apps/src/source/local-directory-source.ts +74 −2
e2e/local/custom-tools-directory-picker.test.ts+7−0
@@ -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 });
packages/plugins/apps/src/pipeline/discover.ts+12−3
@@ -29,6 +29,15 @@ export interface DiscoverResult { · 28 unmodified lines
29const TOOL_RE = /^tools\/([a-z0-9][a-z0-9-]*)\.(ts|tsx|js|jsx)$/;29const TOOL_RE = /^tools\/([a-z0-9][a-z0-9-]*)\.(ts|tsx|js|jsx)$/;
30const DEFERRED_RE = /^(workflows|ui|skills)\//;30const DEFERRED_RE = /^(workflows|ui|skills)\//;
31 31
32/** Reserved top-level app source folders whose contents are deferred by discovery. */
33export 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. */
36export 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. */
39export const isReservedArtifactPath = (path: string): boolean => DEFERRED_RE.test(path);
40
32export const validToolKey = (key: string): boolean => /^[a-z0-9][a-z0-9-]*$/.test(key);41export const validToolKey = (key: string): boolean => /^[a-z0-9][a-z0-9-]*$/.test(key);
33 42
34export const discover = (files: ReadonlyMap<string, string>): DiscoverResult | PublishError => {43export 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) {
packages/plugins/apps/src/plugin/routes.ts+6−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
86const DomainErrors = [InternalError] as const;92const DomainErrors = [InternalError] as const;
packages/plugins/apps/src/react/AddCustomToolsSource.tsx+70−12
@@ -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";
37import { directoryBrowserRows } from "./source-panel-model";37import { directoryBrowserRows, directorySourceVerdict } from "./source-panel-model";
38 38
39export default function AddCustomToolsSource(props: {39export 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 Browse268 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 symlink410 <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 Cancel426 Cancel
389 </Button>427 </Button>
390 <Button428 <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
437function 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}
packages/plugins/apps/src/react/custom-tools-client.ts+8−0
@@ -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
125export interface CustomToolsDirectorySourceShape {
126 readonly toolFiles: readonly string[];
127 readonly skipped: readonly string[];
128 readonly hasPackageJson: boolean;
122}129}
123 130
124export interface CustomToolsDirectoryListing {131export 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
130export class CustomToolsClientError extends Data.TaggedError("CustomToolsClientError")<{138export class CustomToolsClientError extends Data.TaggedError("CustomToolsClientError")<{
packages/plugins/apps/src/react/custom-tools-client.web.test.ts+69−7
@@ -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";
20import { sourcePanelModel, syncNoticeFromResult } from "./source-panel-model";20import {
21 directorySourceVerdict,
22 sourcePanelModel,
23 syncNoticeFromResult,
24} from "./source-panel-model";
21import { directoryBrowserRows } from "./source-panel-model";25import { directoryBrowserRows } from "./source-panel-model";
22 26
23const jsonResponse = (body: unknown, init?: ResponseInit): Response =>27const 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",
packages/plugins/apps/src/react/source-panel-model.ts+41−0
@@ -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
40export 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
39const shortRef = (ref: string | undefined): string => (ref ? ref.slice(0, 12) : "Not synced");50const shortRef = (ref: string | undefined): string => (ref ? ref.slice(0, 12) : "Not synced");
40 51
41export const formatRelativeSyncTime = (timestamp: number, now = Date.now()): string => {52export 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
129const toolNameFromFile = (file: string): string => file.replace(/\.[^.]+$/, "");
130
131export 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};
packages/plugins/apps/src/source/app-source.test.ts+49−0
@@ -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(
packages/plugins/apps/src/source/local-directory-source.ts+74−2
@@ -7,6 +7,7 @@ import { dirname, isAbsolute, resolve, sep } from "node:path"; · 6 unmodified lines
7 7
8import { Effect, Schema } from "effect";8import { Effect, Schema } from "effect";
9 9
10import { RESERVED_ARTIFACT_DIRS, toolKeyFromPath } from "../pipeline/discover";
10import { PublishError, enforcePublishLimits, type PublishFile } from "../pipeline/publish";11import { PublishError, enforcePublishLimits, type PublishFile } from "../pipeline/publish";
11import { AppSourceError, type AppSourceSnapshot } from "./app-source";12import { AppSourceError, type AppSourceSnapshot } from "./app-source";
12import {13import {
@@ -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
40export interface LocalDirectorySourceShape {
41 readonly toolFiles: readonly string[];
42 readonly skipped: readonly string[];
43 readonly hasPackageJson: boolean;
36}44}
37 45
38export interface LocalDirectoryDirsResult {46export 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
44const sha256 = (bytes: Uint8Array | string): string =>53const 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
205const 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
212const 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
218const 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
224const 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
196export const fetchLocalDirectoryAppSource = (259export 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