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
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-solid";
|
|
2
|
+
import type { ThemeMode } from "@opentui/core";
|
|
3
|
+
import { useKeyboard, useRenderer } from "@opentui/solid";
|
|
4
|
+
import { createMemo, createSignal } from "solid-js";
|
|
5
|
+
import { addSecretAtom, deleteSecretAtom, secretsListAtom, secretsLoadStateAtom, secretsPageInfoAtom, secretsStatusMessageAtom } from "../atom/secrets";
|
|
6
|
+
import { WEBDAV_URL } from "../constants";
|
|
7
|
+
import type { OpenTUIElement } from "../opentui-jsx";
|
|
8
|
+
import type { Palette } from "../theme";
|
|
9
|
+
import { clamp, copyToClipboard } from "../utils";
|
|
10
|
+
import { Footer } from "./footer";
|
|
11
|
+
import { Header } from "./header";
|
|
12
|
+
import { SecretDetail } from "./secret-detail";
|
|
13
|
+
import { SecretList } from "./secret-list";
|
|
14
|
+
|
|
15
|
+
type SecretsPageProps = {
|
|
16
|
+
palette: Palette;
|
|
17
|
+
themeMode: ThemeMode | null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function SecretsPage(props: SecretsPageProps): OpenTUIElement {
|
|
21
|
+
const renderer = useRenderer();
|
|
22
|
+
const items = useAtomValue(() => secretsListAtom);
|
|
23
|
+
const secretsLoadState = useAtomValue(() => secretsLoadStateAtom);
|
|
24
|
+
const remoteStatusMessage = useAtomValue(() => secretsStatusMessageAtom);
|
|
25
|
+
const refreshSecrets = useAtomRefresh(() => secretsPageInfoAtom);
|
|
26
|
+
const createSecret = useAtomSet(() => addSecretAtom, { mode: "promise" });
|
|
27
|
+
const revokeSecret = useAtomSet(() => deleteSecretAtom, { mode: "promise" });
|
|
28
|
+
const [addSecretMode, setAddSecretMode] = createSignal(false);
|
|
29
|
+
const [addSecretName, setAddSecretName] = createSignal("");
|
|
30
|
+
const [addSecretError, setAddSecretError] = createSignal<string | null>(null);
|
|
31
|
+
const [addingSecret, setAddingSecret] = createSignal(false);
|
|
32
|
+
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
|
33
|
+
const [statusMessage, setStatusMessage] = createSignal("");
|
|
34
|
+
const [confirmingDelete, setConfirmingDelete] = createSignal(false);
|
|
35
|
+
|
|
36
|
+
const selectedItem = createMemo(() => items()[selectedIndex()] ?? null);
|
|
37
|
+
const headerStatus = createMemo(() =>
|
|
38
|
+
statusMessage().length > 0 ? statusMessage() : remoteStatusMessage(),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const moveSelection = (direction: number) => {
|
|
42
|
+
if (addSecretMode()) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
setConfirmingDelete(false);
|
|
46
|
+
setSelectedIndex((current) =>
|
|
47
|
+
clamp(current + direction, 0, Math.max(0, items().length - 1)),
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const refresh = (nextSelectedIndex = 0) => {
|
|
52
|
+
setAddSecretMode(false);
|
|
53
|
+
setAddSecretError(null);
|
|
54
|
+
setConfirmingDelete(false);
|
|
55
|
+
setSelectedIndex(nextSelectedIndex);
|
|
56
|
+
setStatusMessage("");
|
|
57
|
+
refreshSecrets();
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const copyValue = async (label: string, value: string) => {
|
|
61
|
+
const copied = await copyToClipboard(value);
|
|
62
|
+
setStatusMessage(copied ? `${label} copied` : `${label} ready to copy`);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const deleteSelected = async () => {
|
|
66
|
+
if (addSecretMode()) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const item = selectedItem();
|
|
70
|
+
const currentIndex = selectedIndex();
|
|
71
|
+
if (!item) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!confirmingDelete()) {
|
|
76
|
+
setConfirmingDelete(true);
|
|
77
|
+
setStatusMessage(`Confirm delete: ${item.name}`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
await revokeSecret(item.name);
|
|
83
|
+
const nextSelectedIndex = clamp(
|
|
84
|
+
Math.min(currentIndex, Math.max(0, items().length - 2)),
|
|
85
|
+
0,
|
|
86
|
+
Math.max(0, items().length - 2),
|
|
87
|
+
);
|
|
88
|
+
setConfirmingDelete(false);
|
|
89
|
+
setStatusMessage(`Deleted ${item.name}`);
|
|
90
|
+
refresh(nextSelectedIndex);
|
|
91
|
+
} catch (cause) {
|
|
92
|
+
setConfirmingDelete(false);
|
|
93
|
+
setSelectedIndex(currentIndex);
|
|
94
|
+
setStatusMessage(
|
|
95
|
+
cause instanceof Error ? cause.message : `Failed to delete ${item.name}`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const openAddSecret = () => {
|
|
101
|
+
setConfirmingDelete(false);
|
|
102
|
+
setAddSecretError(null);
|
|
103
|
+
setAddSecretName("");
|
|
104
|
+
setAddSecretMode(true);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const cancelAddSecret = () => {
|
|
108
|
+
setAddSecretMode(false);
|
|
109
|
+
setAddSecretError(null);
|
|
110
|
+
setAddingSecret(false);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const submitAddSecret = async () => {
|
|
114
|
+
setAddingSecret(true);
|
|
115
|
+
setAddSecretError(null);
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const created = await createSecret(addSecretName());
|
|
119
|
+
setAddSecretMode(false);
|
|
120
|
+
setAddSecretName("");
|
|
121
|
+
setStatusMessage(`Created ${created.name}`);
|
|
122
|
+
refresh();
|
|
123
|
+
} catch (cause) {
|
|
124
|
+
setAddSecretError(
|
|
125
|
+
cause instanceof Error ? cause.message : "Failed to create app password.",
|
|
126
|
+
);
|
|
127
|
+
} finally {
|
|
128
|
+
setAddingSecret(false);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
useKeyboard((key) => {
|
|
133
|
+
const name = key.name.toLowerCase();
|
|
134
|
+
|
|
135
|
+
if (name === "q") {
|
|
136
|
+
renderer.destroy();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (addSecretMode()) {
|
|
141
|
+
if (name === "escape") {
|
|
142
|
+
cancelAddSecret();
|
|
143
|
+
key.preventDefault();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (name === "up") {
|
|
150
|
+
moveSelection(-1);
|
|
151
|
+
key.preventDefault();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (name === "down") {
|
|
156
|
+
moveSelection(1);
|
|
157
|
+
key.preventDefault();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (name === "escape") {
|
|
162
|
+
if (addSecretMode()) {
|
|
163
|
+
cancelAddSecret();
|
|
164
|
+
key.preventDefault();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
setConfirmingDelete(false);
|
|
169
|
+
setStatusMessage("Delete cancelled");
|
|
170
|
+
key.preventDefault();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (name === "r") {
|
|
175
|
+
refresh();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (name === "n") {
|
|
180
|
+
openAddSecret();
|
|
181
|
+
key.preventDefault();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (name === "d") {
|
|
186
|
+
void deleteSelected();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (name === "y" && confirmingDelete()) {
|
|
191
|
+
void deleteSelected();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const item = selectedItem();
|
|
196
|
+
if (!item || secretsLoadState() !== "ready") {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (name === "enter") {
|
|
201
|
+
void copyValue("Password", item.password);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (name === "u") {
|
|
206
|
+
void copyValue("WebDAV URL", WEBDAV_URL);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (name === "a") {
|
|
211
|
+
if (!item.account) {
|
|
212
|
+
setStatusMessage("Account unavailable");
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
void copyValue("Account", item.account);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<box flexDirection="column" flexGrow={1} paddingX={1} paddingY={1} gap={1}>
|
|
222
|
+
<Header
|
|
223
|
+
count={items().length}
|
|
224
|
+
palette={props.palette}
|
|
225
|
+
status={headerStatus()}
|
|
226
|
+
themeMode={props.themeMode}
|
|
227
|
+
/>
|
|
228
|
+
|
|
229
|
+
<box flexDirection="row" flexGrow={1} gap={1} minHeight={14}>
|
|
230
|
+
<SecretList
|
|
231
|
+
palette={props.palette}
|
|
232
|
+
selectedIndex={selectedIndex()}
|
|
233
|
+
/>
|
|
234
|
+
<SecretDetail
|
|
235
|
+
addError={addSecretError()}
|
|
236
|
+
addName={addSecretName()}
|
|
237
|
+
addSecretMode={addSecretMode()}
|
|
238
|
+
addingSecret={addingSecret()}
|
|
239
|
+
confirmingDelete={confirmingDelete()}
|
|
240
|
+
item={selectedItem()}
|
|
241
|
+
onAddNameInput={setAddSecretName}
|
|
242
|
+
onAddSubmit={() => {
|
|
243
|
+
void submitAddSecret();
|
|
244
|
+
}}
|
|
245
|
+
palette={props.palette}
|
|
246
|
+
/>
|
|
247
|
+
</box>
|
|
248
|
+
|
|
249
|
+
<Footer palette={props.palette} />
|
|
250
|
+
</box>
|
|
251
|
+
);
|
|
252
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const WEBDAV_URL = "https://dav.jianguoyun.com/dav/";
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { Data, Effect, Schema } from "effect";
|
|
2
|
+
import { FetchHttpClient, HttpBody, HttpClient, HttpClientResponse, UrlParams } from "effect/unstable/http";
|
|
3
|
+
import type { SecretItem } from "../types";
|
|
4
|
+
|
|
5
|
+
const GENERATE_ASP_URL = "https://www.jianguoyun.com/d/ajax/userop/generateAsp";
|
|
6
|
+
|
|
7
|
+
export class AddSecretHttpError extends Data.TaggedError("AddSecretHttpError")<{
|
|
8
|
+
readonly cause: unknown;
|
|
9
|
+
readonly message: string;
|
|
10
|
+
}> { }
|
|
11
|
+
|
|
12
|
+
export class AddSecretParseError extends Data.TaggedError("AddSecretParseError")<{
|
|
13
|
+
readonly message: string;
|
|
14
|
+
}> { }
|
|
15
|
+
|
|
16
|
+
const AddSecretResponseSchema = Schema.Struct({
|
|
17
|
+
credential: Schema.String,
|
|
18
|
+
creationTime: Schema.String,
|
|
19
|
+
name: Schema.String,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export type AddSecretResponse = Schema.Schema.Type<typeof AddSecretResponseSchema>;
|
|
23
|
+
|
|
24
|
+
export const addSecret = (
|
|
25
|
+
cookie: string,
|
|
26
|
+
name: string,
|
|
27
|
+
): Effect.Effect<SecretItem, AddSecretHttpError | AddSecretParseError> =>
|
|
28
|
+
Effect.gen(function* () {
|
|
29
|
+
const normalizedName = name.trim();
|
|
30
|
+
|
|
31
|
+
if (normalizedName.length === 0) {
|
|
32
|
+
return yield* Effect.fail(
|
|
33
|
+
new AddSecretParseError({
|
|
34
|
+
message: "Secret name cannot be empty.",
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const client = yield* HttpClient.HttpClient;
|
|
40
|
+
const response = yield* client.post(GENERATE_ASP_URL, {
|
|
41
|
+
headers: {
|
|
42
|
+
Accept: "application/json, text/javascript, */*; q=0.01",
|
|
43
|
+
Cookie: cookie,
|
|
44
|
+
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
45
|
+
Origin: "https://www.jianguoyun.com",
|
|
46
|
+
Referer: "https://www.jianguoyun.com/d/mobile_asp",
|
|
47
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
48
|
+
},
|
|
49
|
+
body: HttpBody.urlParams(
|
|
50
|
+
UrlParams.fromInput({
|
|
51
|
+
asp_name: normalizedName,
|
|
52
|
+
}),
|
|
53
|
+
),
|
|
54
|
+
}).pipe(
|
|
55
|
+
Effect.mapError((cause) =>
|
|
56
|
+
new AddSecretHttpError({
|
|
57
|
+
cause,
|
|
58
|
+
message: "Failed to request Nutstore generateAsp endpoint.",
|
|
59
|
+
}),
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
if (response.status < 200 || response.status >= 300) {
|
|
64
|
+
return yield* Effect.fail(
|
|
65
|
+
new AddSecretHttpError({
|
|
66
|
+
cause: response.status,
|
|
67
|
+
message: `generateAsp returned ${response.status}.`,
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const record = yield* HttpClientResponse.schemaBodyJson(AddSecretResponseSchema)(
|
|
73
|
+
response,
|
|
74
|
+
).pipe(
|
|
75
|
+
Effect.mapError((cause) =>
|
|
76
|
+
new AddSecretParseError({
|
|
77
|
+
message: "generateAsp response is not a valid ASP record.",
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
return toSecretItem(record);
|
|
83
|
+
}).pipe(Effect.provide(FetchHttpClient.layer));
|
|
84
|
+
|
|
85
|
+
function toSecretItem(record: AddSecretResponse): SecretItem {
|
|
86
|
+
const normalizedName = record.name.trim() || "Untitled App Password";
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
id: `secret_${record.creationTime}_${normalizedName}`,
|
|
90
|
+
name: normalizedName,
|
|
91
|
+
password: record.credential,
|
|
92
|
+
createdAt: record.creationTime,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Effect, Data } from 'effect'
|
|
2
|
+
import { FileSystem } from 'effect/FileSystem'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
|
|
6
|
+
export class ConfigFileError extends Data.TaggedError('ConfigFileError')<{
|
|
7
|
+
readonly cause: unknown
|
|
8
|
+
readonly message: string
|
|
9
|
+
}> { }
|
|
10
|
+
|
|
11
|
+
export class CookieNotFoundError extends Data.TaggedError('CookieNotFoundError') { }
|
|
12
|
+
|
|
13
|
+
const resolveConfigPaths = Effect.sync(function () {
|
|
14
|
+
const configDir = path.join(os.homedir(), '.config', 'nswds')
|
|
15
|
+
const configFile = path.join(configDir, 'cookie')
|
|
16
|
+
|
|
17
|
+
return { configDir, configFile }
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
export const readCookie = Effect.gen(function* () {
|
|
21
|
+
const { configDir, configFile } = yield* resolveConfigPaths
|
|
22
|
+
const fs = yield* FileSystem
|
|
23
|
+
const fileContent = yield* fs.readFileString(configFile).pipe(
|
|
24
|
+
Effect.catchTag("PlatformError", err => {
|
|
25
|
+
return new CookieNotFoundError()
|
|
26
|
+
})
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
if (!fileContent || !fileContent.trim()) {
|
|
30
|
+
return yield* Effect.fail(new CookieNotFoundError())
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return fileContent
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
export const saveCookie = Effect.fnUntraced(function* (cookie: string) {
|
|
37
|
+
const { configDir, configFile } = yield* resolveConfigPaths
|
|
38
|
+
const fs = yield* FileSystem
|
|
39
|
+
|
|
40
|
+
yield* Effect.gen((function* () {
|
|
41
|
+
yield* fs.makeDirectory(configDir, { recursive: true })
|
|
42
|
+
yield* fs.writeFileString(configFile, cookie)
|
|
43
|
+
})).pipe(
|
|
44
|
+
Effect.mapError(systemError => new ConfigFileError({ cause: systemError, message: "write file failed" }))
|
|
45
|
+
)
|
|
46
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Data, Effect } from "effect";
|
|
2
|
+
import { FetchHttpClient, HttpBody, HttpClient, UrlParams } from "effect/unstable/http";
|
|
3
|
+
|
|
4
|
+
const REVOKE_ASP_URL = "https://www.jianguoyun.com/d/ajax/userop/revokeAsp";
|
|
5
|
+
|
|
6
|
+
export class DeleteSecretHttpError extends Data.TaggedError("DeleteSecretHttpError")<{
|
|
7
|
+
readonly cause: unknown;
|
|
8
|
+
readonly message: string;
|
|
9
|
+
}> { }
|
|
10
|
+
|
|
11
|
+
export class DeleteSecretValidationError extends Data.TaggedError("DeleteSecretValidationError")<{
|
|
12
|
+
readonly message: string;
|
|
13
|
+
}> { }
|
|
14
|
+
|
|
15
|
+
export const deleteSecret = (
|
|
16
|
+
cookie: string,
|
|
17
|
+
name: string,
|
|
18
|
+
): Effect.Effect<void, DeleteSecretHttpError | DeleteSecretValidationError> =>
|
|
19
|
+
Effect.gen(function* () {
|
|
20
|
+
const normalizedName = name.trim();
|
|
21
|
+
|
|
22
|
+
if (normalizedName.length === 0) {
|
|
23
|
+
return yield* Effect.fail(
|
|
24
|
+
new DeleteSecretValidationError({
|
|
25
|
+
message: "Secret name cannot be empty.",
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const client = yield* HttpClient.HttpClient;
|
|
31
|
+
const response = yield* client.post(REVOKE_ASP_URL, {
|
|
32
|
+
headers: {
|
|
33
|
+
Accept: "*/*",
|
|
34
|
+
Cookie: cookie,
|
|
35
|
+
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
36
|
+
Origin: "https://www.jianguoyun.com",
|
|
37
|
+
Referer: "https://www.jianguoyun.com/d/mobile_asp",
|
|
38
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
39
|
+
},
|
|
40
|
+
body: HttpBody.urlParams(
|
|
41
|
+
UrlParams.fromInput({
|
|
42
|
+
asp_name: normalizedName,
|
|
43
|
+
}),
|
|
44
|
+
),
|
|
45
|
+
}).pipe(
|
|
46
|
+
Effect.mapError((cause) =>
|
|
47
|
+
new DeleteSecretHttpError({
|
|
48
|
+
cause,
|
|
49
|
+
message: "Failed to request Nutstore revokeAsp endpoint.",
|
|
50
|
+
}),
|
|
51
|
+
),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
if (response.status < 200 || response.status >= 300) {
|
|
55
|
+
return yield* Effect.fail(
|
|
56
|
+
new DeleteSecretHttpError({
|
|
57
|
+
cause: response.status,
|
|
58
|
+
message: `revokeAsp returned ${response.status}.`,
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}).pipe(Effect.provide(FetchHttpClient.layer));
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { Data, Effect } from "effect";
|
|
2
|
+
import { FetchHttpClient, HttpClient } from "effect/unstable/http";
|
|
3
|
+
import type { SecretItem } from "../types";
|
|
4
|
+
|
|
5
|
+
const MOBILE_ASP_URL = "https://www.jianguoyun.com/d/mobile_asp";
|
|
6
|
+
const USER_ASPS_PATTERN = /userAspsStr\s*:\s*'((?:\\'|[^'])*)'/;
|
|
7
|
+
|
|
8
|
+
export class MobileAspHttpError extends Data.TaggedError("MobileAspHttpError")<{
|
|
9
|
+
readonly cause: unknown;
|
|
10
|
+
readonly message: string;
|
|
11
|
+
}> { }
|
|
12
|
+
|
|
13
|
+
export class MobileAspParseError extends Data.TaggedError("MobileAspParseError")<{
|
|
14
|
+
readonly message: string;
|
|
15
|
+
}> { }
|
|
16
|
+
|
|
17
|
+
export type MobileAspRecord = {
|
|
18
|
+
credential: string;
|
|
19
|
+
creationTime: string;
|
|
20
|
+
name: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const parseSecretsHtml = (html: string): SecretItem[] => {
|
|
24
|
+
const userAspsMatch = html.match(USER_ASPS_PATTERN);
|
|
25
|
+
|
|
26
|
+
if (!userAspsMatch) {
|
|
27
|
+
throw new MobileAspParseError({
|
|
28
|
+
message: "Could not extract userAspsStr from mobile_asp HTML.",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const userAspsLiteral = userAspsMatch[1];
|
|
33
|
+
if (userAspsLiteral === undefined) {
|
|
34
|
+
throw new MobileAspParseError({
|
|
35
|
+
message: "userAspsStr capture group was empty.",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const rawUserAsps = decodeJsSingleQuotedString(userAspsLiteral);
|
|
40
|
+
const records = parseMobileAspRecords(rawUserAsps);
|
|
41
|
+
const account = parseAccountFromHtml(html);
|
|
42
|
+
|
|
43
|
+
return records.map((record) => toSecretItem(record, account));
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const querySecretsList = (
|
|
47
|
+
cookie: string,
|
|
48
|
+
): Effect.Effect<SecretItem[], MobileAspHttpError | MobileAspParseError> =>
|
|
49
|
+
Effect.gen(function* () {
|
|
50
|
+
const client = yield* HttpClient.HttpClient;
|
|
51
|
+
const response = yield* client.get(MOBILE_ASP_URL, {
|
|
52
|
+
headers: {
|
|
53
|
+
Cookie: cookie,
|
|
54
|
+
Accept: "text/html,application/xhtml+xml",
|
|
55
|
+
},
|
|
56
|
+
}).pipe(
|
|
57
|
+
Effect.mapError((cause) =>
|
|
58
|
+
new MobileAspHttpError({
|
|
59
|
+
cause,
|
|
60
|
+
message: "Failed to fetch Nutstore mobile_asp page.",
|
|
61
|
+
}),
|
|
62
|
+
),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const html = yield* response.text.pipe(
|
|
66
|
+
Effect.mapError((cause) =>
|
|
67
|
+
new MobileAspHttpError({
|
|
68
|
+
cause,
|
|
69
|
+
message: "Failed to read Nutstore mobile_asp response body.",
|
|
70
|
+
}),
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
if (response.status < 200 || response.status >= 300) {
|
|
75
|
+
return yield* Effect.fail(
|
|
76
|
+
new MobileAspHttpError({
|
|
77
|
+
cause: response.status,
|
|
78
|
+
message: `mobile_asp returned ${response.status}: ${formatHtmlSnippet(html)}`,
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return yield* Effect.try({
|
|
84
|
+
try: () => parseSecretsHtml(html),
|
|
85
|
+
catch: (cause) =>
|
|
86
|
+
cause instanceof MobileAspParseError
|
|
87
|
+
? cause
|
|
88
|
+
: new MobileAspParseError({
|
|
89
|
+
message: `Failed to parse Nutstore mobile_asp HTML: ${formatHtmlSnippet(html)}`,
|
|
90
|
+
}),
|
|
91
|
+
});
|
|
92
|
+
}).pipe(Effect.provide(FetchHttpClient.layer));
|
|
93
|
+
|
|
94
|
+
function decodeJsSingleQuotedString(value: string): string {
|
|
95
|
+
return value
|
|
96
|
+
.replace(/\\\\/g, "\\")
|
|
97
|
+
.replace(/\\'/g, "'");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function formatHtmlSnippet(html: string): string {
|
|
101
|
+
return html
|
|
102
|
+
.replace(/\s+/g, " ")
|
|
103
|
+
.trim()
|
|
104
|
+
.slice(0, 160);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseAccountFromHtml(html: string): string | undefined {
|
|
108
|
+
const patterns = [
|
|
109
|
+
/(?:account|username|userName|loginName|email)\s*[:=]\s*["']([^"'@\s]+@[^"'\s]+)["']/i,
|
|
110
|
+
/(?:用户名|账号|邮箱)[^<]{0,20}[::]\s*([^<\s]+@[^<\s]+)/i,
|
|
111
|
+
/value=["']([^"'@\s]+@[^"'\s]+)["']/i,
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
for (const pattern of patterns) {
|
|
115
|
+
const matched = html.match(pattern)?.[1]?.trim();
|
|
116
|
+
if (matched) {
|
|
117
|
+
return matched;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return html.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i)?.[0];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function parseMobileAspRecords(value: string): MobileAspRecord[] {
|
|
125
|
+
const parsed = JSON.parse(value) as unknown;
|
|
126
|
+
|
|
127
|
+
if (!Array.isArray(parsed)) {
|
|
128
|
+
throw new MobileAspParseError({
|
|
129
|
+
message: "userAspsStr is not a JSON array.",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return parsed.map((item, index) => {
|
|
134
|
+
if (
|
|
135
|
+
typeof item !== "object" ||
|
|
136
|
+
item === null ||
|
|
137
|
+
typeof item.credential !== "string" ||
|
|
138
|
+
typeof item.creationTime !== "string" ||
|
|
139
|
+
typeof item.name !== "string"
|
|
140
|
+
) {
|
|
141
|
+
throw new MobileAspParseError({
|
|
142
|
+
message: `Invalid ASP record at index ${index}.`,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return item;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function toSecretItem(record: MobileAspRecord, account?: string): SecretItem {
|
|
151
|
+
const normalizedName = record.name.trim() || "Untitled App Password";
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
account,
|
|
155
|
+
id: `secret_${record.creationTime}_${normalizedName}`,
|
|
156
|
+
name: normalizedName,
|
|
157
|
+
password: record.credential,
|
|
158
|
+
createdAt: record.creationTime,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { CliRenderEvents, type ThemeMode } from "@opentui/core";
|
|
2
|
+
import { useRenderer } from "@opentui/solid";
|
|
3
|
+
import { createSignal, onCleanup, onMount } from "solid-js";
|
|
4
|
+
|
|
5
|
+
export function useThemeMode() {
|
|
6
|
+
const renderer = useRenderer();
|
|
7
|
+
const [themeMode, setThemeMode] = createSignal<ThemeMode | null>(
|
|
8
|
+
renderer.themeMode,
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
onMount(() => {
|
|
12
|
+
const updateThemeMode = (mode: ThemeMode) => setThemeMode(mode);
|
|
13
|
+
renderer.on(CliRenderEvents.THEME_MODE, updateThemeMode);
|
|
14
|
+
void renderer.waitForThemeMode(500).then((mode) => {
|
|
15
|
+
if (mode) {
|
|
16
|
+
setThemeMode(mode);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
onCleanup(() => {
|
|
21
|
+
renderer.off(CliRenderEvents.THEME_MODE, updateThemeMode);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
return themeMode;
|
|
26
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "./cli";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { SecretItem } from "./types";
|
|
2
|
+
|
|
3
|
+
export const initialSecrets: SecretItem[] = [
|
|
4
|
+
{
|
|
5
|
+
id: "sec_raycast",
|
|
6
|
+
account: "user@example.com",
|
|
7
|
+
name: "Raycast",
|
|
8
|
+
password: "nsc_app_9vK3pQ7Lm2Xz",
|
|
9
|
+
createdAt: "2026-05-28",
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
id: "sec_obsidian",
|
|
13
|
+
account: "user@example.com",
|
|
14
|
+
name: "Obsidian Sync Backup",
|
|
15
|
+
password: "nsc_app_B8mN4tYw6QaR",
|
|
16
|
+
createdAt: "2026-04-12",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: "sec_alfred",
|
|
20
|
+
account: "user@example.com",
|
|
21
|
+
name: "Alfred Workflow",
|
|
22
|
+
password: "nsc_app_U2hK7dPq5VzE",
|
|
23
|
+
createdAt: "2026-03-09",
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: "sec_backup",
|
|
27
|
+
account: "user@example.com",
|
|
28
|
+
name: "Server Backup",
|
|
29
|
+
password: "nsc_app_H4xT8rLm1CyN",
|
|
30
|
+
createdAt: "2026-01-21",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: "sec_mobile",
|
|
34
|
+
account: "user@example.com",
|
|
35
|
+
name: "Mobile WebDAV",
|
|
36
|
+
password: "nsc_app_M6qP2zLf8TaK",
|
|
37
|
+
createdAt: "2025-12-18",
|
|
38
|
+
},
|
|
39
|
+
];
|