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.
@@ -0,0 +1,205 @@
1
+ import { useAtomSet } from "@effect/atom-solid";
2
+ import { TextAttributes } from "@opentui/core";
3
+ import { useKeyboard, useRenderer } from "@opentui/solid";
4
+ import { Match, Show, Switch, createSignal } from "solid-js";
5
+ import { saveCookieAtom } from "../atom/auth";
6
+ import type { OpenTUIElement } from "../opentui-jsx";
7
+ import type { Palette } from "../theme";
8
+ import { NutstoreLogo } from "./nutstore-logo";
9
+
10
+ export type AuthView = "choice" | "manual" | "validating";
11
+
12
+ type AuthGateProps = {
13
+ palette: Palette;
14
+ };
15
+
16
+ export function AuthGate(props: AuthGateProps): OpenTUIElement {
17
+ const renderer = useRenderer();
18
+ const saveCookie = useAtomSet(() => saveCookieAtom, { mode: "promise" });
19
+ const [view, setView] = createSignal<AuthView>("choice");
20
+ const [cookieInput, setCookieInput] = createSignal("");
21
+ const [error, setError] = createSignal<string | null>(null);
22
+
23
+ const persistCookie = async (cookie: string) => {
24
+ setView("validating");
25
+ setError(null);
26
+
27
+ try {
28
+ await saveCookie(cookie);
29
+ } catch (cause) {
30
+ setView("manual");
31
+ setError(cause instanceof Error ? cause.message : "Cookie validation failed. Paste a Cookie header to continue.");
32
+ }
33
+ };
34
+
35
+ const selectManualInput = () => {
36
+ setView("manual");
37
+ setError(null);
38
+ };
39
+
40
+ const submitChoice = () => {
41
+ selectManualInput();
42
+ };
43
+
44
+ const submitManualCookie = () => {
45
+ void persistCookie(cookieInput());
46
+ };
47
+
48
+ const isSubmitKey = (name: string) => name === "enter" || name === "return";
49
+
50
+ useKeyboard((key) => {
51
+ const name = key.name.toLowerCase();
52
+
53
+ if (name === "q") {
54
+ renderer.destroy();
55
+ return;
56
+ }
57
+
58
+ if (view() === "choice" && name === "m") {
59
+ selectManualInput();
60
+ key.preventDefault();
61
+ return;
62
+ }
63
+
64
+ if (view() === "choice" && isSubmitKey(name)) {
65
+ submitChoice();
66
+ key.preventDefault();
67
+ return;
68
+ }
69
+
70
+ if (view() === "manual" && isSubmitKey(name)) {
71
+ submitManualCookie();
72
+ key.preventDefault();
73
+ }
74
+ });
75
+
76
+ return (
77
+ <box flexDirection="column" flexGrow={1} paddingX={1} paddingY={1} gap={1}>
78
+ <box height={7} flexDirection="row" alignItems="center">
79
+ <box
80
+ width={40}
81
+ marginRight={2}
82
+ alignItems="center"
83
+ justifyContent="center"
84
+ >
85
+ <NutstoreLogo palette={props.palette} />
86
+ </box>
87
+ <box flexDirection="column" flexGrow={1} justifyContent="center">
88
+ <text attributes={TextAttributes.BOLD} fg={props.palette.fg}>
89
+ WebDAV Cookie Setup
90
+ </text>
91
+ <text fg={props.palette.muted} truncate>
92
+ Prepare access before loading app passwords
93
+ </text>
94
+ </box>
95
+ </box>
96
+
97
+ <box
98
+ border
99
+ borderColor={props.palette.border}
100
+ flexDirection="column"
101
+ flexGrow={1}
102
+ justifyContent="center"
103
+ paddingX={3}
104
+ paddingY={1}
105
+ title="Authentication"
106
+ >
107
+ <AuthBody
108
+ cookieInput={cookieInput()}
109
+ error={error()}
110
+ onCookieInput={setCookieInput}
111
+ onManualSubmit={submitManualCookie}
112
+ palette={props.palette}
113
+ view={view()}
114
+ />
115
+ </box>
116
+
117
+ <box height={1}>
118
+ <text fg={props.palette.muted}>
119
+ Enter continue M manual input Q quit
120
+ </text>
121
+ </box>
122
+ </box>
123
+ );
124
+ }
125
+
126
+ type AuthBodyProps = {
127
+ cookieInput: string;
128
+ error: string | null;
129
+ onCookieInput: (value: string) => void;
130
+ onManualSubmit: () => void;
131
+ palette: Palette;
132
+ view: AuthView;
133
+ };
134
+
135
+ function AuthBody(props: AuthBodyProps): OpenTUIElement {
136
+ return (
137
+ <Switch>
138
+ <Match when={props.view === "validating"}>
139
+ <box flexDirection="column" gap={1}>
140
+ <text attributes={TextAttributes.BOLD} fg={props.palette.fg}>
141
+ Validating cookie...
142
+ </text>
143
+ <text fg={props.palette.muted}>
144
+ Saving cookie and validating local session state.
145
+ </text>
146
+ </box>
147
+ </Match>
148
+
149
+ <Match when={props.view === "manual"}>
150
+ <box flexDirection="column" gap={1}>
151
+ <text attributes={TextAttributes.BOLD} fg={props.palette.fg}>
152
+ Paste Nutstore Cookie
153
+ </text>
154
+ <text fg={props.palette.muted}>
155
+ Paste the full Cookie header, then press Enter.
156
+ </text>
157
+ <input
158
+ cursorColor={props.palette.cursor}
159
+ focused
160
+ focusedTextColor={props.palette.fg}
161
+ onInput={props.onCookieInput}
162
+ onSubmit={props.onManualSubmit}
163
+ placeholder="nutstore_cookie=...; session=..."
164
+ placeholderColor={props.palette.muted}
165
+ textColor={props.palette.fg}
166
+ value={props.cookieInput}
167
+ />
168
+ <AuthError error={props.error} palette={props.palette} />
169
+ </box>
170
+ </Match>
171
+
172
+ <Match when={props.view === "choice"}>
173
+ <box flexDirection="column" gap={1}>
174
+ <text attributes={TextAttributes.BOLD} fg={props.palette.fg}>
175
+ No stored cookie found
176
+ </text>
177
+ <text fg={props.palette.muted}>
178
+ Manual input is the only available authentication method right now.
179
+ </text>
180
+ <text fg={props.palette.fg}>
181
+ Press Enter to paste a Cookie header manually.
182
+ </text>
183
+ <text fg={props.palette.muted}>
184
+ Automatic browser cookie detection is hidden until the experimental flow is ready.
185
+ </text>
186
+
187
+ <AuthError error={props.error} palette={props.palette} />
188
+ </box>
189
+ </Match>
190
+ </Switch>
191
+ );
192
+ }
193
+
194
+ function AuthError(props: {
195
+ error: string | null;
196
+ palette: Palette;
197
+ }): OpenTUIElement {
198
+ return (
199
+ <Show when={props.error}>
200
+ <text fg={props.palette.danger}>
201
+ {props.error}
202
+ </text>
203
+ </Show>
204
+ );
205
+ }
@@ -0,0 +1,37 @@
1
+ import { TextAttributes } from "@opentui/core";
2
+ import type { OpenTUIElement } from "../opentui-jsx";
3
+ import type { Palette } from "../theme";
4
+
5
+ export function Footer(props: { palette: Palette }): OpenTUIElement {
6
+ return (
7
+ <box height={1} flexDirection="row" flexWrap="wrap" columnGap={1}>
8
+ <FooterHint action="select" keyLabel="Up/Down" palette={props.palette} />
9
+ <FooterHint action="copy secret" keyLabel="Enter" palette={props.palette} />
10
+ <FooterHint action="copy URL" keyLabel="U" palette={props.palette} />
11
+ <FooterHint action="copy account" keyLabel="A" palette={props.palette} />
12
+ <FooterHint action="new secret" keyLabel="N" palette={props.palette} />
13
+ <FooterHint action="delete" keyLabel="D" palette={props.palette} />
14
+ <FooterHint action="refresh" keyLabel="R" palette={props.palette} />
15
+ <FooterHint action="quit" keyLabel="Q" palette={props.palette} />
16
+ </box>
17
+ );
18
+ }
19
+
20
+ function FooterHint(props: {
21
+ action: string;
22
+ keyLabel: string;
23
+ palette: Palette;
24
+ }): OpenTUIElement {
25
+ return (
26
+ <box flexDirection="row" marginRight={1}>
27
+ <text
28
+ attributes={TextAttributes.BOLD}
29
+ bg={props.palette.selectedBg}
30
+ fg={props.palette.selectedFg}
31
+ >
32
+ {" "}{props.keyLabel}{" "}
33
+ </text>
34
+ <text fg={props.palette.muted}> {props.action}</text>
35
+ </box>
36
+ );
37
+ }
@@ -0,0 +1,39 @@
1
+ import { TextAttributes, type ThemeMode } from "@opentui/core";
2
+ import type { OpenTUIElement } from "../opentui-jsx";
3
+ import type { Palette } from "../theme";
4
+ import { NutstoreLogo } from "./nutstore-logo";
5
+
6
+ type HeaderProps = {
7
+ count: number;
8
+ palette: Palette;
9
+ status: string;
10
+ themeMode: ThemeMode | null;
11
+ };
12
+
13
+ export function Header(props: HeaderProps): OpenTUIElement {
14
+ return (
15
+ <box height={7} flexDirection="row" alignItems="center">
16
+ <box
17
+ width={40}
18
+ marginRight={2}
19
+ alignItems="center"
20
+ justifyContent="center"
21
+ >
22
+ <NutstoreLogo palette={props.palette} />
23
+ </box>
24
+ <box
25
+ flexDirection="column"
26
+ flexGrow={1}
27
+ justifyContent="center"
28
+ rowGap={1}
29
+ >
30
+ <text attributes={TextAttributes.BOLD} fg={props.palette.fg}>
31
+ WebDAV App Passwords
32
+ </text>
33
+ <text fg={props.palette.muted} truncate>
34
+ {props.count} secrets | {props.themeMode ?? "light"} | {props.status}
35
+ </text>
36
+ </box>
37
+ </box>
38
+ );
39
+ }
@@ -0,0 +1,56 @@
1
+ import { TextAttributes } from "@opentui/core";
2
+ import type { OpenTUIElement } from "../opentui-jsx";
3
+ import type { Palette } from "../theme";
4
+
5
+ type NutstoreLogoProps = {
6
+ palette: Palette;
7
+ };
8
+
9
+ const MARK_LINES = [
10
+ [" ▗▄▄▄ ", "brand"],
11
+ [" ▟████▙ ", "brand"],
12
+ [" ███▀▀ ", "accent"],
13
+ [" ▜████▛ ", "brand"],
14
+ [" ▝▀▀▘ ", "muted"],
15
+ ] as const;
16
+
17
+ const WORDMARK_LINES = [
18
+ "NUTSTORE",
19
+ "Personal cloud,",
20
+ "without the noise",
21
+ ] as const;
22
+
23
+ export function NutstoreLogo(props: NutstoreLogoProps): OpenTUIElement {
24
+ return (
25
+ <box alignItems="center" flexDirection="row" gap={2}>
26
+ <box flexDirection="column">
27
+ {MARK_LINES.map(([line, tone]) => (
28
+ <text fg={toneColor(props.palette, tone)}>{line}</text>
29
+ ))}
30
+ </box>
31
+
32
+ <box flexDirection="column" justifyContent="center">
33
+ <text attributes={TextAttributes.BOLD} fg={props.palette.fg}>
34
+ {WORDMARK_LINES[0]}
35
+ </text>
36
+ <text fg={props.palette.muted}>{WORDMARK_LINES[1]}</text>
37
+ <text fg={props.palette.muted}>{WORDMARK_LINES[2]}</text>
38
+ </box>
39
+ </box>
40
+ );
41
+ }
42
+
43
+ function toneColor(
44
+ palette: Palette,
45
+ tone: "brand" | "accent" | "muted",
46
+ ): string {
47
+ if (tone === "muted") {
48
+ return palette.muted;
49
+ }
50
+
51
+ if (tone === "accent") {
52
+ return palette.brandLogo[3] ?? palette.fg;
53
+ }
54
+
55
+ return palette.brandLogo[1] ?? palette.fg;
56
+ }
@@ -0,0 +1,198 @@
1
+ import { TextAttributes } from "@opentui/core";
2
+ import { Show } from "solid-js";
3
+ import { WEBDAV_URL } from "../constants";
4
+ import type { OpenTUIElement } from "../opentui-jsx";
5
+ import type { Palette } from "../theme";
6
+ import type { SecretItem } from "../types";
7
+ import { truncate } from "../utils";
8
+
9
+ type SecretDetailProps = {
10
+ addName: string;
11
+ addSecretMode: boolean;
12
+ addError: string | null;
13
+ addingSecret: boolean;
14
+ confirmingDelete: boolean;
15
+ item: SecretItem | null;
16
+ onAddNameInput: (value: string) => void;
17
+ onAddSubmit: () => void;
18
+ palette: Palette;
19
+ };
20
+
21
+ export function SecretDetail(props: SecretDetailProps): OpenTUIElement {
22
+ return (
23
+ <box
24
+ border
25
+ borderColor={props.confirmingDelete ? props.palette.danger : props.palette.border}
26
+ flexDirection="column"
27
+ flexGrow={1}
28
+ paddingX={2}
29
+ paddingY={1}
30
+ title={props.addSecretMode ? "Add Secret" : props.confirmingDelete ? "Confirm Delete" : "Detail"}
31
+ >
32
+ <Show when={props.addSecretMode}>
33
+ <AddSecretContent
34
+ addError={props.addError}
35
+ addName={props.addName}
36
+ addingSecret={props.addingSecret}
37
+ onAddNameInput={props.onAddNameInput}
38
+ onAddSubmit={props.onAddSubmit}
39
+ palette={props.palette}
40
+ />
41
+ </Show>
42
+ <Show when={!props.addSecretMode}>
43
+ <Show
44
+ when={props.item !== null}
45
+ fallback={
46
+ <box flexGrow={1} alignItems="center" justifyContent="center">
47
+ <text fg={props.palette.muted}>
48
+ Select an app password to inspect
49
+ </text>
50
+ </box>
51
+ }
52
+ >
53
+ <SecretDetailContent
54
+ confirmingDelete={props.confirmingDelete}
55
+ item={props.item as SecretItem}
56
+ palette={props.palette}
57
+ />
58
+ </Show>
59
+ </Show>
60
+ </box>
61
+ );
62
+ }
63
+
64
+ function AddSecretContent(props: {
65
+ addError: string | null;
66
+ addName: string;
67
+ addingSecret: boolean;
68
+ onAddNameInput: (value: string) => void;
69
+ onAddSubmit: () => void;
70
+ palette: Palette;
71
+ }): OpenTUIElement {
72
+ return (
73
+ <box flexDirection="column" gap={1}>
74
+ <text attributes={TextAttributes.BOLD} fg={props.palette.fg}>
75
+ Create App Password
76
+ </text>
77
+ <text fg={props.palette.muted}>
78
+ Enter a name, then press Enter to create a new app password.
79
+ </text>
80
+ <input
81
+ cursorColor={props.palette.cursor}
82
+ focused
83
+ focusedTextColor={props.palette.fg}
84
+ onInput={props.onAddNameInput}
85
+ onSubmit={props.onAddSubmit}
86
+ placeholder="NICE-APP-PASSWORD"
87
+ placeholderColor={props.palette.muted}
88
+ textColor={props.palette.fg}
89
+ value={props.addName}
90
+ />
91
+ <Show when={props.addingSecret}>
92
+ <text fg={props.palette.muted}>Creating app password...</text>
93
+ </Show>
94
+ <Show when={props.addError}>
95
+ <text fg={props.palette.danger}>{props.addError}</text>
96
+ </Show>
97
+ <box flexDirection="row" flexWrap="wrap" columnGap={1}>
98
+ <ShortcutHint action="create secret" keyLabel="Enter" palette={props.palette} />
99
+ <ShortcutHint action="cancel" keyLabel="Esc" palette={props.palette} />
100
+ </box>
101
+ </box>
102
+ );
103
+ }
104
+
105
+ function SecretDetailContent(props: {
106
+ confirmingDelete: boolean;
107
+ item: SecretItem;
108
+ palette: Palette;
109
+ }): OpenTUIElement {
110
+ return (
111
+ <box flexDirection="column" gap={1}>
112
+ <text attributes={TextAttributes.BOLD} fg={props.palette.fg} truncate>
113
+ {truncate(props.item.name, 36)}
114
+ </text>
115
+
116
+ <box flexDirection="column">
117
+ <DetailRow
118
+ label="WebDAV"
119
+ maxLength={27}
120
+ palette={props.palette}
121
+ value={WEBDAV_URL}
122
+ shouldTruncate={false}
123
+ />
124
+ <DetailRow
125
+ label="Account"
126
+ palette={props.palette}
127
+ value={props.item.account ?? "Unavailable"}
128
+ shouldTruncate={false}
129
+ />
130
+ <DetailRow
131
+ label="Password"
132
+ palette={props.palette}
133
+ value={props.item.password}
134
+ />
135
+ <DetailRow
136
+ label="Created"
137
+ palette={props.palette}
138
+ value={props.item.createdAt}
139
+ />
140
+ </box>
141
+
142
+ <Show
143
+ when={props.confirmingDelete}
144
+ fallback={
145
+ <box flexDirection="row" flexWrap="wrap" columnGap={1}>
146
+ <ShortcutHint action="copy secret" keyLabel="Enter" palette={props.palette} />
147
+ <ShortcutHint action="copy URL" keyLabel="U" palette={props.palette} />
148
+ <ShortcutHint action="copy account" keyLabel="A" palette={props.palette} />
149
+ <ShortcutHint action="new secret" keyLabel="N" palette={props.palette} />
150
+ <ShortcutHint action="delete" keyLabel="D" palette={props.palette} />
151
+ </box>
152
+ }
153
+ >
154
+ <box border borderColor={props.palette.danger} paddingX={1}>
155
+ <text fg={props.palette.danger}>Press Y to delete, Esc to cancel</text>
156
+ </box>
157
+ </Show>
158
+ </box>
159
+ );
160
+ }
161
+
162
+ function DetailRow(props: {
163
+ label: string;
164
+ palette: Palette;
165
+ value: string;
166
+ maxLength?: number;
167
+ shouldTruncate?: boolean;
168
+ }): OpenTUIElement {
169
+ return (
170
+ <box flexDirection="row">
171
+ <text width={9} fg={props.palette.muted}>
172
+ {props.label}
173
+ </text>
174
+ <text flexGrow={1} fg={props.palette.fg} truncate={props.shouldTruncate !== false}>
175
+ {props.shouldTruncate !== false ? truncate(props.value, props.maxLength ?? 29) : props.value}
176
+ </text>
177
+ </box>
178
+ );
179
+ }
180
+
181
+ function ShortcutHint(props: {
182
+ action: string;
183
+ keyLabel: string;
184
+ palette: Palette;
185
+ }): OpenTUIElement {
186
+ return (
187
+ <box flexDirection="row" marginRight={1}>
188
+ <text
189
+ attributes={TextAttributes.BOLD}
190
+ bg={props.palette.selectedBg}
191
+ fg={props.palette.selectedFg}
192
+ >
193
+ {" "}{props.keyLabel}{" "}
194
+ </text>
195
+ <text fg={props.palette.muted}> {props.action}</text>
196
+ </box>
197
+ );
198
+ }
@@ -0,0 +1,78 @@
1
+ import { useAtomValue } from "@effect/atom-solid";
2
+ import { For, Match, Switch } from "solid-js";
3
+ import { secretsListAtom, secretsLoadStateAtom, secretsStatusMessageAtom } from "../atom/secrets";
4
+ import type { OpenTUIElement } from "../opentui-jsx";
5
+ import type { Palette } from "../theme";
6
+ import { maskPassword, truncate } from "../utils";
7
+
8
+ type SecretListProps = {
9
+ palette: Palette;
10
+ selectedIndex: number;
11
+ };
12
+
13
+ export function SecretList(props: SecretListProps): OpenTUIElement {
14
+ const items = useAtomValue(() => secretsListAtom);
15
+ const loadState = useAtomValue(() => secretsLoadStateAtom);
16
+ const statusMessage = useAtomValue(() => secretsStatusMessageAtom);
17
+
18
+ return (
19
+ <box
20
+ border
21
+ borderColor={props.palette.border}
22
+ flexDirection="column"
23
+ flexShrink={0}
24
+ paddingX={1}
25
+ title="Secrets"
26
+ width={44}
27
+ >
28
+ <Switch
29
+ fallback={
30
+ <For each={items()}>
31
+ {(item, index) => {
32
+ const selected = () => index() === props.selectedIndex;
33
+ const prefix = () => String(index() + 1).padStart(2, "0");
34
+
35
+ return (
36
+ <box flexDirection="row" justifyContent="space-between">
37
+ <text
38
+ bg={selected() ? props.palette.selectedBg : undefined}
39
+ fg={selected() ? props.palette.selectedFg : props.palette.fg}
40
+ flexGrow={1}
41
+ truncate
42
+ >
43
+ {prefix()} {truncate(item.name.padEnd(18), 18)}{" "}
44
+
45
+ </text>
46
+ <text
47
+ truncate>
48
+ {maskPassword(item.password).padStart(12)}
49
+ </text>
50
+ </box>
51
+
52
+ );
53
+ }}
54
+ </For>
55
+ }
56
+ >
57
+ <Match when={loadState() === "loading"}>
58
+ <box flexGrow={1} alignItems="center" justifyContent="center">
59
+ <text fg={props.palette.muted}>Loading app passwords...</text>
60
+ </box>
61
+ </Match>
62
+ <Match when={loadState() === "error"}>
63
+ <box flexDirection="column" flexGrow={1} justifyContent="center" paddingX={1}>
64
+ <text fg={props.palette.muted}>Failed to load app passwords</text>
65
+ <text fg={props.palette.muted} truncate>
66
+ {statusMessage()}
67
+ </text>
68
+ </box>
69
+ </Match>
70
+ <Match when={loadState() === "empty"}>
71
+ <box flexGrow={1} alignItems="center" justifyContent="center">
72
+ <text fg={props.palette.muted}>No app passwords</text>
73
+ </box>
74
+ </Match>
75
+ </Switch>
76
+ </box>
77
+ );
78
+ }