@prettier-ai/dsh-host-directory-picker-browse 0.1.2-alpha.1
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/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +122 -0
- package/README.zh.md +122 -0
- package/lib/index.js +234 -0
- package/lib/invariant.js +20 -0
- package/lib/types/index.d.ts +83 -0
- package/lib/types/invariant.d.ts +16 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeepSeek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.i18n.yaml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
|
2
|
+
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
|
3
|
+
# after editing either side, bring the other along and re-record with:
|
|
4
|
+
# pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md
|
|
5
|
+
README.md: 986504a48da5b1ea28c174168eec41e40bb77e4e
|
|
6
|
+
README.zh.md: 54a722bdad2d88e6cadc6f39c74f479851197351
|
package/README.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "In-app browsing backend of the directory-picker seam: one-level directory listing and child-directory creation for the web GUI host, serving remote clients too."
|
|
3
|
+
kind: "package-reference"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# @deepseek-ai/dsh-host-directory-picker-browse
|
|
7
|
+
|
|
8
|
+
English | [中文](README.zh.md)
|
|
9
|
+
|
|
10
|
+
## Summary
|
|
11
|
+
|
|
12
|
+
Users who cannot reach an OS chooser still pick a workspace directory through `dsh-host-directory-picker-browse`: it provides one-level directory listing and child-directory creation over Node's standard library, and nothing renders on the host display — so it serves the remote clients the native backend cannot reach. Listings return directories only, name-sorted, with symlink-to-directory following and a host-owned `hidden` flag; creation is non-recursive and validates a single path segment. One composition row also fills the workspace flow's directory holes with the in-app **Select Workspace Directory** dialog.
|
|
13
|
+
|
|
14
|
+
## Table of Contents
|
|
15
|
+
|
|
16
|
+
- [Use this package](#use-this-package)
|
|
17
|
+
- [Understand the implementation](#understand-the-implementation)
|
|
18
|
+
- [Further Exploration](#further-exploration)
|
|
19
|
+
- [Model Experience](#model-experience)
|
|
20
|
+
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
|
|
21
|
+
- [Dev Note](#dev-note)
|
|
22
|
+
|
|
23
|
+
-----
|
|
24
|
+
|
|
25
|
+
<a id="use-this-package"></a>
|
|
26
|
+
## Use this package
|
|
27
|
+
|
|
28
|
+
Compose this backend when a workspace directory must be chosen without an OS chooser — remote browsers, SSH-forwarded sessions, or unattended hosts. The workspace flow drives `directoryPicker/list` and `directoryPicker/createDirectory`; both primitives answer from the host filesystem.
|
|
29
|
+
|
|
30
|
+
### Listing a directory
|
|
31
|
+
|
|
32
|
+
`list(path?)` returns one directory level: name-sorted child directories with their absolute paths, a `hidden` flag (dot-prefixed on POSIX), a `home` anchor, and `crumbs` — the root-to-target ancestor chain where every crumb is a jump target and the root is labeled by its full path. An absent path lists the host account's home directory. One call returns at most `maxEntries` rows (config, default 1,000 — the bound GitHub's web UI applies to directory listings), and a cut level reports `truncated: true` so the client can say the level is incomplete. Symlinks to directories are followed; broken and cyclic links are skipped.
|
|
33
|
+
|
|
34
|
+
### Creating a directory
|
|
35
|
+
|
|
36
|
+
`createDirectory(path, name)` creates one child directory under an existing parent. It is non-recursive — a missing parent is a real failure, not a level to invent — and rejects anything but a single non-blank path segment (`name` must not contain separators and must not be `.` or `..`).
|
|
37
|
+
|
|
38
|
+
### Observable failures
|
|
39
|
+
|
|
40
|
+
Both primitives refuse a path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes that `isAbsolute` accepts — with `directory-unreadable` or `directory-create-failed`, instead of resolving it under the host process working directory. Creation of an existing child answers `directory-exists`. A caller's `AbortSignal` stops an in-flight scan, so a disconnect or timeout does not leave the scan outliving the caller.
|
|
41
|
+
|
|
42
|
+
### Configuration
|
|
43
|
+
|
|
44
|
+
| Field | Default | Meaning |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `maxEntries` | `1,000` | Complete-result bound of one listing level; hidden rows count toward it |
|
|
47
|
+
|
|
48
|
+
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-host-directory-picker-browse) is the exhaustive source for every accepted field and its JSDoc.
|
|
49
|
+
|
|
50
|
+
-----
|
|
51
|
+
|
|
52
|
+
<a id="understand-the-implementation"></a>
|
|
53
|
+
## Understand the implementation
|
|
54
|
+
|
|
55
|
+
<details>
|
|
56
|
+
<summary>Implementation internals — click to expand</summary>
|
|
57
|
+
|
|
58
|
+
### Design concept
|
|
59
|
+
|
|
60
|
+
The backend streams one directory level through a bounded name-sorted window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, hidden rows count against the bound, only windowed candidates are probed, and the level reports `truncated: true`. Window insertion is binary with an O(1) full-window tail rejection, so an oversized level costs O(1) per candidate past the head instead of a window scan.
|
|
61
|
+
|
|
62
|
+
### The fully-qualified fence
|
|
63
|
+
|
|
64
|
+
`fullyQualified` rejects any path that does not name one fixed filesystem location regardless of process state: POSIX-absolute on POSIX; on Windows only drive-qualified (`C:\…`) or complete UNC (`\\server\share…`) forms. Rooted drive-less forms and incomplete UNC prefixes pass `isAbsolute` yet still resolve against the process's current drive, so the backend refuses them rather than rebasing a wire value.
|
|
65
|
+
|
|
66
|
+
### Abort and probing
|
|
67
|
+
|
|
68
|
+
Every filesystem await races the caller's signal (`raceAbort`), so a stalled network filesystem cannot keep a departed caller's request alive; an abandoned read's late settlement is swallowed. Symlink enterability is decided by a `stat` probe — failure means not enterable — and a windowed broken symlink is not backfilled from beyond the window, because an eviction already marked the level truncated.
|
|
69
|
+
|
|
70
|
+
### Source map
|
|
71
|
+
|
|
72
|
+
| File | Role |
|
|
73
|
+
|---|---|
|
|
74
|
+
| [`src/index.ts`](src/index.ts) | `BrowseDirectoryPicker` service: listing, creation, bounded window, error mapping |
|
|
75
|
+
| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the filesystem is authoritative) |
|
|
76
|
+
|
|
77
|
+
</details>
|
|
78
|
+
|
|
79
|
+
-----
|
|
80
|
+
|
|
81
|
+
<a id="further-exploration"></a>
|
|
82
|
+
## Further Exploration
|
|
83
|
+
|
|
84
|
+
Read these when the backend contract is not enough: the seam definition first, then the decision record and the native alternative.
|
|
85
|
+
|
|
86
|
+
- [Directory-picker seam](../directory-picker/README.md) — the `browse` capability contract and the typed error vocabulary.
|
|
87
|
+
- [Directory-picker capability seam decision](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md) — the policy decisions behind listing and creation.
|
|
88
|
+
- [Native backend](../directory-picker-native/README.md) — the OS-chooser alternative for local operators.
|
|
89
|
+
- [Adaptive chooser](../directory-picker-auto/README.md) — boot-time resolution between the two backends.
|
|
90
|
+
- [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-host-directory-picker-browse) — every accepted config field and its source declaration.
|
|
91
|
+
|
|
92
|
+
-----
|
|
93
|
+
|
|
94
|
+
<a id="model-experience"></a>
|
|
95
|
+
## Model Experience
|
|
96
|
+
|
|
97
|
+
None, as the GUI-host picking backend registers nothing model-facing.
|
|
98
|
+
|
|
99
|
+
#### KV Cache effect
|
|
100
|
+
|
|
101
|
+
None; this package neither assembles nor sends a provider request.
|
|
102
|
+
|
|
103
|
+
## Known Limitations and Deferred Work
|
|
104
|
+
|
|
105
|
+
<a id="known-limitations-and-deferred-work"></a>
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
These limits define where the browse interaction is incomplete or intentionally unscoped. They are current package constraints, not a task backlog.
|
|
109
|
+
|
|
110
|
+
- **Windows hidden attribute is not read** — Node dirents do not expose `FILE_ATTRIBUTE_HIDDEN`, so `hidden` means dot-prefixed on every platform until a native probe is worth its cost.
|
|
111
|
+
- **No drive-root enumeration** — on Windows the ancestry stops at the drive root; crossing drives waits for the browser UI's path-entry affordance rather than an enumeration primitive here.
|
|
112
|
+
- **Whole-filesystem scope** — there is no per-deployment browse-root restriction; `workspace.create` accepts arbitrary paths, so a root here would be UX scoping rather than a security boundary.
|
|
113
|
+
|
|
114
|
+
<a id="dev-note"></a>
|
|
115
|
+
### Dev Note
|
|
116
|
+
|
|
117
|
+
<details>
|
|
118
|
+
<summary>Working context for maintainers — click to expand</summary>
|
|
119
|
+
|
|
120
|
+
None.
|
|
121
|
+
|
|
122
|
+
</details>
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "目录选择 seam 的应用内浏览后端:为 web GUI 宿主提供单层目录列举与子目录创建,也能服务于远程客户端。"
|
|
3
|
+
kind: "package-reference"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# @deepseek-ai/dsh-host-directory-picker-browse
|
|
7
|
+
|
|
8
|
+
[English](README.md) | 中文
|
|
9
|
+
|
|
10
|
+
## 概述
|
|
11
|
+
|
|
12
|
+
无法触达 OS 选择器的用户仍能通过 `dsh-host-directory-picker-browse` 选择工作区目录:它基于 Node 标准库提供单层目录列举与子目录创建,宿主屏幕上不渲染任何东西——因此它能服务原生后端无法触及的远程客户端。列举只返回目录、按名称排序,跟随指向目录的符号链接,并携带宿主判定的 `hidden` 标志;创建不递归,且把名称校验为单个路径段。一行组合配置还会用应用内**选择工作区目录**对话框填满工作区流程的目录扩展位。
|
|
13
|
+
|
|
14
|
+
## 目录
|
|
15
|
+
|
|
16
|
+
- [使用本包](#use-this-package)
|
|
17
|
+
- [理解实现](#understand-the-implementation)
|
|
18
|
+
- [进一步探索](#further-exploration)
|
|
19
|
+
- [模型体验](#model-experience)
|
|
20
|
+
- [已知限制与延期工作](#known-limitations-and-deferred-work)
|
|
21
|
+
- [开发备注](#dev-note)
|
|
22
|
+
|
|
23
|
+
-----
|
|
24
|
+
|
|
25
|
+
<a id="use-this-package"></a>
|
|
26
|
+
## 使用本包
|
|
27
|
+
|
|
28
|
+
当工作区目录必须在没有 OS 选择器的情况下被选中时——远程浏览器、SSH 转发会话或无人值守宿主——组合此后端。工作区流程驱动 `directoryPicker/list` 与 `directoryPicker/createDirectory`;两个原语都从宿主文件系统作答。
|
|
29
|
+
|
|
30
|
+
### 列举目录
|
|
31
|
+
|
|
32
|
+
`list(path?)` 返回一个目录层级:按名称排序的子目录及其绝对路径、`hidden` 标志(POSIX 上为点前缀)、`home` 锚点,以及 `crumbs`——从根到目标的祖先链,其中每个 crumb 都是跳转目标,根以完整路径标注。不带路径时列举宿主账户的家目录。单次调用至多返回 `maxEntries` 行(配置项,默认 1,000——GitHub 网页端对目录列举采用的同一上限),被截断的层级会报告 `truncated: true`,供客户端提示层级不完整。指向目录的符号链接会被跟随;断链与循环链接被跳过。
|
|
33
|
+
|
|
34
|
+
### 创建目录
|
|
35
|
+
|
|
36
|
+
`createDirectory(path, name)` 在既有父目录下创建一个子目录。它不递归——父目录缺失是真实失败,不是要补造的层级——并且拒绝任何非单个非空白路径段的内容(`name` 不得包含分隔符,也不得为 `.` 或 `..`)。
|
|
37
|
+
|
|
38
|
+
### 可观察的失败
|
|
39
|
+
|
|
40
|
+
两个原语都拒绝非完全限定的路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀——报 `directory-unreadable` 或 `directory-create-failed`,而不是把它解析到宿主进程工作目录之下。创建已存在的子目录回答 `directory-exists`。调用方的 `AbortSignal` 会停止进行中的扫描,因此断连或超时不会让扫描比调用方活得更久。
|
|
41
|
+
|
|
42
|
+
### 配置
|
|
43
|
+
|
|
44
|
+
| 字段 | 默认值 | 含义 |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `maxEntries` | `1,000` | 单个列举层级的完整结果上限;隐藏行计入该上限 |
|
|
47
|
+
|
|
48
|
+
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-host-directory-picker-browse)是每个受支持字段及其 JSDoc 的穷尽式真源。
|
|
49
|
+
|
|
50
|
+
-----
|
|
51
|
+
|
|
52
|
+
<a id="understand-the-implementation"></a>
|
|
53
|
+
## 理解实现
|
|
54
|
+
|
|
55
|
+
<details>
|
|
56
|
+
<summary>实现细节——点击展开</summary>
|
|
57
|
+
|
|
58
|
+
### 设计理念
|
|
59
|
+
|
|
60
|
+
后端把单个目录层级流式送入一个有界、按名排序的窗口,因此无论目录有多少子项,内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`。窗口插入为二分查找、满窗尾部单次比较即拒绝,因此超大型层级在头部之后的每个候选都只需 O(1),而不是窗口扫描。
|
|
61
|
+
|
|
62
|
+
### 完全限定栅栏
|
|
63
|
+
|
|
64
|
+
`fullyQualified` 拒绝任何不指向一个与进程状态无关的固定文件系统位置的路径:POSIX 上要求 POSIX 绝对路径;Windows 上只接受盘符限定(`C:\…`)或完整 UNC(`\\server\share…`)形态。无盘符有根形态与不完整 UNC 前缀能通过 `isAbsolute`,却仍会解析到进程的当前盘符,因此后端拒绝它们,而不是重定位一个 wire 值。
|
|
65
|
+
|
|
66
|
+
### 中止与探测
|
|
67
|
+
|
|
68
|
+
每个文件系统 await 都与调用方的信号竞争(`raceAbort`),因此停滞的网络文件系统不能让已离开的调用方请求继续存活;被弃读的迟到结算会被吞掉。符号链接的可进入性由 `stat` 探测决定——失败即不可进入——窗口内的断链符号链接不会从窗口外回填,因为发生过驱逐本身已把层级标记为截断。
|
|
69
|
+
|
|
70
|
+
### 源码地图
|
|
71
|
+
|
|
72
|
+
| 文件 | 职责 |
|
|
73
|
+
|---|---|
|
|
74
|
+
| [`src/index.ts`](src/index.ts) | `BrowseDirectoryPicker` 服务:列举、创建、有界窗口、错误映射 |
|
|
75
|
+
| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;文件系统是权威) |
|
|
76
|
+
|
|
77
|
+
</details>
|
|
78
|
+
|
|
79
|
+
-----
|
|
80
|
+
|
|
81
|
+
<a id="further-exploration"></a>
|
|
82
|
+
## 进一步探索
|
|
83
|
+
|
|
84
|
+
当后端约定不够用时阅读以下内容:先看 seam 定义,再看决策记录与原生替代方案。
|
|
85
|
+
|
|
86
|
+
- [目录选择 seam](../directory-picker/README.zh.md)——`browse` 能力约定与类型化错误词汇。
|
|
87
|
+
- [目录选择能力 seam 决策](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md)——列举与创建背后的策略裁决。
|
|
88
|
+
- [原生后端](../directory-picker-native/README.zh.md)——面向本地操作者的 OS 选择器替代方案。
|
|
89
|
+
- [自适应选择器](../directory-picker-auto/README.zh.md)——两个后端之间的启动时判定。
|
|
90
|
+
- [生成配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-host-directory-picker-browse)——每个受支持配置字段及其源声明。
|
|
91
|
+
|
|
92
|
+
-----
|
|
93
|
+
|
|
94
|
+
<a id="model-experience"></a>
|
|
95
|
+
## 模型体验
|
|
96
|
+
|
|
97
|
+
无。GUI 宿主的目录选择后端不注册任何面向模型的内容。
|
|
98
|
+
|
|
99
|
+
#### KV Cache 影响
|
|
100
|
+
|
|
101
|
+
无;该包既不组装也不发送提供方请求。
|
|
102
|
+
|
|
103
|
+
## 已知限制与延期工作
|
|
104
|
+
|
|
105
|
+
<a id="known-limitations-and-deferred-work"></a>
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
这些限制说明浏览交互在何处不完整或有意不限定范围。它们是当前包约束,不是任务积压。
|
|
109
|
+
|
|
110
|
+
- **不读取 Windows 隐藏属性**——Node 的 dirent 不暴露 `FILE_ATTRIBUTE_HIDDEN`,因此在所有平台上 `hidden` 都意味着点前缀,直到原生探测值得付出相应成本为止。
|
|
111
|
+
- **不枚举盘符根**——Windows 上祖先链止于盘符根;跨盘依赖浏览器 UI 的路径输入入口,而不是这里的枚举原语。
|
|
112
|
+
- **全盘可浏览**——没有按部署限定的浏览根;`workspace.create` 接受任意路径,因此这里的根会限定 UX 范围,而不是安全边界。
|
|
113
|
+
|
|
114
|
+
<a id="dev-note"></a>
|
|
115
|
+
### 开发备注
|
|
116
|
+
|
|
117
|
+
<details>
|
|
118
|
+
<summary>维护者的工作上下文——点击展开</summary>
|
|
119
|
+
|
|
120
|
+
无。
|
|
121
|
+
|
|
122
|
+
</details>
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { mkdir, opendir, stat } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, join, posix, resolve, win32 } from "node:path";
|
|
4
|
+
import z from "@prettier-ai/schemastery";
|
|
5
|
+
import { DirectoryPicker, DirectoryPickerError } from "@prettier-ai/dsh-host-directory-picker";
|
|
6
|
+
//#region lib/types/index.js
|
|
7
|
+
/**
|
|
8
|
+
* Browse backend of the directory-picker seam: registers `ctx.directoryPicker`
|
|
9
|
+
* with the `browse` capability — one-level directory listing and child-directory
|
|
10
|
+
* creation over the host filesystem via Node's stdlib (which already carries
|
|
11
|
+
* the per-OS adaptation). Nothing renders on the host display, so this backend
|
|
12
|
+
* serves remote clients the dialog backend cannot. Policy decisions (hidden
|
|
13
|
+
* entries flagged but returned, symlinks followed, whole-filesystem scope) are
|
|
14
|
+
* recorded in the directory-picker seam Agent Note.
|
|
15
|
+
* @module @prettier-ai/dsh-host-directory-picker-browse
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Ancestor chain from the filesystem root to `target` inclusive — the
|
|
19
|
+
* breadcrumb rows of a listing, every one a jump target.
|
|
20
|
+
*/
|
|
21
|
+
function ancestryCrumbs(target) {
|
|
22
|
+
const crumbs = [];
|
|
23
|
+
let current = target;
|
|
24
|
+
for (;;) {
|
|
25
|
+
const parent = dirname(current);
|
|
26
|
+
crumbs.unshift({
|
|
27
|
+
name: parent === current ? current : basename(current),
|
|
28
|
+
path: current,
|
|
29
|
+
hidden: false
|
|
30
|
+
});
|
|
31
|
+
if (parent === current) return crumbs;
|
|
32
|
+
current = parent;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* True when the path names one fixed filesystem location regardless of
|
|
37
|
+
* process state: POSIX-absolute on POSIX; on Windows only drive-qualified
|
|
38
|
+
* (`C:\…`) or complete UNC (`\\server\share…`) forms. Rooted drive-less
|
|
39
|
+
* forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`)
|
|
40
|
+
* pass `isAbsolute` yet still resolve against the process's current drive.
|
|
41
|
+
* @param path - candidate path.
|
|
42
|
+
* @param platform - replaces `process.platform` for deterministic tests.
|
|
43
|
+
* @returns whether the path is fully qualified on the platform.
|
|
44
|
+
*/
|
|
45
|
+
function fullyQualified(path, platform = process.platform) {
|
|
46
|
+
return platform === "win32" ? win32.isAbsolute(path) && /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/]+[^\\/]+)/.test(path) : posix.isAbsolute(path);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Insert a streamed candidate into the name-sorted bounded window, evicting
|
|
50
|
+
* the name-largest candidate when the window exceeds `keep`. Memory over an
|
|
51
|
+
* arbitrarily large level therefore stays O(keep) regardless of how many
|
|
52
|
+
* children the directory holds.
|
|
53
|
+
* @param window - the name-ascending window, mutated in place.
|
|
54
|
+
* @param candidate - the streamed candidate to place.
|
|
55
|
+
* @param keep - the window bound.
|
|
56
|
+
* @returns true when an eviction happened (the level has candidates beyond the window).
|
|
57
|
+
*/
|
|
58
|
+
function boundedInsert(window, candidate, keep) {
|
|
59
|
+
if (window.length === keep && candidate.name.localeCompare(window[window.length - 1].name) >= 0) return true;
|
|
60
|
+
let lo = 0;
|
|
61
|
+
let hi = window.length;
|
|
62
|
+
while (lo < hi) {
|
|
63
|
+
const mid = lo + hi >>> 1;
|
|
64
|
+
if (candidate.name.localeCompare(window[mid].name) < 0) hi = mid;
|
|
65
|
+
else lo = mid + 1;
|
|
66
|
+
}
|
|
67
|
+
window.splice(lo, 0, candidate);
|
|
68
|
+
if (window.length <= keep) return false;
|
|
69
|
+
window.pop();
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Await `operation`, but reject with the signal's reason the moment it
|
|
74
|
+
* aborts. Node's filesystem reads are not retractable, so the operation
|
|
75
|
+
* itself keeps running against a handle the caller then closes — its late
|
|
76
|
+
* settlement is swallowed here so an abandoned read cannot surface as an
|
|
77
|
+
* unhandled rejection.
|
|
78
|
+
* @param operation - the in-flight filesystem step.
|
|
79
|
+
* @param signal - caller lifetime; absent means plain awaiting.
|
|
80
|
+
* @returns the operation's value.
|
|
81
|
+
*/
|
|
82
|
+
function raceAbort(operation, signal) {
|
|
83
|
+
if (signal === void 0) return operation;
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
const onAbort = () => {
|
|
86
|
+
operation.catch(() => {});
|
|
87
|
+
reject(asError(signal.reason));
|
|
88
|
+
};
|
|
89
|
+
if (signal.aborted) {
|
|
90
|
+
onAbort();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
94
|
+
operation.then((value) => {
|
|
95
|
+
signal.removeEventListener("abort", onAbort);
|
|
96
|
+
resolve(value);
|
|
97
|
+
}, (reason) => {
|
|
98
|
+
signal.removeEventListener("abort", onAbort);
|
|
99
|
+
reject(asError(reason));
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/** The thrown value as an Error (wire/abort reasons may be anything). */
|
|
104
|
+
function asError(reason) {
|
|
105
|
+
return reason instanceof Error ? reason : new Error(String(reason));
|
|
106
|
+
}
|
|
107
|
+
/* v8 ignore start -- a close failure of an abandoned handle has no consumer, and forcing one needs a filesystem torn down mid-request. */
|
|
108
|
+
/** Swallow the close failure of a handle its caller already departed. */
|
|
109
|
+
function swallowCloseFailure() {}
|
|
110
|
+
/* v8 ignore stop */
|
|
111
|
+
/** Message text of an unknown thrown value. */
|
|
112
|
+
function messageOf(error) {
|
|
113
|
+
/* v8 ignore next -- node:fs rejects with Error instances; the String arm only satisfies the unknown narrowing. */
|
|
114
|
+
return error instanceof Error ? error.message : String(error);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* One listing row for a dirent, following symlinks to directories; null for
|
|
118
|
+
* non-directories and broken/cyclic links (skipped silently — the browser
|
|
119
|
+
* shows what can be entered, and a broken link cannot).
|
|
120
|
+
*/
|
|
121
|
+
async function directoryRow(parent, name, isDirectory, isSymbolicLink, signal) {
|
|
122
|
+
const path = join(parent, name);
|
|
123
|
+
let enterable = isDirectory;
|
|
124
|
+
if (!enterable && isSymbolicLink) try {
|
|
125
|
+
enterable = (await raceAbort(stat(path), signal)).isDirectory();
|
|
126
|
+
} catch {
|
|
127
|
+
/* v8 ignore next 2 -- an abort landing mid-probe needs a stalled stat; the per-candidate check in list covers the settled path. */
|
|
128
|
+
if (signal?.aborted) throw asError(signal.reason);
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
if (!enterable) return null;
|
|
132
|
+
return {
|
|
133
|
+
name,
|
|
134
|
+
path,
|
|
135
|
+
hidden: name.startsWith(".")
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/** The `ctx.directoryPicker` browse implementation (stable capability object per service life). */
|
|
139
|
+
var BrowseDirectoryPicker = class extends DirectoryPicker {
|
|
140
|
+
config;
|
|
141
|
+
/**
|
|
142
|
+
* `maxEntries` bounds the complete listing level a single `list` call may
|
|
143
|
+
* materialize and put on the wire: at most this many child-directory rows
|
|
144
|
+
* (hidden rows included), with `truncated` flagging a cut level. The
|
|
145
|
+
* default follows GitHub's web UI, which truncates directory listings at
|
|
146
|
+
* 1,000 entries.
|
|
147
|
+
*/
|
|
148
|
+
static Config = z.object({ maxEntries: z.natural().min(1).default(1e3) });
|
|
149
|
+
browseCapability = {
|
|
150
|
+
kind: "browse",
|
|
151
|
+
list: (path, signal) => this.list(path, signal),
|
|
152
|
+
createDirectory: (path, name) => this.createDirectory(path, name)
|
|
153
|
+
};
|
|
154
|
+
constructor(ctx, config) {
|
|
155
|
+
super(ctx);
|
|
156
|
+
this.config = config;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The browse interaction capability.
|
|
160
|
+
* @returns the stable `browse` capability object.
|
|
161
|
+
*/
|
|
162
|
+
capability() {
|
|
163
|
+
return this.browseCapability;
|
|
164
|
+
}
|
|
165
|
+
async list(path, signal) {
|
|
166
|
+
const home = homedir();
|
|
167
|
+
if (path !== void 0 && !fullyQualified(path)) throw new DirectoryPickerError("directory-unreadable", path, `cannot list "${path}": not a fully qualified path`);
|
|
168
|
+
const target = resolve(path ?? home);
|
|
169
|
+
const keep = this.config.maxEntries + 1;
|
|
170
|
+
const window = [];
|
|
171
|
+
let evicted = false;
|
|
172
|
+
try {
|
|
173
|
+
const opening = opendir(target);
|
|
174
|
+
const level = await raceAbort(opening, signal).catch((error) => {
|
|
175
|
+
opening.then((dir) => dir.close().catch(swallowCloseFailure), () => {});
|
|
176
|
+
throw error;
|
|
177
|
+
});
|
|
178
|
+
try {
|
|
179
|
+
for (;;) {
|
|
180
|
+
const dirent = await raceAbort(level.read(), signal);
|
|
181
|
+
if (dirent === null) break;
|
|
182
|
+
if (!dirent.isDirectory() && !dirent.isSymbolicLink()) continue;
|
|
183
|
+
if (boundedInsert(window, {
|
|
184
|
+
name: dirent.name,
|
|
185
|
+
isDirectory: dirent.isDirectory(),
|
|
186
|
+
isSymbolicLink: dirent.isSymbolicLink()
|
|
187
|
+
}, keep)) evicted = true;
|
|
188
|
+
}
|
|
189
|
+
} finally {
|
|
190
|
+
const closing = level.close();
|
|
191
|
+
/* v8 ignore next 3 -- an abort between open and close needs a stalled read; the abandoned-close arm has no observable outcome. */
|
|
192
|
+
if (signal?.aborted) closing.catch(swallowCloseFailure);
|
|
193
|
+
else await closing;
|
|
194
|
+
}
|
|
195
|
+
} catch (error) {
|
|
196
|
+
signal?.throwIfAborted();
|
|
197
|
+
throw new DirectoryPickerError("directory-unreadable", target, `cannot list ${target}: ${messageOf(error)}`);
|
|
198
|
+
}
|
|
199
|
+
const entries = [];
|
|
200
|
+
let truncated = evicted;
|
|
201
|
+
for (const candidate of window) {
|
|
202
|
+
signal?.throwIfAborted();
|
|
203
|
+
const row = await directoryRow(target, candidate.name, candidate.isDirectory, candidate.isSymbolicLink, signal);
|
|
204
|
+
if (row === null) continue;
|
|
205
|
+
if (entries.length === this.config.maxEntries) {
|
|
206
|
+
truncated = true;
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
entries.push(row);
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
path: target,
|
|
213
|
+
home,
|
|
214
|
+
crumbs: ancestryCrumbs(target),
|
|
215
|
+
entries,
|
|
216
|
+
truncated
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
async createDirectory(path, name) {
|
|
220
|
+
if (!fullyQualified(path)) throw new DirectoryPickerError("directory-create-failed", path, `cannot create under "${path}": not a fully qualified parent path`);
|
|
221
|
+
const parent = resolve(path);
|
|
222
|
+
if (name.trim() === "" || name === "." || name === ".." || /[/\\]/.test(name)) throw new DirectoryPickerError("directory-create-failed", join(parent, name), `"${name}" is not a single path segment`);
|
|
223
|
+
const target = join(parent, name);
|
|
224
|
+
try {
|
|
225
|
+
await mkdir(target);
|
|
226
|
+
return target;
|
|
227
|
+
} catch (error) {
|
|
228
|
+
if (typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST") throw new DirectoryPickerError("directory-exists", target, `${target} already exists`);
|
|
229
|
+
throw new DirectoryPickerError("directory-create-failed", target, `cannot create ${target}: ${messageOf(error)}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
//#endregion
|
|
234
|
+
export { boundedInsert, BrowseDirectoryPicker as default, fullyQualified, raceAbort };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/**
|
|
3
|
+
* Package-owned invariant companion for the browse directory-picker backend.
|
|
4
|
+
* @module @prettier-ai/dsh-host-directory-picker-browse/invariant
|
|
5
|
+
*/
|
|
6
|
+
const PACKAGE_NAME = "@prettier-ai/dsh-host-directory-picker-browse";
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
const name = "host-directory-picker-browse-invariant";
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
const inject = ["invariants"];
|
|
11
|
+
/** No runtime invariant: each list/create is one stateless filesystem round trip; the filesystem itself is the authoritative state. */
|
|
12
|
+
const install = () => {};
|
|
13
|
+
/**
|
|
14
|
+
* Register the browse directory-picker invariant companion.
|
|
15
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
16
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
17
|
+
*/
|
|
18
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
19
|
+
//#endregion
|
|
20
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browse backend of the directory-picker seam: registers `ctx.directoryPicker`
|
|
3
|
+
* with the `browse` capability — one-level directory listing and child-directory
|
|
4
|
+
* creation over the host filesystem via Node's stdlib (which already carries
|
|
5
|
+
* the per-OS adaptation). Nothing renders on the host display, so this backend
|
|
6
|
+
* serves remote clients the dialog backend cannot. Policy decisions (hidden
|
|
7
|
+
* entries flagged but returned, symlinks followed, whole-filesystem scope) are
|
|
8
|
+
* recorded in the directory-picker seam Agent Note.
|
|
9
|
+
* @module @prettier-ai/dsh-host-directory-picker-browse
|
|
10
|
+
*/
|
|
11
|
+
import type { Context } from '@prettier-ai/cordis';
|
|
12
|
+
import z from '@prettier-ai/schemastery';
|
|
13
|
+
import { DirectoryPicker } from '@prettier-ai/dsh-host-directory-picker';
|
|
14
|
+
import type { DirectoryPickerCapability } from '@prettier-ai/dsh-host-directory-picker';
|
|
15
|
+
/**
|
|
16
|
+
* True when the path names one fixed filesystem location regardless of
|
|
17
|
+
* process state: POSIX-absolute on POSIX; on Windows only drive-qualified
|
|
18
|
+
* (`C:\…`) or complete UNC (`\\server\share…`) forms. Rooted drive-less
|
|
19
|
+
* forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`)
|
|
20
|
+
* pass `isAbsolute` yet still resolve against the process's current drive.
|
|
21
|
+
* @param path - candidate path.
|
|
22
|
+
* @param platform - replaces `process.platform` for deterministic tests.
|
|
23
|
+
* @returns whether the path is fully qualified on the platform.
|
|
24
|
+
*/
|
|
25
|
+
export declare function fullyQualified(path: string, platform?: NodeJS.Platform): boolean;
|
|
26
|
+
/** One streamed listing candidate: the dirent facts a row needs, nothing else retained. */
|
|
27
|
+
export interface ListingCandidate {
|
|
28
|
+
/** Base name within the streamed level. */
|
|
29
|
+
name: string;
|
|
30
|
+
/** Dirent says directory (no probe needed). */
|
|
31
|
+
isDirectory: boolean;
|
|
32
|
+
/** Dirent says symlink (enterability needs a stat probe). */
|
|
33
|
+
isSymbolicLink: boolean;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Insert a streamed candidate into the name-sorted bounded window, evicting
|
|
37
|
+
* the name-largest candidate when the window exceeds `keep`. Memory over an
|
|
38
|
+
* arbitrarily large level therefore stays O(keep) regardless of how many
|
|
39
|
+
* children the directory holds.
|
|
40
|
+
* @param window - the name-ascending window, mutated in place.
|
|
41
|
+
* @param candidate - the streamed candidate to place.
|
|
42
|
+
* @param keep - the window bound.
|
|
43
|
+
* @returns true when an eviction happened (the level has candidates beyond the window).
|
|
44
|
+
*/
|
|
45
|
+
export declare function boundedInsert(window: ListingCandidate[], candidate: ListingCandidate, keep: number): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Await `operation`, but reject with the signal's reason the moment it
|
|
48
|
+
* aborts. Node's filesystem reads are not retractable, so the operation
|
|
49
|
+
* itself keeps running against a handle the caller then closes — its late
|
|
50
|
+
* settlement is swallowed here so an abandoned read cannot surface as an
|
|
51
|
+
* unhandled rejection.
|
|
52
|
+
* @param operation - the in-flight filesystem step.
|
|
53
|
+
* @param signal - caller lifetime; absent means plain awaiting.
|
|
54
|
+
* @returns the operation's value.
|
|
55
|
+
*/
|
|
56
|
+
export declare function raceAbort<T>(operation: Promise<T>, signal: AbortSignal | undefined): Promise<T>;
|
|
57
|
+
/** Validated plugin configuration. */
|
|
58
|
+
export interface Config {
|
|
59
|
+
/** Complete-result bound of one listing level; see {@link BrowseDirectoryPicker.Config}. */
|
|
60
|
+
maxEntries: number;
|
|
61
|
+
}
|
|
62
|
+
/** The `ctx.directoryPicker` browse implementation (stable capability object per service life). */
|
|
63
|
+
export default class BrowseDirectoryPicker extends DirectoryPicker {
|
|
64
|
+
private readonly config;
|
|
65
|
+
/**
|
|
66
|
+
* `maxEntries` bounds the complete listing level a single `list` call may
|
|
67
|
+
* materialize and put on the wire: at most this many child-directory rows
|
|
68
|
+
* (hidden rows included), with `truncated` flagging a cut level. The
|
|
69
|
+
* default follows GitHub's web UI, which truncates directory listings at
|
|
70
|
+
* 1,000 entries.
|
|
71
|
+
*/
|
|
72
|
+
static Config: z<Config>;
|
|
73
|
+
private readonly browseCapability;
|
|
74
|
+
constructor(ctx: Context, config: Config);
|
|
75
|
+
/**
|
|
76
|
+
* The browse interaction capability.
|
|
77
|
+
* @returns the stable `browse` capability object.
|
|
78
|
+
*/
|
|
79
|
+
capability(): DirectoryPickerCapability;
|
|
80
|
+
private list;
|
|
81
|
+
private createDirectory;
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for the browse directory-picker backend.
|
|
3
|
+
* @module @prettier-ai/dsh-host-directory-picker-browse/invariant
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@prettier-ai/cordis';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export declare const name = "host-directory-picker-browse-invariant";
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register the browse directory-picker invariant companion.
|
|
12
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
13
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
14
|
+
*/
|
|
15
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
16
|
+
//# sourceMappingURL=invariant.d.ts.map
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@prettier-ai/dsh-host-directory-picker-browse",
|
|
3
|
+
"description": "In-app browsing backend of the directory-picker seam (listing/creation primitives over the host filesystem)",
|
|
4
|
+
"version": "0.1.2-alpha.1",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
|
11
|
+
"directory": "packages/host/directory-picker-browse"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "lib/index.js",
|
|
15
|
+
"types": "lib/types/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./lib/types/index.d.ts",
|
|
19
|
+
"default": "./lib/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./invariant": {
|
|
22
|
+
"types": "./lib/types/invariant.d.ts",
|
|
23
|
+
"default": "./lib/invariant.js"
|
|
24
|
+
},
|
|
25
|
+
"./src/*": "./src/*",
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"lib/index.js",
|
|
30
|
+
"lib/invariant.js",
|
|
31
|
+
"lib/types/**/*.d.ts"
|
|
32
|
+
],
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@prettier-ai/dsh-host-directory-picker": "^0.1.2-alpha.1",
|
|
36
|
+
"@prettier-ai/schemastery": "^3.18.1"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@prettier-ai/cordis": "^4.0.1",
|
|
40
|
+
"@prettier-ai/dsh-invariants": "^0.1.2-alpha.1"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@prettier-ai/cordis": "^4.0.1",
|
|
44
|
+
"@prettier-ai/dsh-invariants": "^0.1.2-alpha.1"
|
|
45
|
+
}
|
|
46
|
+
}
|