voop 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024
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.
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # voop
2
+
3
+ Publish files to the web instantly with a single command.
4
+
5
+ ![Demo](demo.gif)
6
+
7
+ ## Why?
8
+
9
+ I use coding agents to create interactive HTML pages instead of static markdown docs. When you can just say "make me a visualization of this data" and get a working HTML page, you need a fast way to share it. This tool lets me go from local file to public URL in one command.
10
+
11
+ ## Alternatives
12
+
13
+ | Tool | Pros | Cons |
14
+ |------|------|------|
15
+ | [Surge.sh](https://surge.sh) | Free, easy, custom domains | Don't own infra, account required |
16
+ | [Vercel CLI](https://vercel.com) | Great DX, preview URLs | Opinionated, expects frameworks |
17
+ | [Netlify CLI](https://netlify.com) | Feature-rich | Overkill for single files |
18
+ | [Cloudflare Pages](https://pages.cloudflare.com) | Fast CDN | Requires git/project setup |
19
+ | GitHub Pages | Free | Requires repo + git push |
20
+
21
+ **Why voop?**
22
+ - You own the infrastructure (your R2 bucket, your rules)
23
+ - No account signup/login each time
24
+ - No git workflow or project structure required
25
+ - Files stay up forever (no expiring preview URLs)
26
+ - Single file → single URL, nothing else
27
+
28
+ ```bash
29
+ npx voop index.html
30
+ # https://your-bucket.r2.dev/index-a1b2c3d4.html
31
+ # (copied)
32
+ ```
33
+
34
+ Uses Cloudflare R2 for storage with free egress and global CDN.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ npx voop <file>
40
+ ```
41
+
42
+ Or install globally:
43
+
44
+ ```bash
45
+ npm install -g voop
46
+ voop <file>
47
+ ```
48
+
49
+ ## Setup
50
+
51
+ On first run, you'll be guided through an interactive setup wizard:
52
+
53
+ ```bash
54
+ npx voop myfile.html
55
+ # No configuration found. Let's set things up first.
56
+ # → Prompts for credentials...
57
+ # → Tests connection...
58
+ # → Proceeds to upload
59
+ ```
60
+
61
+ Before running, you'll need:
62
+
63
+ 1. **Create R2 Bucket**: [Cloudflare Dashboard](https://dash.cloudflare.com) → R2 → Create bucket → Enable public access
64
+ 2. **Create API Token**: R2 → Manage R2 API Tokens → Create with Object Read & Write permissions
65
+
66
+ The wizard will ask for:
67
+ - Cloudflare Account ID
68
+ - R2 Access Key ID
69
+ - R2 Secret Access Key
70
+ - Bucket name
71
+ - Public URL (e.g., `https://pub-xxx.r2.dev`)
72
+
73
+ Config is stored at `~/.config/voop/config.json`.
74
+
75
+ ## Usage
76
+
77
+ ```bash
78
+ voop mypage.html # Upload a file
79
+ voop --setup # Reconfigure credentials
80
+ voop --test # Test R2 connection
81
+ voop --help # Show help
82
+ ```
83
+
84
+ Each file gets a unique URL with a random suffix to avoid conflicts.
85
+
86
+ ## License
87
+
88
+ MIT
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env node
2
+ import { S3Client, PutObjectCommand, HeadBucketCommand } from "@aws-sdk/client-s3";
3
+ import { lookup } from "mime-types";
4
+ import { existsSync, readFileSync, mkdirSync, writeFileSync } from "fs";
5
+ import { basename, extname } from "path";
6
+ import { randomBytes } from "crypto";
7
+ import { spawn } from "child_process";
8
+ import * as p from "@clack/prompts";
9
+ const CONFIG_PATH = `${process.env.HOME}/.config/voop/config.json`;
10
+ function loadConfig() {
11
+ if (!existsSync(CONFIG_PATH)) {
12
+ return null;
13
+ }
14
+ try {
15
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ function isConfigValid(config) {
22
+ if (!config)
23
+ return false;
24
+ return !!(config.accountId &&
25
+ config.accessKeyId &&
26
+ config.secretAccessKey &&
27
+ config.bucketName &&
28
+ config.publicUrl);
29
+ }
30
+ function saveConfig(config) {
31
+ const dir = CONFIG_PATH.replace("/config.json", "");
32
+ mkdirSync(dir, { recursive: true });
33
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
34
+ }
35
+ function createS3Client(config) {
36
+ return new S3Client({
37
+ region: "auto",
38
+ endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`,
39
+ credentials: {
40
+ accessKeyId: config.accessKeyId,
41
+ secretAccessKey: config.secretAccessKey,
42
+ },
43
+ });
44
+ }
45
+ async function testConnection(config) {
46
+ const spinner = p.spinner();
47
+ spinner.start("Testing connection to R2...");
48
+ try {
49
+ const client = createS3Client(config);
50
+ await client.send(new HeadBucketCommand({ Bucket: config.bucketName }));
51
+ spinner.stop("Connection successful!");
52
+ return true;
53
+ }
54
+ catch (error) {
55
+ const message = error instanceof Error ? error.message : "Unknown error";
56
+ spinner.stop(`Connection failed: ${message}`);
57
+ return false;
58
+ }
59
+ }
60
+ async function setup(existingConfig) {
61
+ p.intro("voop Setup");
62
+ p.note(`Before continuing, you'll need:\n` +
63
+ `1. A Cloudflare account with R2 enabled\n` +
64
+ `2. An R2 bucket with public access enabled\n` +
65
+ `3. An R2 API token (create at R2 → Manage R2 API Tokens)\n\n` +
66
+ `Dashboard: https://dash.cloudflare.com → R2 Object Storage`, "Prerequisites");
67
+ const result = await p.group({
68
+ accountId: () => p.text({
69
+ message: "Cloudflare Account ID",
70
+ placeholder: "e.g., 1a2b3c4d5e6f7g8h9i0j",
71
+ initialValue: existingConfig?.accountId || "",
72
+ validate: (v) => (!v ? "Account ID is required" : undefined),
73
+ }),
74
+ accessKeyId: () => p.text({
75
+ message: "R2 Access Key ID",
76
+ placeholder: "e.g., abc123def456...",
77
+ initialValue: existingConfig?.accessKeyId || "",
78
+ validate: (v) => (!v ? "Access Key ID is required" : undefined),
79
+ }),
80
+ secretAccessKey: () => p.password({
81
+ message: "R2 Secret Access Key",
82
+ validate: (v) => (!v && !existingConfig?.secretAccessKey ? "Secret Access Key is required" : undefined),
83
+ }),
84
+ bucketName: () => p.text({
85
+ message: "R2 Bucket Name",
86
+ placeholder: "e.g., my-public-files",
87
+ initialValue: existingConfig?.bucketName || "",
88
+ validate: (v) => (!v ? "Bucket name is required" : undefined),
89
+ }),
90
+ publicUrl: () => p.text({
91
+ message: "Public URL (your bucket's public domain)",
92
+ placeholder: "e.g., https://pub-xxx.r2.dev",
93
+ initialValue: existingConfig?.publicUrl || "",
94
+ validate: (v) => {
95
+ if (!v)
96
+ return "Public URL is required";
97
+ if (!v.startsWith("http"))
98
+ return "URL must start with http:// or https://";
99
+ return undefined;
100
+ },
101
+ }),
102
+ }, {
103
+ onCancel: () => {
104
+ p.cancel("Setup cancelled.");
105
+ return process.exit(0);
106
+ },
107
+ });
108
+ const config = {
109
+ accountId: result.accountId,
110
+ accessKeyId: result.accessKeyId,
111
+ secretAccessKey: result.secretAccessKey || existingConfig?.secretAccessKey || "",
112
+ bucketName: result.bucketName,
113
+ publicUrl: result.publicUrl,
114
+ };
115
+ saveConfig(config);
116
+ console.log("");
117
+ const success = await testConnection(config);
118
+ if (!success) {
119
+ p.log.warning("Setup saved but connection test failed. Run 'voop --setup' to reconfigure.");
120
+ }
121
+ p.outro(`Config saved to ${CONFIG_PATH}`);
122
+ return config;
123
+ }
124
+ function copyToClipboard(text) {
125
+ try {
126
+ const proc = spawn("pbcopy", [], { stdio: ["pipe", "ignore", "ignore"] });
127
+ proc.stdin?.write(text);
128
+ proc.stdin?.end();
129
+ console.log(`(copied)`);
130
+ }
131
+ catch { }
132
+ }
133
+ async function publish(filePath) {
134
+ let config = loadConfig();
135
+ if (!isConfigValid(config)) {
136
+ console.log("No configuration found. Let's set things up first.\n");
137
+ config = await setup(config);
138
+ if (!config) {
139
+ process.exit(1);
140
+ }
141
+ console.log("\nProceeding to upload...\n");
142
+ }
143
+ if (!existsSync(filePath)) {
144
+ console.error(`File not found: ${filePath}`);
145
+ process.exit(1);
146
+ }
147
+ const client = createS3Client(config);
148
+ const fileContent = readFileSync(filePath);
149
+ const ext = extname(filePath);
150
+ const baseName = basename(filePath, ext);
151
+ const shortId = randomBytes(4).toString("hex");
152
+ const key = `${baseName}-${shortId}${ext}`;
153
+ const contentType = lookup(filePath) || "application/octet-stream";
154
+ await client.send(new PutObjectCommand({
155
+ Bucket: config.bucketName,
156
+ Key: key,
157
+ Body: fileContent,
158
+ ContentType: contentType,
159
+ }));
160
+ const publicUrl = config.publicUrl.replace(/\/$/, "");
161
+ const url = `${publicUrl}/${key}`;
162
+ console.log(url);
163
+ copyToClipboard(url);
164
+ }
165
+ const args = process.argv.slice(2);
166
+ if (args.length === 0 || args[0] === "--help" || args[0] === "-h" || args[0] === "help") {
167
+ console.log(`
168
+ voop - Upload files to the web instantly
169
+
170
+ USAGE
171
+ voop <file> Upload a file and get a public URL
172
+ voop --setup Configure or reconfigure credentials
173
+ voop --test Test connection to R2
174
+ voop --help Show this help
175
+
176
+ EXAMPLES
177
+ voop screenshot.png
178
+ voop document.pdf
179
+ voop index.html
180
+
181
+ SETUP
182
+ On first run, you'll be guided through an interactive setup.
183
+ Your credentials are stored at: ~/.config/voop/config.json
184
+
185
+ To reconfigure at any time, run: voop --setup
186
+
187
+ REQUIREMENTS
188
+ - Cloudflare account with R2 Object Storage
189
+ - R2 bucket with public access enabled
190
+ - R2 API token (Access Key ID + Secret Access Key)
191
+
192
+ Create these at: https://dash.cloudflare.com → R2 Object Storage
193
+ `);
194
+ process.exit(0);
195
+ }
196
+ if (args[0] === "--setup" || args[0] === "setup") {
197
+ const existingConfig = loadConfig();
198
+ await setup(existingConfig);
199
+ }
200
+ else if (args[0] === "--test" || args[0] === "test") {
201
+ const config = loadConfig();
202
+ if (!isConfigValid(config)) {
203
+ console.error("Not configured. Run: voop --setup");
204
+ process.exit(1);
205
+ }
206
+ const success = await testConnection(config);
207
+ process.exit(success ? 0 : 1);
208
+ }
209
+ else {
210
+ await publish(args[0]);
211
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "voop",
3
+ "version": "0.1.0",
4
+ "description": "Publish files to the web instantly with a single command using Coudflare R2",
5
+ "author": "Ye Myat Min",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/yemyat/voop"
10
+ },
11
+ "keywords": [
12
+ "cli",
13
+ "publish",
14
+ "cloudflare",
15
+ "r2",
16
+ "static-hosting"
17
+ ],
18
+ "type": "module",
19
+ "bin": {
20
+ "voop": "./dist/index.js"
21
+ },
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "scripts": {
26
+ "build": "tsc",
27
+ "prepublishOnly": "npm run build"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^22.0.0",
31
+ "@types/mime-types": "^2.1.4",
32
+ "typescript": "^5.7.0"
33
+ },
34
+ "engines": {
35
+ "node": ">=18.0.0"
36
+ },
37
+ "dependencies": {
38
+ "@aws-sdk/client-s3": "^3.940.0",
39
+ "@clack/prompts": "^0.11.0",
40
+ "mime-types": "^3.0.2"
41
+ }
42
+ }