nutstore-webdav-secret-cli 0.1.0

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/README.md ADDED
@@ -0,0 +1,265 @@
1
+ # Nutstore WebDAV Secret CLI
2
+
3
+ 一个基于 Bun、Solid 和 OpenTUI 的终端工具,用来查看和管理坚果云 WebDAV 第三方应用密码。
4
+
5
+ ## 功能
6
+
7
+ - 读取并缓存本地 Cookie
8
+ - 拉取 `mobile_asp` 页面并解析已有 app passwords
9
+ - 创建新的 app password
10
+ - 删除已有 app password
11
+ - 复制:
12
+ - secret
13
+ - WebDAV URL
14
+ - account
15
+
16
+ ## 环境
17
+
18
+ - Bun `1.3+`
19
+
20
+ 安装依赖:
21
+
22
+ ```bash
23
+ bun install
24
+ ```
25
+
26
+ ## npm 安装
27
+
28
+ 这个项目现在按“源码版 Bun CLI”发布到 npm。
29
+
30
+ 安装:
31
+
32
+ ```bash
33
+ npm i -g nutstore-webdav-secret-cli
34
+ ```
35
+
36
+ 安装后命令是:
37
+
38
+ ```bash
39
+ nswds
40
+ ```
41
+
42
+ 注意:
43
+
44
+ - 机器上仍然需要先安装 Bun
45
+ - npm 包当前分发的是源码入口,不是跨平台预编译二进制
46
+ - 本地单文件二进制仍然走下面的构建流程
47
+
48
+ ## 运行
49
+
50
+ 开发模式:
51
+
52
+ ```bash
53
+ bun run dev
54
+ ```
55
+
56
+ 调试模式:
57
+
58
+ ```bash
59
+ bun run debug
60
+ ```
61
+
62
+ `debug` 脚本会:
63
+
64
+ - 关闭 alternate screen
65
+ - 打开 OpenTUI console overlay
66
+ - 更方便查看日志和错误
67
+
68
+ ## 本地发布
69
+
70
+ 这个项目现在支持本地编译成单文件 CLI 可执行物。
71
+
72
+ 构建走的是:
73
+
74
+ - `Bun.build(...)`
75
+ - `@opentui/solid/bun-plugin`
76
+ - `compile.target` 根据当前本机平台自动推断
77
+
78
+ 类型检查:
79
+
80
+ ```bash
81
+ bun run typecheck
82
+ ```
83
+
84
+ 构建本地二进制:
85
+
86
+ ```bash
87
+ bun run build
88
+ ```
89
+
90
+ 产物路径:
91
+
92
+ ```bash
93
+ ./dist/nswds
94
+ ```
95
+
96
+ 运行已构建二进制:
97
+
98
+ ```bash
99
+ bun run run:dist
100
+ ```
101
+
102
+ 本地发布流程:
103
+
104
+ ```bash
105
+ bun run release:local
106
+ ```
107
+
108
+ 它会执行:
109
+
110
+ - `bun run typecheck`
111
+ - `bun run build`
112
+
113
+ 如果你想手动指定目标平台,可以覆盖:
114
+
115
+ ```bash
116
+ BUILD_TARGET=bun-darwin-arm64 bun run build
117
+ BUILD_TARGET=bun-linux-x64 bun run build
118
+ ```
119
+
120
+ ## npm 发布
121
+
122
+ 发布前检查:
123
+
124
+ ```bash
125
+ bun run typecheck
126
+ bun run pack:check
127
+ ```
128
+
129
+ 发布:
130
+
131
+ ```bash
132
+ bun publish
133
+ ```
134
+
135
+ 如果你用 npm:
136
+
137
+ ```bash
138
+ npm publish --access public
139
+ ```
140
+
141
+ 当前 `package.json` 已经配置:
142
+
143
+ - `bin.nswds -> ./src/cli.tsx`
144
+ - `publishConfig.access = public`
145
+ - `prepublishOnly` 会先跑类型检查和 `npm pack --dry-run`
146
+
147
+ ## GitHub 发布流
148
+
149
+ 仓库现在按 `Changesets + GitHub Actions + npm trusted publishing` 设计。
150
+
151
+ 日常流程:
152
+
153
+ 1. 开功能分支
154
+ 2. 改代码
155
+ 3. 运行 `bun run changeset`
156
+ 4. 提交 PR
157
+ 5. `CI` workflow 会跑类型检查、二进制构建和 `npm pack --dry-run`
158
+ 6. PR 合并到 `main`
159
+ 7. `Release` workflow 会自动创建或更新一个 release PR
160
+ 8. 合并这个 release PR
161
+ 9. workflow 自动发布 npm
162
+
163
+ 也就是说:
164
+
165
+ - 普通功能 PR 不会直接发 npm
166
+ - 只有 release PR 合并后才真正发布
167
+ - 版本号和 changelog 由 changesets 自动维护
168
+
169
+ ### 在 npm 侧你需要做什么
170
+
171
+ 1. 登录 npm
172
+ 2. 进入这个包的设置页
173
+ 3. 打开 `Trusted Publishers`
174
+ 4. 添加一个 GitHub Actions publisher
175
+
176
+ 建议填写:
177
+
178
+ - Owner: 你的 GitHub 用户名或组织名
179
+ - Repository: `nutstore-webdav-secret-cli`
180
+ - Workflow file: `release.yml`
181
+ - Environment: 留空
182
+
183
+ 配置完成后,GitHub Action 就可以通过 OIDC 直接发布,不需要单独保存 `NPM_TOKEN`。
184
+
185
+ ### 在 GitHub 侧你需要做什么
186
+
187
+ 1. 把默认分支确认成 `main`
188
+ 2. 打开仓库 `Settings -> Actions -> General`
189
+ 3. 确保允许工作流读写 Pull Requests 和 Contents
190
+ 4. 如果你启用了分支保护,允许 `GITHUB_TOKEN` 创建 release PR
191
+
192
+ ### 创建 changeset
193
+
194
+ ```bash
195
+ bun run changeset
196
+ ```
197
+
198
+ 常用选择:
199
+
200
+ - `patch`: 修 bug、文案调整、小优化
201
+ - `minor`: 新功能
202
+ - `major`: 破坏性变更
203
+
204
+ ## Cookie
205
+
206
+ 首次启动如果没有本地 Cookie,会进入手动输入流程。
207
+
208
+ Cookie 会保存到:
209
+
210
+ ```bash
211
+ ~/.config/nswds/cookie
212
+ ```
213
+
214
+ 这里保存的是完整的 `Cookie` header 内容。
215
+
216
+ ## 快捷键
217
+
218
+ 列表页:
219
+
220
+ - `Up/Down` 选择 secret
221
+ - `Enter` 复制当前 secret
222
+ - `U` 复制 WebDAV URL
223
+ - `A` 复制 account
224
+ - `N` 新增 secret
225
+ - `D` 删除当前 secret
226
+ - `R` 刷新列表
227
+ - `Q` 退出
228
+
229
+ 新增 secret:
230
+
231
+ - `Enter` 提交创建
232
+ - `Esc` 取消
233
+
234
+ 删除 secret:
235
+
236
+ - `D` 进入确认
237
+ - `Y` 确认删除
238
+ - `Esc` 取消
239
+
240
+ ## 请求接口
241
+
242
+ 当前已经接上的接口:
243
+
244
+ - 列表:`https://www.jianguoyun.com/d/mobile_asp`
245
+ - 创建:`https://www.jianguoyun.com/d/ajax/userop/generateAsp`
246
+ - 删除:`https://www.jianguoyun.com/d/ajax/userop/revokeAsp`
247
+
248
+ 创建和删除接口都按浏览器同源 AJAX 请求的方式发送:
249
+
250
+ - `application/x-www-form-urlencoded`
251
+ - `X-Requested-With: XMLHttpRequest`
252
+ - `Origin: https://www.jianguoyun.com`
253
+ - `Referer: https://www.jianguoyun.com/d/mobile_asp`
254
+
255
+ 如果创建或删除返回 `403`,优先检查:
256
+
257
+ - 本地保存的 Cookie 是否完整
258
+ - Cookie 是否仍然有效
259
+ - 是否包含接口依赖的关键字段
260
+
261
+ ## 当前限制
262
+
263
+ - account 目前是从返回 HTML 里做启发式提取,不是严格结构化字段
264
+ - 自动检测浏览器 Cookie 入口还没有启用
265
+ - 删除和创建成功后依赖刷新列表同步状态
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "nutstore-webdav-secret-cli",
3
+ "version": "0.1.0",
4
+ "description": "Terminal UI for managing Nutstore WebDAV app passwords.",
5
+ "type": "module",
6
+ "private": false,
7
+ "bin": {
8
+ "nswds": "./src/cli.tsx"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "scripts",
13
+ "README.md"
14
+ ],
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "engines": {
19
+ "bun": ">=1.3.0"
20
+ },
21
+ "scripts": {
22
+ "dev": "bun run --watch src/cli.tsx",
23
+ "debug": "OTUI_USE_ALTERNATE_SCREEN=false OTUI_USE_CONSOLE=true SHOW_CONSOLE=true bun run --watch src/cli.tsx",
24
+ "typecheck": "bunx tsc --noEmit --pretty false",
25
+ "changeset": "changeset",
26
+ "version-packages": "changeset version",
27
+ "build": "mkdir -p dist && bun run ./scripts/build.ts",
28
+ "run:dist": "./dist/nswds",
29
+ "release:local": "bun run typecheck && bun run build",
30
+ "pack:check": "mkdir -p .cache/npm && npm_config_cache=./.cache/npm npm pack --dry-run",
31
+ "release:npm": "npm publish --access public",
32
+ "prepublishOnly": "bun run typecheck && bun run pack:check"
33
+ },
34
+ "devDependencies": {
35
+ "@changesets/cli": "^2.31.0",
36
+ "@types/bun": "latest"
37
+ },
38
+ "peerDependencies": {
39
+ "typescript": "^5"
40
+ },
41
+ "dependencies": {
42
+ "@babel/core": "^7.29.0",
43
+ "@babel/preset-typescript": "^7.28.5",
44
+ "@effect/atom-solid": "^4.0.0-beta.78",
45
+ "@effect/platform-bun": "4.0.0-beta.78",
46
+ "@effect/platform-node": "4.0.0-beta.78",
47
+ "@opentui/core": "^0.3.2",
48
+ "@opentui/solid": "^0.3.2",
49
+ "add": "^2.0.6",
50
+ "babel-preset-solid": "1.9.12",
51
+ "effect": "^4.0.0-beta.78",
52
+ "solid-js": "1.9.12"
53
+ }
54
+ }
@@ -0,0 +1,56 @@
1
+ import solidPlugin from "@opentui/solid/bun-plugin";
2
+
3
+ const APP_NAME = "nswds";
4
+
5
+ type BuildTarget =
6
+ | "bun-darwin-arm64"
7
+ | "bun-darwin-x64"
8
+ | "bun-linux-arm64"
9
+ | "bun-linux-x64"
10
+ | "bun-windows-arm64"
11
+ | "bun-windows-x64";
12
+
13
+ const target = (process.env.BUILD_TARGET as BuildTarget | undefined) ?? inferBuildTarget();
14
+ const outfile = process.env.BUILD_OUTFILE ?? `dist/${APP_NAME}`;
15
+
16
+ await Bun.build({
17
+ entrypoints: ["./src/cli.tsx"],
18
+ plugins: [solidPlugin],
19
+ compile: {
20
+ target,
21
+ outfile,
22
+ },
23
+ });
24
+
25
+ console.log(`Built ${outfile} for ${target}`);
26
+
27
+ function inferBuildTarget(): BuildTarget {
28
+ const platform = process.platform;
29
+ const arch = process.arch;
30
+
31
+ if (platform === "darwin" && arch === "arm64") {
32
+ return "bun-darwin-arm64";
33
+ }
34
+
35
+ if (platform === "darwin" && arch === "x64") {
36
+ return "bun-darwin-x64";
37
+ }
38
+
39
+ if (platform === "linux" && arch === "x64") {
40
+ return "bun-linux-x64";
41
+ }
42
+
43
+ if (platform === "linux" && arch === "arm64") {
44
+ return "bun-linux-arm64";
45
+ }
46
+
47
+ if (platform === "win32" && arch === "x64") {
48
+ return "bun-windows-x64";
49
+ }
50
+
51
+ if (platform === "win32" && arch === "arm64") {
52
+ return "bun-windows-arm64";
53
+ }
54
+
55
+ throw new Error(`Unsupported build target for ${platform}-${arch}`);
56
+ }
package/src/App.tsx ADDED
@@ -0,0 +1,57 @@
1
+ import { useAtomValue } from "@effect/atom-solid";
2
+ import { Match, Switch, createMemo } from "solid-js";
3
+ import { AuthGate } from "./components/auth-gate";
4
+ import { SecretsPage } from "./components/secrets-page";
5
+ import { authStateAtom, cookieAtom } from "./atom/auth";
6
+ import { useThemeMode } from "./hooks/useThemeMode";
7
+ import type { OpenTUIElement } from "./opentui-jsx";
8
+ import { getPalette } from "./theme";
9
+
10
+ function AuthChecking(props: {
11
+ cookie: string | null;
12
+ muted: string;
13
+ fg: string;
14
+ }): OpenTUIElement {
15
+ return (
16
+ <box
17
+ alignItems="center"
18
+ flexDirection="column"
19
+ flexGrow={1}
20
+ gap={1}
21
+ justifyContent="center"
22
+ paddingX={2}
23
+ >
24
+ <text fg={props.fg}>Checking stored cookie...</text>
25
+ <text fg={props.muted}>
26
+ {props.cookie ? "Refreshing existing session..." : "Loading authentication state..."}
27
+ </text>
28
+ </box>
29
+ );
30
+ }
31
+
32
+ export function App(): OpenTUIElement {
33
+ const themeMode = useThemeMode();
34
+ const palette = createMemo(() => getPalette(themeMode()));
35
+ const authState = useAtomValue(() => authStateAtom);
36
+ const cookie = useAtomValue(() => cookieAtom);
37
+
38
+ return (
39
+ <Switch>
40
+ <Match when={authState() === "authenticated"}>
41
+ <SecretsPage palette={palette()} themeMode={themeMode()} />
42
+ </Match>
43
+
44
+ <Match when={authState() === "checking"}>
45
+ <AuthChecking
46
+ cookie={cookie()}
47
+ fg={palette().fg}
48
+ muted={palette().muted}
49
+ />
50
+ </Match>
51
+
52
+ <Match when={true}>
53
+ <AuthGate palette={palette()} />
54
+ </Match>
55
+ </Switch>
56
+ );
57
+ }
@@ -0,0 +1,55 @@
1
+ import { Data } from "effect";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Atom from "effect/unstable/reactivity/Atom";
4
+ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5
+ import { ConfigFileError, readCookie, saveCookie } from "../effect/auth";
6
+ import { runtimeAtom } from "./platform";
7
+
8
+ export type AuthState = "checking" | "anonymous" | "authenticated";
9
+ export type SaveCookieError = InvalidCookieError | ConfigFileError;
10
+
11
+ class InvalidCookieError extends Data.TaggedError("InvalidCookieError")<{
12
+ readonly message: string;
13
+ }> {}
14
+
15
+ export const storedCookieAtom = runtimeAtom.atom(
16
+ readCookie.pipe(Effect.map((cookie) => cookie.trim())),
17
+ ).pipe(Atom.keepAlive);
18
+
19
+ export const saveCookieAtom = runtimeAtom.fn((cookie: string) => {
20
+ const normalized = cookie.trim();
21
+
22
+ if (normalized.length === 0) {
23
+ return Effect.fail<SaveCookieError>(
24
+ new InvalidCookieError({
25
+ message: "Cookie is empty. Paste a Cookie header to continue.",
26
+ }),
27
+ );
28
+ }
29
+
30
+ return saveCookie(normalized).pipe(
31
+ Effect.as(normalized),
32
+ Effect.mapError((error): SaveCookieError => error),
33
+ );
34
+ }).pipe(Atom.keepAlive);
35
+
36
+ export const authStateAtom = Atom.make((get): AuthState => {
37
+ const storedResult = get(storedCookieAtom);
38
+ const saveResult = get(saveCookieAtom);
39
+
40
+ if (AsyncResult.isInitial(storedResult) || storedResult.waiting) {
41
+ return "checking";
42
+ }
43
+
44
+ const cookie = AsyncResult.getOrElse(saveResult, () =>
45
+ AsyncResult.getOrElse(storedResult, () => null as string | null),
46
+ );
47
+
48
+ return cookie && cookie.trim().length > 0 ? "authenticated" : "anonymous";
49
+ }).pipe(Atom.keepAlive);
50
+
51
+ export const cookieAtom = Atom.make((get) =>
52
+ AsyncResult.getOrElse(get(saveCookieAtom), () =>
53
+ AsyncResult.getOrElse(get(storedCookieAtom), () => null as string | null),
54
+ ),
55
+ ).pipe(Atom.keepAlive);
@@ -0,0 +1,10 @@
1
+ import { BunServices } from "@effect/platform-bun";
2
+ import { NodeServices } from "@effect/platform-node";
3
+ import * as Atom from "effect/unstable/reactivity/Atom";
4
+
5
+ const platformLayer =
6
+ typeof Bun !== "undefined"
7
+ ? BunServices.layer
8
+ : NodeServices.layer;
9
+
10
+ export const runtimeAtom = Atom.runtime(platformLayer).pipe(Atom.keepAlive);
@@ -0,0 +1,94 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Option from "effect/Option";
3
+ import * as Atom from "effect/unstable/reactivity/Atom";
4
+ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5
+ import { AddSecretHttpError, AddSecretParseError, addSecret } from "../effect/add-secret";
6
+ import { DeleteSecretHttpError, DeleteSecretValidationError, deleteSecret } from "../effect/delete-secret";
7
+ import { MobileAspHttpError, MobileAspParseError, querySecretsList } from "../effect/list-secrets";
8
+ import type { SecretItem } from "../types";
9
+ import { cookieAtom } from "./auth";
10
+ import { runtimeAtom } from "./platform";
11
+
12
+ export type SecretsLoadState = "loading" | "ready" | "empty" | "error";
13
+ type SecretsError = MobileAspHttpError | MobileAspParseError;
14
+ export type AddSecretError = MissingCookieError | AddSecretHttpError | AddSecretParseError;
15
+ export type DeleteSecretError = MissingCookieError | DeleteSecretHttpError | DeleteSecretValidationError;
16
+
17
+ const EMPTY_SECRETS: SecretItem[] = [];
18
+
19
+ class MissingCookieError extends Error {
20
+ constructor() {
21
+ super("Cookie unavailable. Re-authenticate before creating an app password.");
22
+ }
23
+ }
24
+
25
+ export const secretsPageInfoAtom: Atom.Atom<
26
+ AsyncResult.AsyncResult<SecretItem[], SecretsError>
27
+ > = runtimeAtom.atom((get): Effect.Effect<SecretItem[], SecretsError> => {
28
+ const cookie = get(cookieAtom);
29
+
30
+ if (!cookie) {
31
+ return Effect.succeed(EMPTY_SECRETS);
32
+ }
33
+
34
+ return querySecretsList(cookie);
35
+ }).pipe(Atom.keepAlive);
36
+
37
+ export const secretsListAtom = Atom.make((get) =>
38
+ AsyncResult.getOrElse(get(secretsPageInfoAtom), () => EMPTY_SECRETS),
39
+ ).pipe(Atom.keepAlive);
40
+
41
+ export const secretsLoadStateAtom = Atom.make((get): SecretsLoadState => {
42
+ const result = get(secretsPageInfoAtom);
43
+
44
+ if (AsyncResult.isInitial(result) || result.waiting) {
45
+ return "loading";
46
+ }
47
+
48
+ if (AsyncResult.isFailure(result)) {
49
+ return "error";
50
+ }
51
+
52
+ return result.value.length > 0 ? "ready" : "empty";
53
+ }).pipe(Atom.keepAlive);
54
+
55
+ export const secretsStatusMessageAtom = Atom.make((get): string => {
56
+ const result = get(secretsPageInfoAtom);
57
+
58
+ if (AsyncResult.isInitial(result) || result.waiting) {
59
+ return "Loading app passwords...";
60
+ }
61
+
62
+ if (AsyncResult.isFailure(result)) {
63
+ return Option.match(AsyncResult.error(result), {
64
+ onNone: () => "Failed to load app passwords",
65
+ onSome: (error) => error.message,
66
+ });
67
+ }
68
+
69
+ return `Loaded ${result.value.length} app passwords`;
70
+ }).pipe(Atom.keepAlive);
71
+
72
+ export const addSecretAtom = runtimeAtom.fn((name: string, get) => {
73
+ const cookie = get(cookieAtom);
74
+
75
+ if (!cookie) {
76
+ return Effect.fail<AddSecretError>(new MissingCookieError());
77
+ }
78
+
79
+ return addSecret(cookie, name).pipe(
80
+ Effect.mapError((error): AddSecretError => error),
81
+ );
82
+ }).pipe(Atom.keepAlive);
83
+
84
+ export const deleteSecretAtom = runtimeAtom.fn((name: string, get) => {
85
+ const cookie = get(cookieAtom);
86
+
87
+ if (!cookie) {
88
+ return Effect.fail<DeleteSecretError>(new MissingCookieError());
89
+ }
90
+
91
+ return deleteSecret(cookie, name).pipe(
92
+ Effect.mapError((error): DeleteSecretError => error),
93
+ );
94
+ }).pipe(Atom.keepAlive);
@@ -0,0 +1,11 @@
1
+ export const mockStoredCookie = async () => {
2
+ return null;
3
+ };
4
+
5
+ export const mockAutoDetectCookie = async () => {
6
+ return "nutstore_mock_cookie=auto-detected; session=mock";
7
+ };
8
+
9
+ export const mockValidateCookie = async (cookie: string) => {
10
+ return cookie.trim().length > 0;
11
+ };
package/src/cli.tsx ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { render } from "@opentui/solid";
4
+ import { App } from "./App";
5
+
6
+ render(() => <App />);