@hunsu/bridge 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -361,6 +361,7 @@ export type StudioServerOptions = {
361
361
  roadmapRegistryPath?: string;
362
362
  runtimeConfig?: BridgeRuntimeConfig;
363
363
  security?: StudioServerSecurityOptions;
364
+ directoryPicker?: NativeDirectoryPicker;
364
365
  };
365
366
  export type StudioCommandResult = {
366
367
  acceptedEvents: DomainEvent[];
@@ -455,6 +456,29 @@ export type FilesystemGrantRequest = {
455
456
  export type FilesystemGrantResult = {
456
457
  capability: FilesystemBrowseCapability;
457
458
  };
459
+ export type NativeDirectoryPickerOptions = {
460
+ cwd: string;
461
+ };
462
+ export type NativeDirectoryPickerResult = {
463
+ status: "selected";
464
+ path: string;
465
+ } | {
466
+ status: "cancelled";
467
+ } | {
468
+ status: "unavailable";
469
+ reason: string;
470
+ };
471
+ export type NativeDirectoryPicker = (options: NativeDirectoryPickerOptions) => Promise<NativeDirectoryPickerResult>;
472
+ export type FilesystemDirectoryPickResult = {
473
+ status: "selected";
474
+ capability: FilesystemBrowseCapability;
475
+ entry: FilesystemBrowseEntry;
476
+ } | {
477
+ status: "cancelled";
478
+ } | {
479
+ status: "unavailable";
480
+ reason: string;
481
+ };
458
482
  export type MoveFileNode = {
459
483
  kind: "directory";
460
484
  name: string;
@@ -914,6 +938,11 @@ export declare function createFilesystemBrowseGrant(request: FilesystemGrantRequ
914
938
  export declare function tryCreateFilesystemBrowseGrant(request: FilesystemGrantRequest, state: StudioServerState, options?: {
915
939
  cwd?: string;
916
940
  }): Result<FilesystemGrantResult, FilesystemBrowseError>;
941
+ export declare function pickFilesystemDirectory(state: StudioServerState, options?: {
942
+ cwd?: string;
943
+ picker?: NativeDirectoryPicker;
944
+ }): Promise<FilesystemDirectoryPickResult>;
945
+ export declare function openNativeDirectoryPicker(_options: NativeDirectoryPickerOptions): Promise<NativeDirectoryPickerResult>;
917
946
  export declare function createRouteWorktree(cwd: string, routeId: string, createdAt?: string, requestedBaseRef?: string, requestedWorktreeRoot?: string): WorktreeRef;
918
947
  export declare function createExecuteWorktree(cwd: string, executeId: string, createdAt?: string, requestedBaseRef?: string, requestedWorktreeRoot?: string): WorktreeRef;
919
948
  export declare function readMoveDiff(board: BoardProjection, moveId: string, cwd?: string): MoveDiff;
package/dist/index.js CHANGED
@@ -662,6 +662,8 @@ export function createStudioServer(options = {}) {
662
662
  const runner = options.runner ?? createConfiguredRunner(runtimeConfig);
663
663
  const actionRunner = options.actionRunner;
664
664
  const security = createStudioServerSecurity(options.security, runtimeConfig);
665
+ const directoryPicker = options.directoryPicker ?? openNativeDirectoryPicker;
666
+ let activeDirectoryPick = false;
665
667
  return createServer(async (request, response) => {
666
668
  try {
667
669
  const url = new URL(request.url ?? "/", "http://localhost");
@@ -699,6 +701,20 @@ export function createStudioServer(options = {}) {
699
701
  }));
700
702
  return;
701
703
  }
704
+ if (request.method === "POST" && pathname === "/api/filesystem/pick-directory") {
705
+ if (activeDirectoryPick) {
706
+ sendJson(response, 409, { status: "unavailable", reason: "A folder picker is already open." });
707
+ return;
708
+ }
709
+ activeDirectoryPick = true;
710
+ try {
711
+ sendJson(response, 200, await pickFilesystemDirectory(state, { cwd: repositoryPath, picker: directoryPicker }));
712
+ }
713
+ finally {
714
+ activeDirectoryPick = false;
715
+ }
716
+ return;
717
+ }
702
718
  if (request.method === "POST" && pathname === "/api/filesystem/grants") {
703
719
  const body = await readJson(request);
704
720
  sendJson(response, 201, createFilesystemBrowseGrant(body, state, { cwd: repositoryPath }));
@@ -2994,6 +3010,73 @@ export function tryCreateFilesystemBrowseGrant(request, state, options = {}) {
2994
3010
  state.filesystemCapabilities[token] = capability;
2995
3011
  return ok({ capability });
2996
3012
  }
3013
+ export async function pickFilesystemDirectory(state, options = {}) {
3014
+ const cwd = options.cwd ?? process.cwd();
3015
+ const picker = options.picker ?? openNativeDirectoryPicker;
3016
+ const picked = await picker({ cwd });
3017
+ if (picked.status !== "selected") {
3018
+ return picked;
3019
+ }
3020
+ const grant = createFilesystemBrowseGrant({ path: picked.path }, state, { cwd });
3021
+ return {
3022
+ status: "selected",
3023
+ capability: grant.capability,
3024
+ entry: filesystemBrowseEntryForPath(grant.capability.path, grant.capability.rootId)
3025
+ };
3026
+ }
3027
+ export async function openNativeDirectoryPicker(_options) {
3028
+ if (process.platform === "darwin") {
3029
+ return runDirectoryPickerCommand("osascript", [
3030
+ "-e",
3031
+ "POSIX path of (choose folder with prompt \"Choose a project folder for Hunsu Studio\")"
3032
+ ], {
3033
+ cancelPatterns: [/User canceled/i, /-128/]
3034
+ });
3035
+ }
3036
+ if (process.platform === "win32") {
3037
+ return runDirectoryPickerCommand("powershell.exe", [
3038
+ "-NoProfile",
3039
+ "-STA",
3040
+ "-Command",
3041
+ [
3042
+ "Add-Type -AssemblyName System.Windows.Forms;",
3043
+ "$dialog = New-Object System.Windows.Forms.FolderBrowserDialog;",
3044
+ "$dialog.Description = 'Choose a project folder for Hunsu Studio';",
3045
+ "$dialog.ShowNewFolderButton = $true;",
3046
+ "if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {",
3047
+ " [Console]::Out.WriteLine($dialog.SelectedPath);",
3048
+ " exit 0;",
3049
+ "}",
3050
+ "exit 2;"
3051
+ ].join(" ")
3052
+ ], {
3053
+ cancelCodes: [2]
3054
+ });
3055
+ }
3056
+ const zenity = await runDirectoryPickerCommand("zenity", [
3057
+ "--file-selection",
3058
+ "--directory",
3059
+ "--title=Choose a project folder for Hunsu Studio"
3060
+ ], {
3061
+ cancelCodes: [1],
3062
+ unavailablePatterns: [/cannot open display/i, /no display/i]
3063
+ });
3064
+ if (zenity.status !== "unavailable") {
3065
+ return zenity;
3066
+ }
3067
+ const kdialog = await runDirectoryPickerCommand("kdialog", [
3068
+ "--title",
3069
+ "Choose a project folder for Hunsu Studio",
3070
+ "--getexistingdirectory"
3071
+ ], {
3072
+ cancelCodes: [1],
3073
+ unavailablePatterns: [/cannot connect/i, /no display/i]
3074
+ });
3075
+ if (kdialog.status !== "unavailable") {
3076
+ return kdialog;
3077
+ }
3078
+ return { status: "unavailable", reason: "No supported native folder picker is available in this environment." };
3079
+ }
2997
3080
  function resolveRoadmapOpenRequestPath(request, state) {
2998
3081
  if (!request.browseToken) {
2999
3082
  return request;
@@ -3031,6 +3114,39 @@ function browseRootLabel(path) {
3031
3114
  }
3032
3115
  return basename(path) || path;
3033
3116
  }
3117
+ async function runDirectoryPickerCommand(command, args, options = {}) {
3118
+ try {
3119
+ const { stdout } = await execFileAsync(command, args);
3120
+ const selectedPath = stdout.trim();
3121
+ return selectedPath ? { status: "selected", path: selectedPath } : { status: "cancelled" };
3122
+ }
3123
+ catch (error) {
3124
+ const pickerError = error;
3125
+ const output = `${pickerError.stdout ?? ""}\n${pickerError.stderr ?? ""}\n${pickerError.message}`;
3126
+ if (pickerError.code === "ENOENT" || options.unavailablePatterns?.some(pattern => pattern.test(output))) {
3127
+ return { status: "unavailable", reason: `${command} is not available.` };
3128
+ }
3129
+ if (typeof pickerError.code === "number" && options.cancelCodes?.includes(pickerError.code)) {
3130
+ return { status: "cancelled" };
3131
+ }
3132
+ if (options.cancelPatterns?.some(pattern => pattern.test(output))) {
3133
+ return { status: "cancelled" };
3134
+ }
3135
+ return { status: "unavailable", reason: output.trim() || `${command} failed to open a folder picker.` };
3136
+ }
3137
+ }
3138
+ function filesystemBrowseEntryForPath(path, rootId) {
3139
+ const entryPath = String(path);
3140
+ return {
3141
+ kind: "directory",
3142
+ name: basename(entryPath) || entryPath,
3143
+ path: entryPath,
3144
+ rootId,
3145
+ type: "directory",
3146
+ isGitRepository: existsSync(join(entryPath, ".git")),
3147
+ isRoadmap: hasBrowseVisibleHunsuRuntimeFiles(entryPath)
3148
+ };
3149
+ }
3034
3150
  function hashText(value) {
3035
3151
  return hashHex(createHash("sha256").update(value));
3036
3152
  }
@@ -3082,7 +3198,12 @@ function canonicalizeGrantTargetPath(path, roots) {
3082
3198
  }
3083
3199
  }
3084
3200
  function isAllowedBrowsePath(path, roots) {
3085
- return roots.some(root => path === root || path.startsWith(`${root}/`));
3201
+ const normalizedPath = normalize(path);
3202
+ return roots.some(root => {
3203
+ const normalizedRoot = normalize(root);
3204
+ const childPath = relative(normalizedRoot, normalizedPath);
3205
+ return childPath === "" || (!childPath.startsWith("..") && !isAbsolute(childPath));
3206
+ });
3086
3207
  }
3087
3208
  function shouldHideBrowseEntry(name) {
3088
3209
  return name === "node_modules"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hunsu/bridge",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "private": false,
5
5
  "description": "Hunsu Bridge localhost runtime and Studio API server.",
6
6
  "license": "Apache-2.0",
@@ -29,9 +29,9 @@
29
29
  "dependencies": {
30
30
  "@hunsu/codex-runner": "0.1.1",
31
31
  "@hunsu/core": "0.1.1",
32
- "@hunsu/config": "0.1.1",
32
+ "@hunsu/protocol-registry": "0.1.1",
33
33
  "@hunsu/protocol": "0.1.1",
34
- "@hunsu/protocol-registry": "0.1.1"
34
+ "@hunsu/config": "0.1.1"
35
35
  },
36
36
  "scripts": {
37
37
  "dev": "node --watch src/index.ts",
package/src/index.ts CHANGED
@@ -582,6 +582,7 @@ export type StudioServerOptions = {
582
582
  roadmapRegistryPath?: string;
583
583
  runtimeConfig?: BridgeRuntimeConfig;
584
584
  security?: StudioServerSecurityOptions;
585
+ directoryPicker?: NativeDirectoryPicker;
585
586
  };
586
587
 
587
588
  export type StudioCommandResult = {
@@ -687,6 +688,22 @@ export type FilesystemGrantResult = {
687
688
  capability: FilesystemBrowseCapability;
688
689
  };
689
690
 
691
+ export type NativeDirectoryPickerOptions = {
692
+ cwd: string;
693
+ };
694
+
695
+ export type NativeDirectoryPickerResult =
696
+ | { status: "selected"; path: string }
697
+ | { status: "cancelled" }
698
+ | { status: "unavailable"; reason: string };
699
+
700
+ export type NativeDirectoryPicker = (options: NativeDirectoryPickerOptions) => Promise<NativeDirectoryPickerResult>;
701
+
702
+ export type FilesystemDirectoryPickResult =
703
+ | { status: "selected"; capability: FilesystemBrowseCapability; entry: FilesystemBrowseEntry }
704
+ | { status: "cancelled" }
705
+ | { status: "unavailable"; reason: string };
706
+
690
707
  export type MoveFileNode =
691
708
  | { kind: "directory"; name: string; path: string }
692
709
  | { kind: "textFile"; name: string; path: string; size: number }
@@ -1695,6 +1712,8 @@ export function createStudioServer(options: StudioServerOptions = {}) {
1695
1712
  const runner = options.runner ?? createConfiguredRunner(runtimeConfig);
1696
1713
  const actionRunner = options.actionRunner;
1697
1714
  const security = createStudioServerSecurity(options.security, runtimeConfig);
1715
+ const directoryPicker = options.directoryPicker ?? openNativeDirectoryPicker;
1716
+ let activeDirectoryPick = false;
1698
1717
 
1699
1718
  return createServer(async (request, response) => {
1700
1719
  try {
@@ -1739,6 +1758,20 @@ export function createStudioServer(options: StudioServerOptions = {}) {
1739
1758
  return;
1740
1759
  }
1741
1760
 
1761
+ if (request.method === "POST" && pathname === "/api/filesystem/pick-directory") {
1762
+ if (activeDirectoryPick) {
1763
+ sendJson(response, 409, { status: "unavailable", reason: "A folder picker is already open." });
1764
+ return;
1765
+ }
1766
+ activeDirectoryPick = true;
1767
+ try {
1768
+ sendJson(response, 200, await pickFilesystemDirectory(state, { cwd: repositoryPath, picker: directoryPicker }));
1769
+ } finally {
1770
+ activeDirectoryPick = false;
1771
+ }
1772
+ return;
1773
+ }
1774
+
1742
1775
  if (request.method === "POST" && pathname === "/api/filesystem/grants") {
1743
1776
  const body = await readJson<FilesystemGrantRequest>(request);
1744
1777
  sendJson(response, 201, createFilesystemBrowseGrant(body, state, { cwd: repositoryPath }));
@@ -4427,6 +4460,82 @@ export function tryCreateFilesystemBrowseGrant(
4427
4460
  return ok({ capability });
4428
4461
  }
4429
4462
 
4463
+ export async function pickFilesystemDirectory(
4464
+ state: StudioServerState,
4465
+ options: { cwd?: string; picker?: NativeDirectoryPicker } = {}
4466
+ ): Promise<FilesystemDirectoryPickResult> {
4467
+ const cwd = options.cwd ?? process.cwd();
4468
+ const picker = options.picker ?? openNativeDirectoryPicker;
4469
+ const picked = await picker({ cwd });
4470
+ if (picked.status !== "selected") {
4471
+ return picked;
4472
+ }
4473
+ const grant = createFilesystemBrowseGrant({ path: picked.path }, state, { cwd });
4474
+ return {
4475
+ status: "selected",
4476
+ capability: grant.capability,
4477
+ entry: filesystemBrowseEntryForPath(grant.capability.path, grant.capability.rootId)
4478
+ };
4479
+ }
4480
+
4481
+ export async function openNativeDirectoryPicker(_options: NativeDirectoryPickerOptions): Promise<NativeDirectoryPickerResult> {
4482
+ if (process.platform === "darwin") {
4483
+ return runDirectoryPickerCommand("osascript", [
4484
+ "-e",
4485
+ "POSIX path of (choose folder with prompt \"Choose a project folder for Hunsu Studio\")"
4486
+ ], {
4487
+ cancelPatterns: [/User canceled/i, /-128/]
4488
+ });
4489
+ }
4490
+
4491
+ if (process.platform === "win32") {
4492
+ return runDirectoryPickerCommand("powershell.exe", [
4493
+ "-NoProfile",
4494
+ "-STA",
4495
+ "-Command",
4496
+ [
4497
+ "Add-Type -AssemblyName System.Windows.Forms;",
4498
+ "$dialog = New-Object System.Windows.Forms.FolderBrowserDialog;",
4499
+ "$dialog.Description = 'Choose a project folder for Hunsu Studio';",
4500
+ "$dialog.ShowNewFolderButton = $true;",
4501
+ "if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {",
4502
+ " [Console]::Out.WriteLine($dialog.SelectedPath);",
4503
+ " exit 0;",
4504
+ "}",
4505
+ "exit 2;"
4506
+ ].join(" ")
4507
+ ], {
4508
+ cancelCodes: [2]
4509
+ });
4510
+ }
4511
+
4512
+ const zenity = await runDirectoryPickerCommand("zenity", [
4513
+ "--file-selection",
4514
+ "--directory",
4515
+ "--title=Choose a project folder for Hunsu Studio"
4516
+ ], {
4517
+ cancelCodes: [1],
4518
+ unavailablePatterns: [/cannot open display/i, /no display/i]
4519
+ });
4520
+ if (zenity.status !== "unavailable") {
4521
+ return zenity;
4522
+ }
4523
+
4524
+ const kdialog = await runDirectoryPickerCommand("kdialog", [
4525
+ "--title",
4526
+ "Choose a project folder for Hunsu Studio",
4527
+ "--getexistingdirectory"
4528
+ ], {
4529
+ cancelCodes: [1],
4530
+ unavailablePatterns: [/cannot connect/i, /no display/i]
4531
+ });
4532
+ if (kdialog.status !== "unavailable") {
4533
+ return kdialog;
4534
+ }
4535
+
4536
+ return { status: "unavailable", reason: "No supported native folder picker is available in this environment." };
4537
+ }
4538
+
4430
4539
  function resolveRoadmapOpenRequestPath<T extends RoadmapOpenRequest>(request: T, state: StudioServerState): T {
4431
4540
  if (!request.browseToken) {
4432
4541
  return request;
@@ -4469,6 +4578,44 @@ function browseRootLabel(path: string): string {
4469
4578
  return basename(path) || path;
4470
4579
  }
4471
4580
 
4581
+ async function runDirectoryPickerCommand(
4582
+ command: string,
4583
+ args: string[],
4584
+ options: { cancelCodes?: number[]; cancelPatterns?: RegExp[]; unavailablePatterns?: RegExp[] } = {}
4585
+ ): Promise<NativeDirectoryPickerResult> {
4586
+ try {
4587
+ const { stdout } = await execFileAsync(command, args);
4588
+ const selectedPath = stdout.trim();
4589
+ return selectedPath ? { status: "selected", path: selectedPath } : { status: "cancelled" };
4590
+ } catch (error) {
4591
+ const pickerError = error as Error & { code?: string | number; stderr?: string; stdout?: string };
4592
+ const output = `${pickerError.stdout ?? ""}\n${pickerError.stderr ?? ""}\n${pickerError.message}`;
4593
+ if (pickerError.code === "ENOENT" || options.unavailablePatterns?.some(pattern => pattern.test(output))) {
4594
+ return { status: "unavailable", reason: `${command} is not available.` };
4595
+ }
4596
+ if (typeof pickerError.code === "number" && options.cancelCodes?.includes(pickerError.code)) {
4597
+ return { status: "cancelled" };
4598
+ }
4599
+ if (options.cancelPatterns?.some(pattern => pattern.test(output))) {
4600
+ return { status: "cancelled" };
4601
+ }
4602
+ return { status: "unavailable", reason: output.trim() || `${command} failed to open a folder picker.` };
4603
+ }
4604
+ }
4605
+
4606
+ function filesystemBrowseEntryForPath(path: CanonicalLocalPath, rootId: BrowseRootId): FilesystemBrowseEntry {
4607
+ const entryPath = String(path);
4608
+ return {
4609
+ kind: "directory",
4610
+ name: basename(entryPath) || entryPath,
4611
+ path: entryPath,
4612
+ rootId,
4613
+ type: "directory",
4614
+ isGitRepository: existsSync(join(entryPath, ".git")),
4615
+ isRoadmap: hasBrowseVisibleHunsuRuntimeFiles(entryPath)
4616
+ };
4617
+ }
4618
+
4472
4619
  function hashText(value: string): string {
4473
4620
  return hashHex(createHash("sha256").update(value));
4474
4621
  }
@@ -4523,7 +4670,12 @@ function canonicalizeGrantTargetPath(path: string, roots: string[]): string | un
4523
4670
  }
4524
4671
 
4525
4672
  function isAllowedBrowsePath(path: string, roots: string[]): boolean {
4526
- return roots.some(root => path === root || path.startsWith(`${root}/`));
4673
+ const normalizedPath = normalize(path);
4674
+ return roots.some(root => {
4675
+ const normalizedRoot = normalize(root);
4676
+ const childPath = relative(normalizedRoot, normalizedPath);
4677
+ return childPath === "" || (!childPath.startsWith("..") && !isAbsolute(childPath));
4678
+ });
4527
4679
  }
4528
4680
 
4529
4681
  function shouldHideBrowseEntry(name: string): boolean {