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 +265 -0
- package/package.json +54 -0
- package/scripts/build.ts +56 -0
- package/src/App.tsx +57 -0
- package/src/atom/auth.ts +55 -0
- package/src/atom/platform.ts +10 -0
- package/src/atom/secrets.ts +94 -0
- package/src/authMock.ts +11 -0
- package/src/cli.tsx +6 -0
- package/src/components/auth-gate.tsx +205 -0
- package/src/components/footer.tsx +37 -0
- package/src/components/header.tsx +39 -0
- package/src/components/nutstore-logo.tsx +56 -0
- package/src/components/secret-detail.tsx +198 -0
- package/src/components/secret-list.tsx +78 -0
- package/src/components/secrets-page.tsx +252 -0
- package/src/constants.ts +1 -0
- package/src/effect/add-secret.ts +94 -0
- package/src/effect/auth.ts +46 -0
- package/src/effect/delete-secret.ts +62 -0
- package/src/effect/list-secrets.ts +160 -0
- package/src/hooks/useThemeMode.ts +26 -0
- package/src/index.tsx +1 -0
- package/src/mockSecrets.ts +39 -0
- package/src/opentui-jsx.d.ts +94 -0
- package/src/theme.ts +49 -0
- package/src/types.ts +7 -0
- package/src/utils.ts +31 -0
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
|
+
}
|
package/scripts/build.ts
ADDED
|
@@ -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
|
+
}
|
package/src/atom/auth.ts
ADDED
|
@@ -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);
|
package/src/authMock.ts
ADDED
|
@@ -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
|
+
};
|