vibefs 0.1.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,13 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # Runtime data
13
+ *.db
@@ -0,0 +1 @@
1
+ 3.14
vibefs-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 reorx
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.
vibefs-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.4
2
+ Name: vibefs
3
+ Version: 0.1.0
4
+ Summary: A simple, secure file preview service with time-limited URLs
5
+ Project-URL: Homepage, https://github.com/reorx/vibefs
6
+ Project-URL: Repository, https://github.com/reorx/vibefs
7
+ Author-email: reorx <novoreorx@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: agent,cli,file-server,preview
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
17
+ Classifier: Topic :: Utilities
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: bottle>=0.13.4
20
+ Requires-Dist: click>=8.3.1
21
+ Requires-Dist: pygments>=2.19.2
22
+ Description-Content-Type: text/markdown
23
+
24
+ # vibefs
25
+
26
+ A file preview server with time-limited access control, designed for AI agents to share local files with users via URLs.
27
+
28
+ Files are not accessible by default. Each file must be explicitly authorized with a TTL (default: 1 hour). The server starts automatically on the first `allow` call and shuts down when all authorizations expire.
29
+
30
+ ## Install
31
+
32
+ With [uv](https://docs.astral.sh/uv/):
33
+
34
+ ```bash
35
+ # Install globally
36
+ uv tool install vibefs
37
+
38
+ # Or run directly without installing
39
+ uvx vibefs --help
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ ### Authorize a file
45
+
46
+ ```bash
47
+ vibefs allow /path/to/file.py
48
+ # http://localhost:17173/f/a3b7c2d1/file.py
49
+
50
+ vibefs allow /path/to/file.py --ttl 300 # 5 minutes
51
+ ```
52
+
53
+ The daemon starts automatically if it's not already running.
54
+
55
+ ### Manage authorizations
56
+
57
+ ```bash
58
+ vibefs list # List active authorizations
59
+ vibefs revoke <token> # Revoke a specific authorization
60
+ ```
61
+
62
+ ### Server control
63
+
64
+ ```bash
65
+ vibefs status # Check if daemon is running
66
+ vibefs stop # Stop the daemon
67
+ vibefs serve # Start server in foreground (for debugging)
68
+ ```
69
+
70
+ ### Configuration
71
+
72
+ ```bash
73
+ vibefs config set base_url https://files.example.com
74
+ vibefs config get base_url
75
+ ```
76
+
77
+ When `base_url` is set, the `allow` command outputs URLs using it instead of `localhost:port`:
78
+
79
+ ```bash
80
+ vibefs allow /path/to/file.py
81
+ # https://files.example.com/f/a3b7c2d1/file.py
82
+ ```
83
+
84
+ ## File rendering
85
+
86
+ - Code and text files (`.py`, `.js`, `.md`, `.json`, etc.) are rendered with syntax highlighting via Pygments.
87
+ - Other files are served with their original content type.
88
+
89
+ ## Deploy
90
+
91
+ vibefs listens on `localhost:17173` by default. To make it accessible from the internet, use a tunneling service to map the local port to a public domain.
92
+
93
+ ### Cloudflare Tunnel
94
+
95
+ ```bash
96
+ # Install cloudflared: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/
97
+
98
+ # Quick tunnel (temporary public URL)
99
+ cloudflared tunnel --url http://localhost:17173
100
+
101
+ # Named tunnel (persistent domain)
102
+ cloudflared tunnel create vibefs
103
+ cloudflared tunnel route dns vibefs vibefs.example.com
104
+ cloudflared tunnel run --url http://localhost:17173 vibefs
105
+ ```
106
+
107
+ ### Other options
108
+
109
+ - **ngrok**: `ngrok http 17173`
110
+ - **Tailscale Funnel**: `tailscale funnel 17173`
111
+ - **frp**, **bore**, or any TCP tunneling tool
112
+
113
+ After setting up the tunnel, configure the base URL so generated links use your public domain:
114
+
115
+ ```bash
116
+ vibefs config set base_url https://vibefs.example.com
117
+ ```
118
+
119
+ ## Agent integration
120
+
121
+ To let an AI agent use vibefs, add instructions like the following to its system prompt or tool documentation:
122
+
123
+ ```
124
+ You have access to `vibefs`, a file preview tool. When you want to share a file
125
+ with the user, run:
126
+
127
+ vibefs allow /path/to/file [--ttl SECONDS]
128
+
129
+ This prints a URL. Send the URL to the user — they can open it in a browser to
130
+ view the file. The link expires after the TTL (default: 1 hour).
131
+
132
+ Use this when:
133
+ - Showing code, logs, or config files
134
+ - Sharing generated output
135
+ - Any time a file is easier to read in a browser than in chat
136
+ ```
137
+
138
+ ## State
139
+
140
+ All runtime data is stored in `~/.vibefs/`:
141
+
142
+ - `vibefs.db` — authorization records (SQLite)
143
+ - `vibefs.pid` — daemon PID file
144
+ - `vibefs.log` — daemon log output
145
+ - `config.json` — configuration
146
+
147
+ ## License
148
+
149
+ MIT
vibefs-0.1.0/PLAN.md ADDED
@@ -0,0 +1,113 @@
1
+ # vibefs — Vibe File Server
2
+
3
+ A simple, secure file preview service designed for AI agents to share files with users via time-limited URLs.
4
+
5
+ ## Concept
6
+
7
+ Agents often need to show files to users. vibefs provides a lightweight web server where files must be explicitly authorized before they can be accessed, with automatic expiration.
8
+
9
+ ## Architecture
10
+
11
+ - **Single-file core**: All logic lives in one Python file (`vibefs.py`) for maximum portability
12
+ - **CLI**: click-based, subcommands for serving and authorizing
13
+ - **Web**: Bottle (zero-dependency micro framework)
14
+ - **Storage**: SQLite (Python built-in), stores authorization records
15
+
16
+ ## Commands
17
+
18
+ ```
19
+ vibefs allow <path> [--ttl 3600] # Authorize a file, auto-start daemon if needed, output access URL
20
+ vibefs revoke <token> # Revoke access to a file
21
+ vibefs list # List currently authorized files
22
+ vibefs serve [--port 8080] [--host 0.0.0.0] # Manually start server in foreground (for debugging)
23
+ vibefs stop # Manually stop the daemon
24
+ vibefs status # Check if daemon is running
25
+ ```
26
+
27
+ ## URL Format
28
+
29
+ ```
30
+ http://localhost:8080/f/{short_hash}/{filename}
31
+ ```
32
+
33
+ - `short_hash`: 6-8 char random token, not derived from path (no path leakage)
34
+ - `filename`: original filename for readability (e.g. `report.txt`)
35
+ - Full filesystem path is never exposed
36
+
37
+ ## Flow
38
+
39
+ 1. Agent runs `vibefs allow /home/user/projects/output/report.txt`
40
+ 2. vibefs generates a short token, stores `{token, filepath, filename, created_at, expires_at}` in SQLite
41
+ 3. Checks if daemon is running (via PID file):
42
+ - Not running → fork a background daemon process that starts the Bottle server
43
+ - Already running → skip
44
+ 4. Outputs: `http://localhost:8080/f/a3b7c2/report.txt`
45
+ 5. User clicks link → vibefs checks token in SQLite:
46
+ - Valid & not expired → read file, return content with appropriate content-type
47
+ - Expired → show "This file is no longer available" page
48
+ - Unknown token → 404
49
+ 6. Default TTL: 1 hour, configurable via `--ttl` (seconds)
50
+
51
+ ## Daemon Architecture
52
+
53
+ The server runs as an implicit, on-demand daemon — no manual `serve` required.
54
+
55
+ ### Auto-start
56
+ - `allow` command checks PID file (`~/.vibefs/vibefs.pid`) to see if daemon is alive
57
+ - If not running, forks a background daemon process (double-fork or `subprocess` detach)
58
+ - Daemon writes its PID to the PID file on startup
59
+
60
+ ### Auto-stop (self-cleanup)
61
+ - Daemon runs a background check every 60 seconds
62
+ - On each check: query SQLite for any non-expired authorizations
63
+ - If ALL authorizations have expired → daemon exits gracefully, removes PID file
64
+ - This ensures the server only runs when there are active files to serve
65
+
66
+ ### State directory: `~/.vibefs/`
67
+ - `~/.vibefs/vibefs.db` — SQLite database (always use this path, not cwd)
68
+ - `~/.vibefs/vibefs.pid` — PID file for daemon liveness check
69
+ - `~/.vibefs/vibefs.log` — daemon log output (stdout/stderr redirect)
70
+
71
+ ### PID file liveness check
72
+ - Read PID from file → `os.kill(pid, 0)` to check if process is alive
73
+ - If PID file exists but process is dead → stale PID file, clean up and restart
74
+
75
+ ## File Format Support
76
+
77
+ - **Phase 1**: Plain text files (text/plain)
78
+ - **Future**: Markdown rendering, images, PDFs, syntax highlighting, etc.
79
+
80
+ ## Dependencies
81
+
82
+ - `click` — CLI framework
83
+ - `bottle` — Web framework
84
+ - Everything else is Python stdlib (sqlite3, hashlib, os, etc.)
85
+
86
+ ## Project Structure
87
+
88
+ ```
89
+ vibefs/
90
+ ├── PLAN.md # This file
91
+ ├── vibefs.py # All core logic (single file)
92
+ ├── pyproject.toml # Project metadata, dependencies & entry point
93
+ ├── uv.lock # Lock file (auto-generated)
94
+ └── README.md # Usage documentation
95
+ ```
96
+
97
+ ## Packaging & Tooling
98
+
99
+ Single-file core, but proper Python packaging practices:
100
+
101
+ - **uv** as package manager (`uv init`, `uv add`, `uv run`)
102
+ - **pyproject.toml** for metadata, dependencies, and CLI entry point
103
+ - Entry point via `[project.scripts]`: `vibefs = "vibefs:cli"`
104
+ - Install with `uv pip install -e .` or run directly with `uv run vibefs`
105
+ - No `src/` layout — `vibefs.py` sits at project root, keeps it flat and simple
106
+
107
+ ## Design Principles
108
+
109
+ - **Portable**: Single Python file, minimal dependencies
110
+ - **Proper packaging**: uv + pyproject.toml, installable as a real Python package
111
+ - **Secure by default**: Nothing is accessible until explicitly allowed
112
+ - **Ephemeral**: Authorizations expire automatically
113
+ - **Agent-friendly**: CLI output is clean and parseable
vibefs-0.1.0/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # vibefs
2
+
3
+ A file preview server with time-limited access control, designed for AI agents to share local files with users via URLs.
4
+
5
+ Files are not accessible by default. Each file must be explicitly authorized with a TTL (default: 1 hour). The server starts automatically on the first `allow` call and shuts down when all authorizations expire.
6
+
7
+ ## Install
8
+
9
+ With [uv](https://docs.astral.sh/uv/):
10
+
11
+ ```bash
12
+ # Install globally
13
+ uv tool install vibefs
14
+
15
+ # Or run directly without installing
16
+ uvx vibefs --help
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ### Authorize a file
22
+
23
+ ```bash
24
+ vibefs allow /path/to/file.py
25
+ # http://localhost:17173/f/a3b7c2d1/file.py
26
+
27
+ vibefs allow /path/to/file.py --ttl 300 # 5 minutes
28
+ ```
29
+
30
+ The daemon starts automatically if it's not already running.
31
+
32
+ ### Manage authorizations
33
+
34
+ ```bash
35
+ vibefs list # List active authorizations
36
+ vibefs revoke <token> # Revoke a specific authorization
37
+ ```
38
+
39
+ ### Server control
40
+
41
+ ```bash
42
+ vibefs status # Check if daemon is running
43
+ vibefs stop # Stop the daemon
44
+ vibefs serve # Start server in foreground (for debugging)
45
+ ```
46
+
47
+ ### Configuration
48
+
49
+ ```bash
50
+ vibefs config set base_url https://files.example.com
51
+ vibefs config get base_url
52
+ ```
53
+
54
+ When `base_url` is set, the `allow` command outputs URLs using it instead of `localhost:port`:
55
+
56
+ ```bash
57
+ vibefs allow /path/to/file.py
58
+ # https://files.example.com/f/a3b7c2d1/file.py
59
+ ```
60
+
61
+ ## File rendering
62
+
63
+ - Code and text files (`.py`, `.js`, `.md`, `.json`, etc.) are rendered with syntax highlighting via Pygments.
64
+ - Other files are served with their original content type.
65
+
66
+ ## Deploy
67
+
68
+ vibefs listens on `localhost:17173` by default. To make it accessible from the internet, use a tunneling service to map the local port to a public domain.
69
+
70
+ ### Cloudflare Tunnel
71
+
72
+ ```bash
73
+ # Install cloudflared: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/
74
+
75
+ # Quick tunnel (temporary public URL)
76
+ cloudflared tunnel --url http://localhost:17173
77
+
78
+ # Named tunnel (persistent domain)
79
+ cloudflared tunnel create vibefs
80
+ cloudflared tunnel route dns vibefs vibefs.example.com
81
+ cloudflared tunnel run --url http://localhost:17173 vibefs
82
+ ```
83
+
84
+ ### Other options
85
+
86
+ - **ngrok**: `ngrok http 17173`
87
+ - **Tailscale Funnel**: `tailscale funnel 17173`
88
+ - **frp**, **bore**, or any TCP tunneling tool
89
+
90
+ After setting up the tunnel, configure the base URL so generated links use your public domain:
91
+
92
+ ```bash
93
+ vibefs config set base_url https://vibefs.example.com
94
+ ```
95
+
96
+ ## Agent integration
97
+
98
+ To let an AI agent use vibefs, add instructions like the following to its system prompt or tool documentation:
99
+
100
+ ```
101
+ You have access to `vibefs`, a file preview tool. When you want to share a file
102
+ with the user, run:
103
+
104
+ vibefs allow /path/to/file [--ttl SECONDS]
105
+
106
+ This prints a URL. Send the URL to the user — they can open it in a browser to
107
+ view the file. The link expires after the TTL (default: 1 hour).
108
+
109
+ Use this when:
110
+ - Showing code, logs, or config files
111
+ - Sharing generated output
112
+ - Any time a file is easier to read in a browser than in chat
113
+ ```
114
+
115
+ ## State
116
+
117
+ All runtime data is stored in `~/.vibefs/`:
118
+
119
+ - `vibefs.db` — authorization records (SQLite)
120
+ - `vibefs.pid` — daemon PID file
121
+ - `vibefs.log` — daemon log output
122
+ - `config.json` — configuration
123
+
124
+ ## License
125
+
126
+ MIT
@@ -0,0 +1,36 @@
1
+ [project]
2
+ name = "vibefs"
3
+ version = "0.1.0"
4
+ description = "A simple, secure file preview service with time-limited URLs"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.10"
8
+ authors = [
9
+ { name = "reorx", email = "novoreorx@gmail.com" },
10
+ ]
11
+ keywords = ["file-server", "preview", "cli", "agent"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Environment :: Console",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: Internet :: WWW/HTTP :: HTTP Servers",
19
+ "Topic :: Utilities",
20
+ ]
21
+ dependencies = [
22
+ "bottle>=0.13.4",
23
+ "click>=8.3.1",
24
+ "pygments>=2.19.2",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/reorx/vibefs"
29
+ Repository = "https://github.com/reorx/vibefs"
30
+
31
+ [project.scripts]
32
+ vibefs = "vibefs:cli"
33
+
34
+ [build-system]
35
+ requires = ["hatchling"]
36
+ build-backend = "hatchling.build"
vibefs-0.1.0/uv.lock ADDED
@@ -0,0 +1,59 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.14"
4
+
5
+ [[package]]
6
+ name = "bottle"
7
+ version = "0.13.4"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ sdist = { url = "https://files.pythonhosted.org/packages/7a/71/cca6167c06d00c81375fd668719df245864076d284f7cb46a694cbeb5454/bottle-0.13.4.tar.gz", hash = "sha256:787e78327e12b227938de02248333d788cfe45987edca735f8f88e03472c3f47", size = 98717, upload-time = "2025-06-15T10:08:59.439Z" }
10
+ wheels = [
11
+ { url = "https://files.pythonhosted.org/packages/83/f6/b55ec74cfe68c6584163faa311503c20b0da4c09883a41e8e00d6726c954/bottle-0.13.4-py2.py3-none-any.whl", hash = "sha256:045684fbd2764eac9cdeb824861d1551d113e8b683d8d26e296898d3dd99a12e", size = 103807, upload-time = "2025-06-15T10:08:57.691Z" },
12
+ ]
13
+
14
+ [[package]]
15
+ name = "click"
16
+ version = "8.3.1"
17
+ source = { registry = "https://pypi.org/simple" }
18
+ dependencies = [
19
+ { name = "colorama", marker = "sys_platform == 'win32'" },
20
+ ]
21
+ sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
22
+ wheels = [
23
+ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
24
+ ]
25
+
26
+ [[package]]
27
+ name = "colorama"
28
+ version = "0.4.6"
29
+ source = { registry = "https://pypi.org/simple" }
30
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
31
+ wheels = [
32
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
33
+ ]
34
+
35
+ [[package]]
36
+ name = "pygments"
37
+ version = "2.19.2"
38
+ source = { registry = "https://pypi.org/simple" }
39
+ sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
40
+ wheels = [
41
+ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
42
+ ]
43
+
44
+ [[package]]
45
+ name = "vibefs"
46
+ version = "0.1.0"
47
+ source = { editable = "." }
48
+ dependencies = [
49
+ { name = "bottle" },
50
+ { name = "click" },
51
+ { name = "pygments" },
52
+ ]
53
+
54
+ [package.metadata]
55
+ requires-dist = [
56
+ { name = "bottle", specifier = ">=0.13.4" },
57
+ { name = "click", specifier = ">=8.3.1" },
58
+ { name = "pygments", specifier = ">=2.19.2" },
59
+ ]
vibefs-0.1.0/vibefs.py ADDED
@@ -0,0 +1,589 @@
1
+ """vibefs — Vibe File Server
2
+
3
+ A simple, secure file preview service designed for AI agents to share files
4
+ with users via time-limited URLs.
5
+ """
6
+
7
+ import atexit
8
+ import json
9
+ import mimetypes
10
+ import os
11
+ import secrets
12
+ import signal
13
+ import sqlite3
14
+ import subprocess
15
+ import sys
16
+ import threading
17
+ import time
18
+
19
+ import bottle
20
+ import click
21
+
22
+ # --- Constants ---
23
+
24
+ DEFAULT_PORT = 17173
25
+ DEFAULT_HOST = '0.0.0.0'
26
+ DEFAULT_TTL = 3600 # 1 hour
27
+ TOKEN_LENGTH = 4 # bytes, produces 8 hex chars
28
+ CLEANUP_INTERVAL = 60 # seconds between auto-stop checks
29
+
30
+ # --- State Directory ---
31
+
32
+ STATE_DIR = os.path.expanduser('~/.vibefs')
33
+ DB_PATH = os.path.join(STATE_DIR, 'vibefs.db')
34
+ PID_PATH = os.path.join(STATE_DIR, 'vibefs.pid')
35
+ LOG_PATH = os.path.join(STATE_DIR, 'vibefs.log')
36
+ CONFIG_PATH = os.path.join(STATE_DIR, 'config.json')
37
+
38
+
39
+ def ensure_state_dir():
40
+ os.makedirs(STATE_DIR, exist_ok=True)
41
+
42
+
43
+ # --- Config ---
44
+
45
+ def load_config():
46
+ if os.path.isfile(CONFIG_PATH):
47
+ with open(CONFIG_PATH) as f:
48
+ return json.load(f)
49
+ return {}
50
+
51
+
52
+ def save_config(cfg):
53
+ ensure_state_dir()
54
+ with open(CONFIG_PATH, 'w') as f:
55
+ json.dump(cfg, f, indent=2)
56
+ f.write('\n')
57
+
58
+
59
+ # --- Database ---
60
+
61
+
62
+ def get_db_path():
63
+ return os.environ.get('VIBEFS_DB', DB_PATH)
64
+
65
+
66
+ def get_db():
67
+ db = sqlite3.connect(get_db_path())
68
+ db.row_factory = sqlite3.Row
69
+ db.execute("""
70
+ CREATE TABLE IF NOT EXISTS authorizations (
71
+ token TEXT PRIMARY KEY,
72
+ filepath TEXT NOT NULL,
73
+ filename TEXT NOT NULL,
74
+ created_at REAL NOT NULL,
75
+ expires_at REAL NOT NULL
76
+ )
77
+ """)
78
+ db.commit()
79
+ return db
80
+
81
+
82
+ def add_authorization(filepath, ttl):
83
+ """Add an authorization record and return (token, filename)."""
84
+ abs_path = os.path.abspath(filepath)
85
+ if not os.path.isfile(abs_path):
86
+ raise FileNotFoundError(f'File not found: {abs_path}')
87
+
88
+ token = secrets.token_hex(TOKEN_LENGTH)
89
+ filename = os.path.basename(abs_path)
90
+ now = time.time()
91
+
92
+ db = get_db()
93
+ db.execute(
94
+ 'INSERT INTO authorizations (token, filepath, filename, created_at, expires_at) VALUES (?, ?, ?, ?, ?)',
95
+ (token, abs_path, filename, now, now + ttl),
96
+ )
97
+ db.commit()
98
+ db.close()
99
+ return token, filename
100
+
101
+
102
+ def remove_authorization(token):
103
+ """Remove an authorization record. Returns True if it existed."""
104
+ db = get_db()
105
+ cursor = db.execute('DELETE FROM authorizations WHERE token = ?', (token,))
106
+ db.commit()
107
+ deleted = cursor.rowcount > 0
108
+ db.close()
109
+ return deleted
110
+
111
+
112
+ def list_authorizations():
113
+ """Return all authorization records."""
114
+ db = get_db()
115
+ rows = db.execute(
116
+ 'SELECT token, filepath, filename, created_at, expires_at FROM authorizations ORDER BY created_at DESC'
117
+ ).fetchall()
118
+ db.close()
119
+ return rows
120
+
121
+
122
+ def lookup_authorization(token):
123
+ """Look up a token. Returns (row, status) where status is 'valid', 'expired', or 'not_found'."""
124
+ db = get_db()
125
+ row = db.execute(
126
+ 'SELECT token, filepath, filename, created_at, expires_at FROM authorizations WHERE token = ?',
127
+ (token,),
128
+ ).fetchone()
129
+ db.close()
130
+
131
+ if row is None:
132
+ return None, 'not_found'
133
+ if time.time() > row['expires_at']:
134
+ return row, 'expired'
135
+ return row, 'valid'
136
+
137
+
138
+ def has_active_authorizations():
139
+ """Check if there are any non-expired authorizations."""
140
+ db = get_db()
141
+ row = db.execute(
142
+ 'SELECT COUNT(*) as cnt FROM authorizations WHERE expires_at > ?',
143
+ (time.time(),),
144
+ ).fetchone()
145
+ db.close()
146
+ return row['cnt'] > 0
147
+
148
+
149
+ # --- PID File Management ---
150
+
151
+
152
+ def read_pid():
153
+ """Read PID from file. Returns int or None."""
154
+ if not os.path.exists(PID_PATH):
155
+ return None
156
+ with open(PID_PATH) as f:
157
+ content = f.read().strip()
158
+ if not content:
159
+ return None
160
+ return int(content)
161
+
162
+
163
+ def write_pid():
164
+ """Write current process PID to file."""
165
+ ensure_state_dir()
166
+ with open(PID_PATH, 'w') as f:
167
+ f.write(str(os.getpid()))
168
+
169
+
170
+ def remove_pid():
171
+ """Remove PID file if it exists."""
172
+ if os.path.exists(PID_PATH):
173
+ os.remove(PID_PATH)
174
+
175
+
176
+ def is_daemon_running():
177
+ """Check if daemon is alive via PID file. Cleans stale PID files."""
178
+ pid = read_pid()
179
+ if pid is None:
180
+ return False
181
+ try:
182
+ os.kill(pid, 0)
183
+ return True
184
+ except ProcessLookupError:
185
+ # Process doesn't exist — stale PID file
186
+ remove_pid()
187
+ return False
188
+ except PermissionError:
189
+ # Process exists but we can't signal it (different user) — treat as running
190
+ return True
191
+
192
+
193
+ # --- Daemon ---
194
+
195
+
196
+ def start_daemon(port, host):
197
+ """Fork a background daemon process running 'vibefs serve'."""
198
+ ensure_state_dir()
199
+ log_file = open(LOG_PATH, 'a')
200
+ proc = subprocess.Popen(
201
+ [sys.executable, '-m', 'vibefs', 'serve', '--port', str(port), '--host', host],
202
+ stdout=log_file,
203
+ stderr=log_file,
204
+ start_new_session=True,
205
+ )
206
+ log_file.close()
207
+ # Give it a moment to start and write PID
208
+ time.sleep(0.3)
209
+ if proc.poll() is not None:
210
+ click.echo('Warning: daemon process exited immediately, check ~/.vibefs/vibefs.log', err=True)
211
+ else:
212
+ click.echo(f'Daemon started (pid {proc.pid})', err=True)
213
+
214
+
215
+ def stop_daemon():
216
+ """Send SIGTERM to the daemon. Returns True if signal was sent."""
217
+ pid = read_pid()
218
+ if pid is None:
219
+ return False
220
+ try:
221
+ os.kill(pid, signal.SIGTERM)
222
+ return True
223
+ except ProcessLookupError:
224
+ remove_pid()
225
+ return False
226
+
227
+
228
+ # --- Auto-stop Timer ---
229
+
230
+
231
+ def start_cleanup_timer():
232
+ """Start a background thread that exits the server when all authorizations expire."""
233
+
234
+ def check_loop():
235
+ while True:
236
+ time.sleep(CLEANUP_INTERVAL)
237
+ if not has_active_authorizations():
238
+ click.echo('All authorizations expired, shutting down.', err=True)
239
+ remove_pid()
240
+ os._exit(0)
241
+
242
+ t = threading.Thread(target=check_loop, daemon=True)
243
+ t.start()
244
+
245
+
246
+ # --- Renderers ---
247
+
248
+
249
+ class BaseRenderer:
250
+ """Default renderer: returns raw file content with guessed content-type."""
251
+
252
+ def render(self, filepath):
253
+ content_type, _ = mimetypes.guess_type(filepath)
254
+ if content_type is None:
255
+ content_type = 'application/octet-stream'
256
+ bottle.response.content_type = content_type
257
+ with open(filepath, 'rb') as f:
258
+ return f.read()
259
+
260
+
261
+ class CodeRenderer:
262
+ """Renders code files with syntax highlighting via Pygments."""
263
+
264
+ def render(self, filepath):
265
+ from pygments import highlight
266
+ from pygments.formatters import HtmlFormatter
267
+ from pygments.lexers import get_lexer_for_filename, TextLexer
268
+
269
+ with open(filepath) as f:
270
+ code = f.read()
271
+
272
+ try:
273
+ lexer = get_lexer_for_filename(filepath)
274
+ except Exception:
275
+ lexer = TextLexer()
276
+
277
+ formatter = HtmlFormatter(
278
+ style='monokai',
279
+ linenos=False,
280
+ cssclass='highlight',
281
+ )
282
+ highlighted = highlight(code, lexer, formatter)
283
+ css = formatter.get_style_defs('.highlight')
284
+ filename = os.path.basename(filepath)
285
+ stat = os.stat(filepath)
286
+ file_size = _format_size(stat.st_size)
287
+ file_mtime = time.strftime('%Y-%m-%d %H:%M', time.localtime(stat.st_mtime))
288
+
289
+ bottle.response.content_type = 'text/html; charset=utf-8'
290
+ return CODE_HTML_TEMPLATE.format(
291
+ filename=filename,
292
+ file_info=f'{file_size} · {file_mtime}',
293
+ pygments_css=css,
294
+ highlighted=highlighted,
295
+ )
296
+
297
+
298
+ def _format_size(nbytes):
299
+ for unit in ('B', 'KB', 'MB', 'GB'):
300
+ if nbytes < 1024:
301
+ return f'{nbytes:.0f} {unit}' if unit == 'B' else f'{nbytes:.1f} {unit}'
302
+ nbytes /= 1024
303
+ return f'{nbytes:.1f} TB'
304
+
305
+
306
+ CODE_HTML_TEMPLATE = """<!DOCTYPE html>
307
+ <html lang="en">
308
+ <head>
309
+ <meta charset="utf-8">
310
+ <meta name="viewport" content="width=device-width, initial-scale=1">
311
+ <title>{filename}</title>
312
+ <style>
313
+ * {{ margin: 0; padding: 0; box-sizing: border-box; }}
314
+ body {{
315
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
316
+ background: #1e1e1e;
317
+ color: #d4d4d4;
318
+ min-height: 100vh;
319
+ }}
320
+ .file-header {{
321
+ background: #2d2d2d;
322
+ border-bottom: 1px solid #404040;
323
+ padding: 12px 16px;
324
+ font-size: 14px;
325
+ font-weight: 600;
326
+ color: #e0e0e0;
327
+ display: flex;
328
+ justify-content: space-between;
329
+ align-items: center;
330
+ }}
331
+ .file-info {{
332
+ font-size: 12px;
333
+ font-weight: 400;
334
+ color: #888;
335
+ }}
336
+ .file-content {{
337
+ overflow-x: auto;
338
+ }}
339
+ /* Pygments overrides */
340
+ {pygments_css}
341
+ .highlight {{
342
+ background: #1e1e1e;
343
+ padding: 0;
344
+ }}
345
+ .highlight pre {{
346
+ padding: 12px 8px;
347
+ margin: 0;
348
+ font-family: 'SF Mono', 'Menlo', 'Monaco', 'Consolas', 'Liberation Mono', monospace;
349
+ font-size: 15px;
350
+ line-height: 1.6;
351
+ white-space: pre-wrap;
352
+ word-wrap: break-word;
353
+ overflow-wrap: break-word;
354
+ }}
355
+ /* Mobile responsive */
356
+ @media (max-width: 768px) {{
357
+ .file-header {{
358
+ padding: 10px 12px;
359
+ font-size: 13px;
360
+ }}
361
+ .highlight pre {{
362
+ font-size: 14px;
363
+ line-height: 1.5;
364
+ padding: 8px 12px;
365
+ }}
366
+ }}
367
+ </style>
368
+ </head>
369
+ <body>
370
+ <div class="file-header"><span>{filename}</span><span class="file-info">{file_info}</span></div>
371
+ <div class="file-content">
372
+ {highlighted}
373
+ </div>
374
+ </body>
375
+ </html>"""
376
+
377
+
378
+ # Renderer registry: extension -> renderer instance
379
+ _renderers = {}
380
+ _fallback_renderer = BaseRenderer()
381
+
382
+ CODE_EXTENSIONS = [
383
+ '.py', '.js', '.ts', '.jsx', '.tsx', '.go', '.rs', '.rb', '.java',
384
+ '.c', '.cpp', '.h', '.hpp', '.cs', '.swift', '.kt', '.scala',
385
+ '.sh', '.bash', '.zsh', '.fish',
386
+ '.html', '.css', '.scss', '.less',
387
+ '.json', '.yaml', '.yml', '.toml', '.ini', '.cfg',
388
+ '.xml', '.sql', '.graphql',
389
+ '.md', '.rst', '.txt',
390
+ '.lua', '.vim', '.el', '.clj', '.hs', '.ml', '.ex', '.exs',
391
+ '.r', '.R', '.jl', '.pl', '.pm', '.php',
392
+ '.dockerfile', '.makefile', '.cmake',
393
+ '.conf', '.env', '.gitignore',
394
+ ]
395
+
396
+
397
+ def init_renderers():
398
+ """Register renderers for known extensions."""
399
+ code_renderer = CodeRenderer()
400
+ for ext in CODE_EXTENSIONS:
401
+ _renderers[ext] = code_renderer
402
+
403
+
404
+ def get_renderer(filepath):
405
+ """Get the appropriate renderer for a file, falling back to BaseRenderer."""
406
+ _, ext = os.path.splitext(filepath)
407
+ return _renderers.get(ext.lower(), _fallback_renderer)
408
+
409
+
410
+ init_renderers()
411
+
412
+
413
+ # --- Web Server (Bottle) ---
414
+
415
+ app = bottle.Bottle()
416
+
417
+
418
+ @app.route('/f/<token>/<filename>')
419
+ def serve_file(token, filename):
420
+ row, status = lookup_authorization(token)
421
+
422
+ if status == 'not_found':
423
+ bottle.abort(404, 'Not found')
424
+
425
+ if status == 'expired':
426
+ return bottle.template(EXPIRED_TEMPLATE, filename=row['filename'])
427
+
428
+ filepath = row['filepath']
429
+ if not os.path.isfile(filepath):
430
+ bottle.abort(404, 'File no longer exists on disk')
431
+
432
+ renderer = get_renderer(filepath)
433
+ return renderer.render(filepath)
434
+
435
+
436
+ EXPIRED_TEMPLATE = """<!DOCTYPE html>
437
+ <html>
438
+ <head><title>File Expired</title>
439
+ <style>
440
+ body { font-family: system-ui, sans-serif; max-width: 480px; margin: 80px auto; text-align: center; color: #333; }
441
+ h1 { font-size: 1.4em; }
442
+ p { color: #666; }
443
+ </style>
444
+ </head>
445
+ <body>
446
+ <h1>This file is no longer available</h1>
447
+ <p><strong>{{filename}}</strong> has expired and can no longer be accessed.</p>
448
+ </body>
449
+ </html>
450
+ """
451
+
452
+
453
+ # --- CLI (Click) ---
454
+
455
+
456
+ @click.group()
457
+ def cli():
458
+ """vibefs — Vibe File Server
459
+
460
+ A simple, secure file preview service for sharing files via time-limited URLs.
461
+ """
462
+ pass
463
+
464
+
465
+ @cli.command()
466
+ @click.option('--port', default=DEFAULT_PORT, show_default=True, help='Port to listen on')
467
+ @click.option('--host', default=DEFAULT_HOST, show_default=True, help='Host to bind to')
468
+ @click.option('--foreground', is_flag=True, default=False, help='Run in foreground (no PID file cleanup timer)')
469
+ def serve(port, host, foreground):
470
+ """Start the web server."""
471
+ ensure_state_dir()
472
+ write_pid()
473
+ atexit.register(remove_pid)
474
+
475
+ if not foreground:
476
+ start_cleanup_timer()
477
+
478
+ click.echo(f'vibefs serving on http://{host}:{port} (pid {os.getpid()})')
479
+ app.run(host=host, port=port, quiet=True)
480
+
481
+
482
+ @cli.command()
483
+ @click.argument('path')
484
+ @click.option('--ttl', default=DEFAULT_TTL, show_default=True, help='Time-to-live in seconds')
485
+ @click.option('--port', default=DEFAULT_PORT, show_default=True, help='Port for URL generation')
486
+ @click.option('--host', default='localhost', show_default=True, help='Host for URL generation')
487
+ def allow(path, ttl, port, host):
488
+ """Authorize a file for access, auto-start daemon if needed, and print its URL."""
489
+ ensure_state_dir()
490
+ token, filename = add_authorization(path, ttl)
491
+ base_url = load_config().get('base_url')
492
+ if base_url:
493
+ url = f'{base_url.rstrip("/")}/f/{token}/{filename}'
494
+ else:
495
+ url = f'http://{host}:{port}/f/{token}/{filename}'
496
+ click.echo(url)
497
+
498
+ # Auto-start daemon if not running
499
+ if not is_daemon_running():
500
+ start_daemon(port, DEFAULT_HOST)
501
+
502
+
503
+ @cli.command()
504
+ @click.argument('token')
505
+ def revoke(token):
506
+ """Revoke access to a file by its token."""
507
+ if remove_authorization(token):
508
+ click.echo(f'Revoked: {token}')
509
+ else:
510
+ click.echo(f'Token not found: {token}', err=True)
511
+
512
+
513
+ @cli.command('list')
514
+ def list_cmd():
515
+ """List currently authorized files."""
516
+ rows = list_authorizations()
517
+ if not rows:
518
+ click.echo('No active authorizations.')
519
+ return
520
+
521
+ now = time.time()
522
+ for row in rows:
523
+ remaining = row['expires_at'] - now
524
+ if remaining > 0:
525
+ status = f'{int(remaining)}s remaining'
526
+ else:
527
+ status = 'expired'
528
+ click.echo(f' {row["token"]} {row["filepath"]} [{status}]')
529
+
530
+
531
+ @cli.command()
532
+ def stop():
533
+ """Stop the running daemon."""
534
+ if stop_daemon():
535
+ click.echo('Daemon stopped.')
536
+ else:
537
+ click.echo('Daemon is not running.', err=True)
538
+
539
+
540
+ @cli.command()
541
+ def status():
542
+ """Check if the daemon is running."""
543
+ pid = read_pid()
544
+ if is_daemon_running():
545
+ click.echo(f'Daemon is running (pid {pid}).')
546
+ else:
547
+ click.echo('Daemon is not running.')
548
+
549
+
550
+ @cli.group()
551
+ def config():
552
+ """Get or set configuration values."""
553
+ pass
554
+
555
+
556
+ VALID_CONFIG_KEYS = ['base_url']
557
+
558
+
559
+ @config.command('set')
560
+ @click.argument('key')
561
+ @click.argument('value')
562
+ def config_set(key, value):
563
+ """Set a config value (e.g. vibefs config set base_url https://files.example.com)."""
564
+ if key not in VALID_CONFIG_KEYS:
565
+ click.echo(f'Unknown config key: {key}. Valid keys: {", ".join(VALID_CONFIG_KEYS)}', err=True)
566
+ sys.exit(1)
567
+ cfg = load_config()
568
+ cfg[key] = value
569
+ save_config(cfg)
570
+ click.echo(f'{key} = {value}')
571
+
572
+
573
+ @config.command('get')
574
+ @click.argument('key')
575
+ def config_get(key):
576
+ """Get a config value (e.g. vibefs config get base_url)."""
577
+ if key not in VALID_CONFIG_KEYS:
578
+ click.echo(f'Unknown config key: {key}. Valid keys: {", ".join(VALID_CONFIG_KEYS)}', err=True)
579
+ sys.exit(1)
580
+ cfg = load_config()
581
+ value = cfg.get(key)
582
+ if value is None:
583
+ click.echo(f'{key}: (not set)')
584
+ else:
585
+ click.echo(f'{key} = {value}')
586
+
587
+
588
+ if __name__ == '__main__':
589
+ cli()