formseal-fetch 2.0.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 grayguava
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.
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: formseal-fetch
3
+ Version: 2.0.0
4
+ Summary: CLI tool for fetching encrypted form submissions
5
+ License: MIT License
6
+
7
+ Copyright (c) 2026 grayguava
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+
27
+ Requires-Python: >=3.8
28
+ Description-Content-Type: text/markdown
29
+ License-File: LICENSE
30
+ Requires-Dist: keyring>=25.0
31
+ Dynamic: license-file
32
+
33
+ # formseal-fetch
34
+
35
+ <p align="center">
36
+ <img src="https://img.shields.io/badge/python-3.8+-3776ab?style=flat&labelColor=1e293b">
37
+ <img src="https://img.shields.io/badge/license-MIT-fc8181?style=flat&labelColor=1e293b">
38
+ <img src="https://img.shields.io/badge/formseal-ecosystem-10b981?style=flat&labelColor=1e293b">
39
+ </p>
40
+
41
+ Download encrypted form submissions from your storage backend for offline decryption.
42
+
43
+ ## What it does
44
+
45
+ ```
46
+ Browser (formseal-embed)
47
+
48
+ ▼ (encrypted submissions)
49
+ Storage (Cloudflare KV / Supabase / ...)
50
+
51
+ ▼ (fsf fetch)
52
+ Your PC ──► ciphertexts.jsonl
53
+
54
+ ▼ (decrypt offline)
55
+ Plaintext form data
56
+ ```
57
+
58
+ ## Install
59
+
60
+ ```bash
61
+ pip install formseal-fetch
62
+ ```
63
+
64
+ ## Quick start
65
+
66
+ ```bash
67
+ fsf connect provider:<name>
68
+ fsf fetch
69
+ fsf status
70
+ ```
71
+
72
+ ## Features
73
+
74
+ - **Secure storage** : Credentials stored in OS keychain (Windows Credential Manager / macOS Keychain / Linux Secret Service)
75
+ - **Deduplication** : Skips already-downloaded ciphertexts automatically
76
+ - **Offline-capable** ; Decrypt downloaded data anytime without network access
77
+
78
+ ## Commands
79
+
80
+ | Command | Description |
81
+ |---------|-------------|
82
+ | `fsf connect` | Connect to a storage provider |
83
+ | `fsf fetch` | Download ciphertexts |
84
+ | `fsf status` | Show connection info |
85
+ | `fsf disconnect` | Clear all credentials |
86
+
87
+ Run `fsf --help` for all commands.
88
+
89
+ ## Security
90
+
91
+ Your API tokens never leave your machine.formseal-fetch:
92
+ - Stores credentials in your OS keychain (encrypted at rest)
93
+ - Makes direct API calls to your storage backend only
94
+ - Sends no telemetry, has no analytics
95
+
96
+ ## Documentation
97
+
98
+ Detailed guides: [docs/](./docs/)
99
+
100
+ - [Getting Started](./docs/getting-started.md)
101
+ - [Security](./docs/security.md)
102
+ - [Commands Reference](./docs/reference/commands.md)
103
+ - [Troubleshooting](./docs/troubleshooting.md)
104
+
105
+ ## License
106
+
107
+ MIT
@@ -0,0 +1,75 @@
1
+ # formseal-fetch
2
+
3
+ <p align="center">
4
+ <img src="https://img.shields.io/badge/python-3.8+-3776ab?style=flat&labelColor=1e293b">
5
+ <img src="https://img.shields.io/badge/license-MIT-fc8181?style=flat&labelColor=1e293b">
6
+ <img src="https://img.shields.io/badge/formseal-ecosystem-10b981?style=flat&labelColor=1e293b">
7
+ </p>
8
+
9
+ Download encrypted form submissions from your storage backend for offline decryption.
10
+
11
+ ## What it does
12
+
13
+ ```
14
+ Browser (formseal-embed)
15
+
16
+ ▼ (encrypted submissions)
17
+ Storage (Cloudflare KV / Supabase / ...)
18
+
19
+ ▼ (fsf fetch)
20
+ Your PC ──► ciphertexts.jsonl
21
+
22
+ ▼ (decrypt offline)
23
+ Plaintext form data
24
+ ```
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install formseal-fetch
30
+ ```
31
+
32
+ ## Quick start
33
+
34
+ ```bash
35
+ fsf connect provider:<name>
36
+ fsf fetch
37
+ fsf status
38
+ ```
39
+
40
+ ## Features
41
+
42
+ - **Secure storage** : Credentials stored in OS keychain (Windows Credential Manager / macOS Keychain / Linux Secret Service)
43
+ - **Deduplication** : Skips already-downloaded ciphertexts automatically
44
+ - **Offline-capable** ; Decrypt downloaded data anytime without network access
45
+
46
+ ## Commands
47
+
48
+ | Command | Description |
49
+ |---------|-------------|
50
+ | `fsf connect` | Connect to a storage provider |
51
+ | `fsf fetch` | Download ciphertexts |
52
+ | `fsf status` | Show connection info |
53
+ | `fsf disconnect` | Clear all credentials |
54
+
55
+ Run `fsf --help` for all commands.
56
+
57
+ ## Security
58
+
59
+ Your API tokens never leave your machine.formseal-fetch:
60
+ - Stores credentials in your OS keychain (encrypted at rest)
61
+ - Makes direct API calls to your storage backend only
62
+ - Sends no telemetry, has no analytics
63
+
64
+ ## Documentation
65
+
66
+ Detailed guides: [docs/](./docs/)
67
+
68
+ - [Getting Started](./docs/getting-started.md)
69
+ - [Security](./docs/security.md)
70
+ - [Commands Reference](./docs/reference/commands.md)
71
+ - [Troubleshooting](./docs/troubleshooting.md)
72
+
73
+ ## License
74
+
75
+ MIT
File without changes
@@ -0,0 +1,194 @@
1
+ # Config management
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from cli.ui import br, fail, ok, info, warn, G, W, D, C, Y, R
8
+ from cli.security import tokens
9
+
10
+
11
+ def _load_version():
12
+ p = Path(__file__).parent.parent.parent / "version.txt"
13
+ if p.exists():
14
+ return p.read_text().strip()
15
+ return "dev"
16
+
17
+
18
+ VERSION = _load_version()
19
+
20
+
21
+ CONFIG_DIR = Path.home() / ".config" / "formseal-fetch"
22
+ CONFIG_FILE = CONFIG_DIR / "config.json"
23
+
24
+
25
+ VALID_PROVIDERS = {
26
+ "cloudflare": {},
27
+ }
28
+
29
+ VALID_GLOBAL = {
30
+ "output_folder": "Output folder path",
31
+ }
32
+
33
+
34
+ def load_config():
35
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
36
+ if CONFIG_FILE.exists():
37
+ return json.loads(CONFIG_FILE.read_text())
38
+ return {}
39
+
40
+
41
+ def save_config(cfg):
42
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
43
+ CONFIG_FILE.write_text(json.dumps(cfg, indent=2))
44
+
45
+
46
+ def get_token(provider: str):
47
+ return tokens.load_token(provider)
48
+
49
+
50
+ def get_namespace(provider: str):
51
+ return tokens.load_namespace(provider)
52
+
53
+
54
+ def run_set(args):
55
+ if len(args) < 2:
56
+ fail("Usage: fsf set <key> <value>")
57
+
58
+ key = args[0]
59
+ value = " ".join(args[1:])
60
+
61
+ cfg = load_config()
62
+
63
+ if key == "provider":
64
+ if value not in VALID_PROVIDERS:
65
+ fail(f"Unknown provider: {value}\nValid: {', '.join(VALID_PROVIDERS.keys())}")
66
+ cfg["provider"] = value
67
+ save_config(cfg)
68
+ br()
69
+ ok(f"Provider set to {value}")
70
+ br()
71
+ return
72
+
73
+ if key in VALID_GLOBAL:
74
+ cfg[key] = value
75
+ save_config(cfg)
76
+ br()
77
+ ok(f"Set {key} = {value}")
78
+ br()
79
+ return
80
+
81
+ fail(f"Unknown key: {key}")
82
+
83
+
84
+ def run_status():
85
+ cfg = load_config()
86
+
87
+ br()
88
+ print(f"{C} \u250c\u2500 {R}{W}formseal-fetch{R} {Y}v{VERSION}{R}")
89
+ print(G + " " + "\u2500" * 52 + R)
90
+ br()
91
+
92
+ print(f" {D}Configuration Status:{R}")
93
+ br()
94
+
95
+ provider = cfg.get("provider")
96
+ if not provider:
97
+ warn("No provider configured. Run: fsf connect provider:<name>")
98
+ br()
99
+ return
100
+
101
+ def row(label, value, color=W):
102
+ print(f" {D}{label:<26}{R}{color}{value}{R}")
103
+
104
+ row("Provider:", "Cloudflare")
105
+
106
+ if provider == "cloudflare":
107
+ namespace = get_namespace(provider)
108
+ token = get_token(provider)
109
+
110
+ row("KV Namespace ID:", namespace or "(not set)", W if namespace else D)
111
+ if namespace:
112
+ row("NS-ID Location:", tokens.namespace_location(provider), G)
113
+
114
+ if token:
115
+ try:
116
+ from cli.providers.cloudflare.account import get_account_id
117
+ account_id = get_account_id(token)
118
+ partial = account_id[:12] + "..." if len(account_id) > 12 else account_id
119
+ row("Account ID:", partial, G)
120
+ except Exception:
121
+ row("Account ID:", "(auth error)", R)
122
+ row("API Token:", "****")
123
+ row("Token Location:", tokens.token_location(provider), G)
124
+ else:
125
+ row("API Token:", "(not set)", D)
126
+ row("Token Location:", "Not set", D)
127
+
128
+ br()
129
+ row("Server Storage Type:", "Key-Value")
130
+
131
+ else:
132
+ fail(f"Unknown provider: {provider}")
133
+
134
+ output_folder = cfg.get("output_folder")
135
+ row("Output Folder:", output_folder or "(not set)", W if output_folder else D)
136
+
137
+ br()
138
+
139
+ br()
140
+
141
+
142
+ def run_config_show():
143
+ cfg = load_config()
144
+
145
+ br()
146
+ print(f"{C} \u250c\u2500 {R}{W}formseal-fetch{R} {G}config{R}")
147
+ print(G + " " + "\u2500" * 52 + R)
148
+ br()
149
+
150
+ provider = cfg.get("provider")
151
+
152
+ if not cfg:
153
+ info("No config. Run: fsf connect provider:<name>")
154
+ else:
155
+ for k, v in cfg.items():
156
+ print(f" {D}{k}:{R} {W}{v}{R}")
157
+
158
+ if provider:
159
+ token = get_token(provider)
160
+ print(f" {D}token:{R} {'****' if token else 'not set'}")
161
+
162
+ br()
163
+
164
+
165
+ def run_disconnect():
166
+ br()
167
+ print(f" {Y}This will delete all config and credentials.{R}")
168
+ print(f" Downloaded ciphertexts will NOT be affected.")
169
+ br()
170
+ sys.stdout.write(f" Continue? [y/N]: ")
171
+ sys.stdout.flush()
172
+ confirm = input().strip().lower()
173
+
174
+ if confirm != "y":
175
+ br()
176
+ info("Cancelled.")
177
+ br()
178
+ return
179
+
180
+ cfg = load_config()
181
+ provider = cfg.get("provider")
182
+
183
+ if CONFIG_FILE.exists():
184
+ CONFIG_FILE.unlink()
185
+
186
+ if provider:
187
+ tokens.clear_all(provider)
188
+
189
+ br()
190
+ ok("Disconnected. All config and credentials cleared.")
191
+ br()
192
+
193
+
194
+ run_logout = run_disconnect
@@ -0,0 +1,72 @@
1
+ # Fetch ciphertexts
2
+
3
+ import argparse
4
+
5
+ from cli.ui import br, fail, ok, info, G, W, D, C, R
6
+ from cli.commands.config import load_config
7
+ from cli.security import tokens
8
+
9
+
10
+ def run(args):
11
+ parser = argparse.ArgumentParser(prog="fsf fetch")
12
+ parser.add_argument("--output", default=None)
13
+ parsed = parser.parse_args(args)
14
+
15
+ cfg = load_config()
16
+ provider = cfg.get("provider")
17
+ output_folder = cfg.get("output_folder", "data")
18
+
19
+ if not provider:
20
+ fail("No provider set. Run: fsf connect provider:<name>")
21
+
22
+ if parsed.output:
23
+ output_path = parsed.output
24
+ else:
25
+ output_path = f"{output_folder}/ciphertexts.jsonl"
26
+
27
+ token = tokens.load_token(provider)
28
+ if not token:
29
+ fail("No token. Run: fsf connect provider:<name> to set token")
30
+
31
+ if provider == "cloudflare":
32
+ namespace = tokens.load_namespace(provider)
33
+ if not namespace:
34
+ fail("No namespace. Run: fsf connect provider:<name> to set namespace")
35
+
36
+ try:
37
+ from cli.providers.cloudflare.account import get_account_id
38
+ account_id = get_account_id(token)
39
+ except Exception as e:
40
+ fail(f"Auth failed: {e}")
41
+
42
+ br()
43
+ provider_display = f"{provider.capitalize()} KV"
44
+ print(f"{C} \u250c\u2500 {R}{W}formseal-fetch{R} {G}{provider_display}{R}")
45
+ print(G + " " + "\u2500" * 52 + R)
46
+ br()
47
+
48
+ account_trunc = account_id[:8] + "***" if len(account_id) > 8 else account_id
49
+ namespace_trunc = namespace[:8] + "***" if len(namespace) > 8 else namespace
50
+
51
+ print(f"{D}>> Account:{R} {D}{account_trunc}{R}")
52
+ print(f"{D}>> Namespace:{R} {D}{namespace_trunc}{R}")
53
+ br()
54
+
55
+ try:
56
+ from cli.providers.cloudflare.storage import fetch as storage_fetch
57
+ written, skipped = storage_fetch(
58
+ namespace=namespace,
59
+ account_id=account_id,
60
+ token=token,
61
+ output_path=output_path,
62
+ )
63
+ except Exception as e:
64
+ fail(f"Fetch failed: {e}")
65
+
66
+ br()
67
+ ok(f"{written} new ciphertexts saved → {output_path}")
68
+ if skipped:
69
+ print(f" {D}({skipped} duplicates skipped){R}")
70
+ br()
71
+ else:
72
+ fail(f"Unknown provider: {provider}")
@@ -0,0 +1,39 @@
1
+ # List providers
2
+
3
+ from pathlib import Path
4
+
5
+ from cli.ui import br, C, G, Y, W, D, R
6
+
7
+
8
+ def _load_version():
9
+ p = Path(__file__).parent.parent.parent / "version.txt"
10
+ if p.exists():
11
+ return p.read_text().strip()
12
+ return "dev"
13
+
14
+
15
+ VERSION = _load_version()
16
+
17
+
18
+ def run(args):
19
+ _list_providers()
20
+
21
+
22
+ def _list_providers():
23
+ br()
24
+ print(f"{C} \u250c\u2500 {R}{W}formseal-fetch{R} {Y}v{VERSION}{R}")
25
+ print(G + " " + "\u2500" * 52 + R)
26
+ br()
27
+
28
+ print(f" {G}Available providers:{R}")
29
+ br()
30
+ print(f" {W}> Cloudflare{R} - Cloudflare KV")
31
+ br()
32
+ print(f" {W}> Supabase{R} - PostgreSQL DB (coming soon)")
33
+ br()
34
+
35
+
36
+ def _help():
37
+ return [
38
+ ("fsf providers", "list available providers"),
39
+ ]
@@ -0,0 +1,127 @@
1
+ # Connect commands
2
+
3
+ import sys
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from cli.ui import br, fail, ok, info, warn, G, W, D, C, Y, O, R
8
+ from cli.commands.config import load_config, save_config
9
+ from cli.security import tokens
10
+
11
+
12
+ SETUP_SCHEMA = {
13
+ "cloudflare": {
14
+ "kv": {
15
+ "config_fields": [
16
+ {"key": "namespace", "prompt": "KV Namespace ID", "required": True},
17
+ ],
18
+ }
19
+ }
20
+ }
21
+
22
+
23
+ def _parse_args(args):
24
+ parsed = {}
25
+ for arg in args:
26
+ if ":" not in arg:
27
+ fail(f"Invalid format: {arg}\n Use flag:value (e.g., provider:<name>)")
28
+ key, value = arg.split(":", 1)
29
+ parsed[key] = value
30
+ return parsed
31
+
32
+
33
+ def run(args):
34
+ if not args:
35
+ fail("Usage: fsf connect provider:<name> [namespace:<id>] [output:<path>]")
36
+
37
+ parsed = _parse_args(args)
38
+
39
+ if "provider" not in parsed:
40
+ fail("provider is required.\n Usage: fsf connect provider:<name> [...]")
41
+
42
+ provider = parsed["provider"].lower()
43
+ if provider not in SETUP_SCHEMA:
44
+ fail(f"Unknown provider: {provider}\n Run fsf providers to see available.")
45
+
46
+ _setup_flow(provider, parsed)
47
+
48
+
49
+ def _setup_flow(provider, parsed):
50
+ print()
51
+ print(f"{C} \u250c\u2500 {R}{W}formseal-fetch{R} {G}setup{R}")
52
+ print(G + " " + "\u2500" * 52 + R)
53
+ print()
54
+
55
+ cfg = load_config()
56
+ cfg["provider"] = provider
57
+
58
+ # Get config fields from schema
59
+ fields = SETUP_SCHEMA[provider]["kv"]["config_fields"]
60
+
61
+ # Ask for namespace if not provided
62
+ namespace = parsed.get("namespace")
63
+ if not namespace:
64
+ for field in fields:
65
+ prompt = field["prompt"]
66
+ try:
67
+ sys.stdout.write(f" {prompt}: ")
68
+ sys.stdout.flush()
69
+ namespace = input().strip()
70
+ except KeyboardInterrupt:
71
+ br()
72
+ info("Cancelled.")
73
+ br()
74
+ return
75
+ if field.get("required") and not namespace:
76
+ fail(f"{prompt} is required")
77
+ break
78
+
79
+ if namespace:
80
+ tokens.save_namespace(provider, namespace)
81
+
82
+ # Ask for token
83
+ if "token" in parsed:
84
+ token = parsed["token"]
85
+ else:
86
+ try:
87
+ sys.stdout.write(" Account API Token: ")
88
+ sys.stdout.flush()
89
+ token = sys.stdin.readline().strip()
90
+ except KeyboardInterrupt:
91
+ br()
92
+ info("Cancelled.")
93
+ br()
94
+ return
95
+ if not token:
96
+ fail("Account API Token is required")
97
+
98
+ token = "".join(c for c in token if c.isprintable()).strip()
99
+ if not token:
100
+ fail("Account API Token is required")
101
+
102
+ tokens.save_token(provider, token)
103
+
104
+ # Ask for output folder
105
+ if "output" in parsed:
106
+ output_folder = parsed["output"]
107
+ else:
108
+ try:
109
+ sys.stdout.write(f" Output Folder [{D}data{R}]: ")
110
+ sys.stdout.flush()
111
+ output_folder = input().strip()
112
+ if not output_folder:
113
+ output_folder = "data"
114
+ except KeyboardInterrupt:
115
+ br()
116
+ info("Cancelled.")
117
+ br()
118
+ return
119
+ cfg["output_folder"] = output_folder
120
+ print()
121
+
122
+ save_config(cfg)
123
+
124
+ print(f"{G} \u2713{R} Saved!")
125
+ print()
126
+ print(f" Run {W}fsf fetch{R} to download ciphertexts")
127
+ print()
@@ -0,0 +1,124 @@
1
+ # Main entry point
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ sys.path.insert(0, str(Path(__file__).parent.parent))
7
+
8
+ from cli.ui import br, fail, info, warn, C, G, W, Y, R, D, GRAY
9
+ from cli.commands.config import run_status, run_set, run_disconnect
10
+ from cli.commands.setup import run as run_connect
11
+ from cli.commands.fetch import run as run_fetch
12
+ from cli.commands.providers import run as run_providers
13
+
14
+
15
+ def _load_version():
16
+ p = Path(__file__).parent.parent / "version.txt"
17
+ if p.exists():
18
+ return p.read_text().strip()
19
+ return "dev"
20
+
21
+
22
+ VERSION = _load_version()
23
+
24
+
25
+ COMMANDS = {
26
+ "connect": ("Connect to a provider", lambda a: run_connect(a)),
27
+ "fetch": ("Fetch ciphertexts", lambda a: run_fetch(a)),
28
+ "status": ("Show connection status", lambda a: run_status()),
29
+ "set": ("Set a config value", run_set),
30
+ "disconnect": ("Clear all credentials", lambda a: run_disconnect()),
31
+ "providers": ("List available providers", lambda a: run_providers(a)),
32
+ }
33
+
34
+
35
+ HELP_GROUPS = {
36
+ "Connect": [
37
+ ("fsf connect provider:<name>", "connect to a storage provider"),
38
+ ("fsf disconnect", "clear configuration"),
39
+ ],
40
+ "Fetch": [
41
+ ("fsf fetch", "download ciphertexts"),
42
+ ("fsf fetch --output <file>", "custom output path"),
43
+ ],
44
+ "Config": [
45
+ ("fsf status", "show configuration"),
46
+ ("fsf set <key> <value>", "set config value"),
47
+ ],
48
+ "Info": [
49
+ ("fsf providers", "list available providers"),
50
+ ("fsf --about", "show project info"),
51
+ ],
52
+ "Docs": [
53
+ ("https://github.com/formseal/formseal-fetch", None),
54
+ ],
55
+ }
56
+
57
+
58
+ def _show_help():
59
+ br()
60
+ print(f"{C} \u250c\u2500 {R}{W}formseal-fetch{R} {Y}v{VERSION}{R}")
61
+ print(G + " " + "\u2500" * 52 + R)
62
+ br()
63
+
64
+ for group, cmds in HELP_GROUPS.items():
65
+ print(f" {GRAY}>> {group}{R}")
66
+ print(G + " " + "\u2500" * 52 + R)
67
+ for cmd, desc in cmds:
68
+ if desc:
69
+ print(f" {W}{cmd:<35}{R} {G}{desc}{R}")
70
+ else:
71
+ print(f" {C}{cmd}{R}")
72
+ br()
73
+
74
+
75
+ def _show_about():
76
+ br()
77
+ print(f"{C} \u250c\u2500 {R}{W}formseal-fetch{R} {Y}v{VERSION}{R}")
78
+ print(G + " " + "\u2500" * 52 + R)
79
+ br()
80
+ print(f" {W}formseal-fetch{R} - CLI for fetching encrypted form submissions")
81
+ br()
82
+ print(f" {G}Repository:{R} https://github.com/formseal/formseal-fetch")
83
+ br()
84
+
85
+
86
+ def main():
87
+ if len(sys.argv) < 2:
88
+ _show_help()
89
+ return
90
+
91
+ cmd = sys.argv[1].lower()
92
+ args = sys.argv[2:]
93
+
94
+ if cmd == "-h" or cmd == "--help":
95
+ _show_help()
96
+ return
97
+
98
+ if cmd == "--about":
99
+ _show_about()
100
+ return
101
+
102
+ if cmd not in COMMANDS:
103
+ br()
104
+ fail(f"Unknown command: {cmd}\nRun 'fsf --help' for available commands")
105
+
106
+ _, handler = COMMANDS[cmd]
107
+
108
+ if handler is None:
109
+ br()
110
+ fail(f"Command not implemented: {cmd}")
111
+
112
+ try:
113
+ handler(args)
114
+ except KeyboardInterrupt:
115
+ br()
116
+ info("Interrupted.")
117
+ br()
118
+ sys.exit(130)
119
+ except Exception as e:
120
+ fail(str(e))
121
+
122
+
123
+ if __name__ == "__main__":
124
+ main()
File without changes
@@ -0,0 +1,69 @@
1
+ # Cloudflare account authentication
2
+
3
+ import json
4
+ import urllib.request
5
+ import urllib.error
6
+
7
+ from cli.ui import fail
8
+
9
+
10
+ class AuthError(Exception):
11
+ """Raised when Cloudflare authentication fails."""
12
+ pass
13
+
14
+
15
+ class TokenError(Exception):
16
+ """Raised when token is invalid or missing."""
17
+ pass
18
+
19
+
20
+ def get_account_id(token):
21
+ """Fetch account_id from Cloudflare API."""
22
+ if not token:
23
+ raise TokenError("Token is empty")
24
+
25
+ token = token.strip()
26
+ if not token:
27
+ raise TokenError("Token is blank after stripping whitespace")
28
+
29
+ req = urllib.request.Request(
30
+ "https://api.cloudflare.com/client/v4/accounts",
31
+ headers={"Authorization": f"Bearer {token}"}
32
+ )
33
+
34
+ try:
35
+ with urllib.request.urlopen(req, timeout=10) as resp:
36
+ data = json.loads(resp.read().decode())
37
+ except urllib.error.HTTPError as e:
38
+ body = e.read().decode("utf-8", errors="replace")
39
+ raise AuthError(f"HTTP {e.code}: {body}")
40
+ except json.JSONDecodeError as e:
41
+ raise AuthError(f"Invalid API response: {e}")
42
+ except urllib.error.URLError as e:
43
+ raise AuthError(f"Network error: {e.reason}")
44
+
45
+ if not data.get("success"):
46
+ errors = data.get("errors", [])
47
+ if errors:
48
+ msg = errors[0].get("message", "Unknown error")
49
+ if "token" in msg.lower():
50
+ raise TokenError(f"Invalid token: {msg}")
51
+ raise AuthError(msg)
52
+ raise AuthError("Unknown API error")
53
+
54
+ accounts = data.get("result", [])
55
+ if not accounts:
56
+ raise AuthError("No accounts found. Token needs 'Account Settings: Read' scope.")
57
+
58
+ return accounts[0]["id"]
59
+
60
+
61
+ def validate_token(token):
62
+ """Check if token is valid. Returns True/False, never raises."""
63
+ try:
64
+ get_account_id(token)
65
+ return True
66
+ except (TokenError, AuthError) as e:
67
+ return False
68
+ except Exception:
69
+ return False
@@ -0,0 +1,3 @@
1
+ from cli.providers.cloudflare.storage.kv import fetch
2
+
3
+ __all__ = ["fetch"]
@@ -0,0 +1,80 @@
1
+ # Cloudflare KV storage
2
+
3
+ import json
4
+ import urllib.request
5
+ import urllib.error
6
+ import urllib.parse
7
+ from pathlib import Path
8
+
9
+ from cli.ui import fail, info
10
+
11
+
12
+ def _get(url, token):
13
+ req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
14
+ try:
15
+ with urllib.request.urlopen(req, timeout=30) as resp:
16
+ return json.loads(resp.read().decode())
17
+ except urllib.error.HTTPError as e:
18
+ fail(f"HTTP {e.code}: {e.read().decode()}")
19
+
20
+
21
+ def _get_raw(url, token):
22
+ req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
23
+ try:
24
+ with urllib.request.urlopen(req, timeout=30) as resp:
25
+ return resp.read().decode("utf-8").strip()
26
+ except urllib.error.HTTPError as e:
27
+ fail(f"HTTP {e.code}: {e.read().decode()}")
28
+
29
+
30
+ def _load_seen(output_path) -> set:
31
+ p = Path(output_path)
32
+ if not p.exists():
33
+ return set()
34
+ return {line.strip() for line in p.read_text(encoding="utf-8").splitlines() if line.strip()}
35
+
36
+
37
+ def fetch(namespace, account_id, token, output_path):
38
+ """Fetch all values from a KV namespace."""
39
+ base = f"https://api.cloudflare.com/client/v4/accounts/{account_id}/storage/kv/namespaces/{namespace}"
40
+
41
+ # List all keys (paginated)
42
+ all_keys = []
43
+ cursor = None
44
+
45
+ while True:
46
+ url = f"{base}/keys" + (f"?cursor={cursor}" if cursor else "")
47
+ data = _get(url, token)
48
+ if not data.get("success"):
49
+ fail(f"API error: {data.get('errors')}")
50
+ all_keys.extend(k["name"] for k in data.get("result", []))
51
+ cursor = data.get("result_info", {}).get("cursor")
52
+ if not cursor:
53
+ break
54
+
55
+ info(f"Found {len(all_keys)} entries")
56
+
57
+ if not all_keys:
58
+ info("No data to fetch.")
59
+ return 0, 0
60
+
61
+ # Deduplicate against existing
62
+ seen = _load_seen(output_path)
63
+ Path(output_path).parent.mkdir(parents=True, exist_ok=True)
64
+
65
+ written = 0
66
+ skipped = 0
67
+
68
+ with open(output_path, "a", encoding="utf-8") as f:
69
+ for key in all_keys:
70
+ value = _get_raw(f"{base}/values/{urllib.parse.quote(key, safe='')}", token)
71
+ if not value:
72
+ continue
73
+ if value in seen:
74
+ skipped += 1
75
+ continue
76
+ f.write(value + "\n")
77
+ seen.add(value)
78
+ written += 1
79
+
80
+ return written, skipped
@@ -0,0 +1,166 @@
1
+ # Credential storage (keyring + JSON fallback)
2
+
3
+ import json
4
+ import base64
5
+ from pathlib import Path
6
+
7
+ try:
8
+ import keyring
9
+ HAS_KEYRING = True
10
+ except ImportError:
11
+ HAS_KEYRING = False
12
+
13
+ SERVICE = "formseal-fetch"
14
+
15
+ CONFIG_DIR = Path.home() / ".config" / "formseal-fetch"
16
+ SECRETS_FILE = CONFIG_DIR / "secrets.json"
17
+
18
+
19
+ def _ensure_config_dir():
20
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
21
+
22
+
23
+ def _load_secrets() -> dict:
24
+ if not SECRETS_FILE.exists():
25
+ return {}
26
+ try:
27
+ return json.loads(SECRETS_FILE.read_text())
28
+ except:
29
+ return {}
30
+
31
+
32
+ def _save_secrets(secrets: dict):
33
+ _ensure_config_dir()
34
+ SECRETS_FILE.write_text(json.dumps(secrets, indent=2))
35
+
36
+
37
+ def save_token(provider: str, token: str) -> bool:
38
+ """Save API token to OS keyring. Falls back to JSON if keyring fails."""
39
+ if HAS_KEYRING:
40
+ try:
41
+ keyring.set_password(SERVICE, f"{provider}:api-token", token)
42
+ return True
43
+ except Exception:
44
+ pass
45
+
46
+ # Fallback to JSON file
47
+ secrets = _load_secrets()
48
+ secrets[f"{provider}:token"] = base64.b64encode(token.encode()).decode()
49
+ _save_secrets(secrets)
50
+ return True
51
+
52
+
53
+ def load_token(provider: str) -> str | None:
54
+ """Load API token from OS keyring. Falls back to JSON if not in keyring."""
55
+ if HAS_KEYRING:
56
+ try:
57
+ token = keyring.get_password(SERVICE, f"{provider}:api-token")
58
+ if token:
59
+ return token
60
+ except Exception:
61
+ pass
62
+
63
+ # Fallback to JSON file
64
+ secrets = _load_secrets()
65
+ encoded = secrets.get(f"{provider}:token")
66
+ if encoded:
67
+ return base64.b64decode(encoded.encode()).decode().strip()
68
+ return None
69
+
70
+
71
+ def delete_token(provider: str):
72
+ """Delete API token from keyring. Falls back to JSON if keyring fails."""
73
+ if HAS_KEYRING:
74
+ try:
75
+ keyring.delete_password(SERVICE, f"{provider}:api-token")
76
+ return
77
+ except Exception:
78
+ pass
79
+
80
+ secrets = _load_secrets()
81
+ secrets.pop(f"{provider}:token", None)
82
+ _save_secrets(secrets)
83
+
84
+
85
+ def save_namespace(provider: str, namespace: str) -> bool:
86
+ """Save KV namespace ID to OS keyring. Falls back to JSON."""
87
+ if HAS_KEYRING:
88
+ try:
89
+ keyring.set_password(SERVICE, f"{provider}:kv-namespace", namespace)
90
+ return True
91
+ except Exception:
92
+ pass
93
+
94
+ # Fallback to JSON file
95
+ secrets = _load_secrets()
96
+ secrets[f"{provider}:namespace"] = base64.b64encode(namespace.encode()).decode()
97
+ _save_secrets(secrets)
98
+ return True
99
+
100
+
101
+ def load_namespace(provider: str) -> str | None:
102
+ """Load KV namespace ID from OS keyring. Falls back to JSON."""
103
+ if HAS_KEYRING:
104
+ try:
105
+ namespace = keyring.get_password(SERVICE, f"{provider}:kv-namespace")
106
+ if namespace:
107
+ return namespace
108
+ except Exception:
109
+ pass
110
+
111
+ # Fallback to JSON file
112
+ secrets = _load_secrets()
113
+ encoded = secrets.get(f"{provider}:namespace")
114
+ if encoded:
115
+ return base64.b64decode(encoded.encode()).decode().strip()
116
+ return None
117
+
118
+
119
+ def delete_namespace(provider: str):
120
+ """Delete namespace from keyring. Falls back to JSON if keyring fails."""
121
+ if HAS_KEYRING:
122
+ try:
123
+ keyring.delete_password(SERVICE, f"{provider}:kv-namespace")
124
+ return
125
+ except Exception:
126
+ pass
127
+
128
+ secrets = _load_secrets()
129
+ secrets.pop(f"{provider}:namespace", None)
130
+ _save_secrets(secrets)
131
+
132
+
133
+ def token_location(provider: str) -> str:
134
+ """Check where token is stored."""
135
+ if HAS_KEYRING:
136
+ try:
137
+ if keyring.get_password(SERVICE, f"{provider}:api-token"):
138
+ return "OS Keychain"
139
+ except Exception:
140
+ pass
141
+
142
+ secrets = _load_secrets()
143
+ if f"{provider}:token" in secrets:
144
+ return "Config File"
145
+ return "Not set"
146
+
147
+
148
+ def namespace_location(provider: str) -> str:
149
+ """Check where namespace is stored."""
150
+ if HAS_KEYRING:
151
+ try:
152
+ if keyring.get_password(SERVICE, f"{provider}:kv-namespace"):
153
+ return "OS Keychain"
154
+ except Exception:
155
+ pass
156
+
157
+ secrets = _load_secrets()
158
+ if f"{provider}:namespace" in secrets:
159
+ return "Config File"
160
+ return "Not set"
161
+
162
+
163
+ def clear_all(provider: str):
164
+ """Clear all secrets for a provider."""
165
+ delete_token(provider)
166
+ delete_namespace(provider)
@@ -0,0 +1,89 @@
1
+ # Terminal output primitives
2
+
3
+ import os
4
+ import sys
5
+
6
+ if os.name == "nt":
7
+ try:
8
+ os.system("chcp 65001 >nul")
9
+ except:
10
+ pass
11
+
12
+ try:
13
+ sys.stdout.reconfigure(encoding="utf-8")
14
+ sys.stderr.reconfigure(encoding="utf-8")
15
+ except:
16
+ pass
17
+
18
+ RESET = "\x1b[0m"
19
+ BOLD = "\x1b[1m"
20
+ DIM = "\x1b[2m"
21
+
22
+ RED = "\x1b[31m"
23
+ GREEN = "\x1b[32m"
24
+ YELLOW = "\x1b[33m"
25
+ BLUE = "\x1b[34m"
26
+ MAGENTA= "\x1b[35m"
27
+ CYAN = "\x1b[36m"
28
+ WHITE = "\x1b[37m"
29
+ GRAY = "\x1b[90m"
30
+
31
+ O = "\x1b[38;5;208m"
32
+ S = "\x1b[38;5;112m"
33
+ G = "\x1b[38;5;244m"
34
+ C = "\x1b[38;5;117m"
35
+ Y = "\x1b[38;5;103m"
36
+ W = WHITE + BOLD
37
+ D = DIM
38
+ R = RESET
39
+
40
+
41
+ def br():
42
+ print()
43
+
44
+
45
+ def rule():
46
+ print(G + " " + "\u2500" * 52 + R)
47
+
48
+
49
+ def badge(label, color):
50
+ return f"{color}{BOLD} {label} {R}"
51
+
52
+
53
+ def fail(msg):
54
+ br()
55
+ print(f"{badge('ERR', RED)} {msg}")
56
+ br()
57
+ raise SystemExit(1)
58
+
59
+
60
+ def row(icon, label, value):
61
+ pad = 12
62
+ label = (label + " " * pad)[:pad]
63
+ print(f"{S}{icon}{R} {D}{label}{R} {W}{value}{R}")
64
+
65
+
66
+ def cmd_line(command, desc):
67
+ pad = 34
68
+ command = (command + " " * pad)[:pad]
69
+ print(f" {W}{command}{R}{G}{desc}{R}")
70
+
71
+
72
+ def code(msg):
73
+ print(f" {O}{msg}{R}")
74
+
75
+
76
+ def link(msg):
77
+ print(f" {O}{msg}{R}")
78
+
79
+
80
+ def ok(msg):
81
+ print(f" {G}\u2713{R} {msg}")
82
+
83
+
84
+ def info(msg):
85
+ print(f" {O}{msg}{R}")
86
+
87
+
88
+ def warn(msg):
89
+ print(f" {Y}Warning:{R} {msg}")
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: formseal-fetch
3
+ Version: 2.0.0
4
+ Summary: CLI tool for fetching encrypted form submissions
5
+ License: MIT License
6
+
7
+ Copyright (c) 2026 grayguava
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+
27
+ Requires-Python: >=3.8
28
+ Description-Content-Type: text/markdown
29
+ License-File: LICENSE
30
+ Requires-Dist: keyring>=25.0
31
+ Dynamic: license-file
32
+
33
+ # formseal-fetch
34
+
35
+ <p align="center">
36
+ <img src="https://img.shields.io/badge/python-3.8+-3776ab?style=flat&labelColor=1e293b">
37
+ <img src="https://img.shields.io/badge/license-MIT-fc8181?style=flat&labelColor=1e293b">
38
+ <img src="https://img.shields.io/badge/formseal-ecosystem-10b981?style=flat&labelColor=1e293b">
39
+ </p>
40
+
41
+ Download encrypted form submissions from your storage backend for offline decryption.
42
+
43
+ ## What it does
44
+
45
+ ```
46
+ Browser (formseal-embed)
47
+
48
+ ▼ (encrypted submissions)
49
+ Storage (Cloudflare KV / Supabase / ...)
50
+
51
+ ▼ (fsf fetch)
52
+ Your PC ──► ciphertexts.jsonl
53
+
54
+ ▼ (decrypt offline)
55
+ Plaintext form data
56
+ ```
57
+
58
+ ## Install
59
+
60
+ ```bash
61
+ pip install formseal-fetch
62
+ ```
63
+
64
+ ## Quick start
65
+
66
+ ```bash
67
+ fsf connect provider:<name>
68
+ fsf fetch
69
+ fsf status
70
+ ```
71
+
72
+ ## Features
73
+
74
+ - **Secure storage** : Credentials stored in OS keychain (Windows Credential Manager / macOS Keychain / Linux Secret Service)
75
+ - **Deduplication** : Skips already-downloaded ciphertexts automatically
76
+ - **Offline-capable** ; Decrypt downloaded data anytime without network access
77
+
78
+ ## Commands
79
+
80
+ | Command | Description |
81
+ |---------|-------------|
82
+ | `fsf connect` | Connect to a storage provider |
83
+ | `fsf fetch` | Download ciphertexts |
84
+ | `fsf status` | Show connection info |
85
+ | `fsf disconnect` | Clear all credentials |
86
+
87
+ Run `fsf --help` for all commands.
88
+
89
+ ## Security
90
+
91
+ Your API tokens never leave your machine.formseal-fetch:
92
+ - Stores credentials in your OS keychain (encrypted at rest)
93
+ - Makes direct API calls to your storage backend only
94
+ - Sends no telemetry, has no analytics
95
+
96
+ ## Documentation
97
+
98
+ Detailed guides: [docs/](./docs/)
99
+
100
+ - [Getting Started](./docs/getting-started.md)
101
+ - [Security](./docs/security.md)
102
+ - [Commands Reference](./docs/reference/commands.md)
103
+ - [Troubleshooting](./docs/troubleshooting.md)
104
+
105
+ ## License
106
+
107
+ MIT
@@ -0,0 +1,21 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ cli/fsf.py
5
+ cli/ui.py
6
+ cli/commands/__init__.py
7
+ cli/commands/config.py
8
+ cli/commands/fetch.py
9
+ cli/commands/providers.py
10
+ cli/commands/setup.py
11
+ cli/providers/__init__.py
12
+ cli/providers/cloudflare/account.py
13
+ cli/providers/cloudflare/storage/__init__.py
14
+ cli/providers/cloudflare/storage/kv.py
15
+ cli/security/tokens.py
16
+ formseal_fetch.egg-info/PKG-INFO
17
+ formseal_fetch.egg-info/SOURCES.txt
18
+ formseal_fetch.egg-info/dependency_links.txt
19
+ formseal_fetch.egg-info/entry_points.txt
20
+ formseal_fetch.egg-info/requires.txt
21
+ formseal_fetch.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ fsf = cli.fsf:main
@@ -0,0 +1 @@
1
+ keyring>=25.0
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["setuptools>=65.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "formseal-fetch"
7
+ version = "2.0.0"
8
+ description = "CLI tool for fetching encrypted form submissions"
9
+ readme = "README.md"
10
+ license = {file = "LICENSE"}
11
+ requires-python = ">=3.8"
12
+ dependencies = [
13
+ "keyring>=25.0",
14
+ ]
15
+
16
+ [project.scripts]
17
+ fsf = "cli.fsf:main"
18
+
19
+ [tool.setuptools]
20
+ packages = ["cli", "cli.commands", "cli.providers", "cli.providers.cloudflare", "cli.providers.cloudflare.storage", "cli.security"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+