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 +21 -0
- package/README.md +88 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +211 -0
- package/package.json +42 -0
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
|
+

|
|
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
|
package/dist/index.d.ts
ADDED
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
|
+
}
|