@sirup/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +155 -0
- package/package.json +34 -0
- package/src/commands/claim.js +49 -0
- package/src/commands/delete.js +48 -0
- package/src/commands/deploy.js +196 -0
- package/src/commands/list.js +75 -0
- package/src/commands/login.js +173 -0
- package/src/commands/logs.js +98 -0
- package/src/commands/status.js +68 -0
- package/src/index.js +28 -0
- package/src/util/api.js +141 -0
- package/src/util/zip.js +143 -0
package/README.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Sirup CLI
|
|
2
|
+
|
|
3
|
+
Deploy anything instantly from the command line — no auth required.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx sirup deploy .
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# Use directly (no install needed)
|
|
13
|
+
npx sirup deploy .
|
|
14
|
+
|
|
15
|
+
# Or install globally
|
|
16
|
+
npm install -g sirup
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Commands
|
|
20
|
+
|
|
21
|
+
### `sirup deploy [directory]`
|
|
22
|
+
|
|
23
|
+
Deploy a directory to sirup.gg. No account needed.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
sirup deploy .
|
|
27
|
+
sirup deploy ./my-app --name my-dashboard
|
|
28
|
+
sirup deploy . --framework react
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**Flags:**
|
|
32
|
+
- `-n, --name <name>` — Display name for the drop
|
|
33
|
+
- `-f, --framework <framework>` — Override auto-detection
|
|
34
|
+
|
|
35
|
+
**What it does:**
|
|
36
|
+
1. Zips the directory (respects `.gitignore` and `.sirupignore`)
|
|
37
|
+
2. Uploads to `POST /v1/deploy`
|
|
38
|
+
3. Streams build logs in real-time (SSE)
|
|
39
|
+
4. Prints the live URL + claim token
|
|
40
|
+
5. Saves claim token to `~/.sirup/tokens.json`
|
|
41
|
+
|
|
42
|
+
### `sirup status <slug>`
|
|
43
|
+
|
|
44
|
+
Check the status of a drop.
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
sirup status k7x9m
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### `sirup logs <slug>`
|
|
51
|
+
|
|
52
|
+
Stream logs from a running drop.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
sirup logs k7x9m # Runtime logs, follow mode
|
|
56
|
+
sirup logs k7x9m --no-follow # Print and exit
|
|
57
|
+
sirup logs k7x9m --type build # Build logs
|
|
58
|
+
sirup logs k7x9m --type access # HTTP access logs
|
|
59
|
+
sirup logs k7x9m --tail 50 # Last 50 lines
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**Flags:**
|
|
63
|
+
- `--follow / --no-follow` — Live stream or print and exit (default: follow)
|
|
64
|
+
- `--tail <n>` — Number of lines from end (default: 200)
|
|
65
|
+
- `--type <type>` — `runtime`, `build`, or `access` (default: runtime)
|
|
66
|
+
|
|
67
|
+
### `sirup claim <slug>`
|
|
68
|
+
|
|
69
|
+
Claim ownership of a drop.
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
sirup claim k7x9m --token ct_a8f29c3e...
|
|
73
|
+
sirup claim k7x9m # Uses saved token from ~/.sirup/tokens.json
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### `sirup delete <slug>`
|
|
77
|
+
|
|
78
|
+
Delete a drop.
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
sirup delete k7x9m
|
|
82
|
+
sirup delete k7x9m --yes # Skip confirmation
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### `sirup list`
|
|
86
|
+
|
|
87
|
+
List your owned drops (requires login).
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
sirup list
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### `sirup login / logout / whoami`
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
sirup login # GitHub device flow auth
|
|
97
|
+
sirup logout # Clear saved credentials
|
|
98
|
+
sirup whoami # Show current user
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## File Ignoring
|
|
102
|
+
|
|
103
|
+
The CLI automatically excludes these from deploys:
|
|
104
|
+
|
|
105
|
+
- `node_modules/`, `.git/`, `.env`, `__pycache__/`, `dist/`, `build/`
|
|
106
|
+
- `.next/`, `.nuxt/`, `.cache/`, `coverage/`, `.venv/`, `venv/`
|
|
107
|
+
- `.DS_Store`, `Thumbs.db`, `*.pyc`
|
|
108
|
+
|
|
109
|
+
Add a `.sirupignore` file for custom patterns (same format as `.gitignore`).
|
|
110
|
+
|
|
111
|
+
## Token Storage
|
|
112
|
+
|
|
113
|
+
- Claim tokens: `~/.sirup/tokens.json`
|
|
114
|
+
- Auth session: `~/.sirup/auth.json`
|
|
115
|
+
|
|
116
|
+
## Environment Variables
|
|
117
|
+
|
|
118
|
+
| Variable | Default | Description |
|
|
119
|
+
|----------|---------|-------------|
|
|
120
|
+
| `SIRUP_API_URL` | `https://api.sirup.gg` | API base URL (for local dev) |
|
|
121
|
+
|
|
122
|
+
## GitHub Secrets Required
|
|
123
|
+
|
|
124
|
+
Set these in repo Settings → Secrets → Actions:
|
|
125
|
+
|
|
126
|
+
| Secret | What it is |
|
|
127
|
+
|--------|-----------|
|
|
128
|
+
| `NPM_TOKEN` | npm access token (Automation type) |
|
|
129
|
+
|
|
130
|
+
## CI/CD
|
|
131
|
+
|
|
132
|
+
Push a version tag triggers npm publish:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
npm version patch # bumps version in package.json
|
|
136
|
+
git push --tags # triggers CI → npm publish
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Project Structure
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
src/
|
|
143
|
+
├── index.js # CLI entry (commander)
|
|
144
|
+
├── commands/
|
|
145
|
+
│ ├── deploy.js # Zip + upload + SSE build logs
|
|
146
|
+
│ ├── status.js # Fetch + display drop info
|
|
147
|
+
│ ├── claim.js # Claim with saved/provided token
|
|
148
|
+
│ ├── logs.js # SSE log streaming with color
|
|
149
|
+
│ ├── delete.js # Delete with confirmation prompt
|
|
150
|
+
│ ├── list.js # Table of owned drops
|
|
151
|
+
│ └── login.js # GitHub device flow + logout + whoami
|
|
152
|
+
└── util/
|
|
153
|
+
├── api.js # API client, token storage (~/.sirup/)
|
|
154
|
+
└── zip.js # Archiver with .gitignore/.sirupignore support
|
|
155
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sirup/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Deploy anything instantly — no auth required. npx sirup deploy .",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"sirup": "./src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src/"
|
|
11
|
+
],
|
|
12
|
+
"keywords": [
|
|
13
|
+
"deploy",
|
|
14
|
+
"hosting",
|
|
15
|
+
"serverless",
|
|
16
|
+
"cli",
|
|
17
|
+
"ai-agents"
|
|
18
|
+
],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/sirup-repos/cli"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"archiver": "^7.0.1",
|
|
29
|
+
"chalk": "^5.4.1",
|
|
30
|
+
"commander": "^13.1.0",
|
|
31
|
+
"eventsource": "^3.0.7",
|
|
32
|
+
"ora": "^8.2.0"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import { request, getClaimToken, getAuthToken } from '../util/api.js';
|
|
4
|
+
|
|
5
|
+
export function registerClaim(program) {
|
|
6
|
+
program
|
|
7
|
+
.command('claim <slug>')
|
|
8
|
+
.description('Claim ownership of a drop')
|
|
9
|
+
.option('-t, --token <token>', 'Claim token (uses saved token if not provided)')
|
|
10
|
+
.action(async (slug, opts) => {
|
|
11
|
+
try {
|
|
12
|
+
await claim(slug, opts);
|
|
13
|
+
} catch (err) {
|
|
14
|
+
console.error(chalk.red(`Error: ${err.message}`));
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function claim(slug, opts) {
|
|
21
|
+
// Resolve claim token: flag > saved > none
|
|
22
|
+
const claimToken = opts.token || getClaimToken(slug);
|
|
23
|
+
|
|
24
|
+
if (!claimToken && !getAuthToken()) {
|
|
25
|
+
console.error(chalk.red('Error: No claim token provided and not logged in.'));
|
|
26
|
+
console.error(chalk.gray(' Use --token <token> or run: sirup login'));
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const spinner = ora(`Claiming ${slug}...`).start();
|
|
31
|
+
|
|
32
|
+
const body = {};
|
|
33
|
+
if (claimToken) {
|
|
34
|
+
body.claim_token = claimToken;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const data = await request('POST', `/drops/${slug}/claim`, {
|
|
38
|
+
body,
|
|
39
|
+
claimToken,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
spinner.succeed(`Claimed ${chalk.bold(slug)} successfully!`);
|
|
43
|
+
|
|
44
|
+
console.log('');
|
|
45
|
+
console.log(` ${chalk.gray('URL:')} ${chalk.cyan(data.drop?.url || `https://${slug}.sirup.gg`)}`);
|
|
46
|
+
console.log(` ${chalk.gray('Owner:')} ${data.drop?.owner_name || 'you'}`);
|
|
47
|
+
console.log(` ${chalk.gray('TTL:')} ${chalk.green('permanent (no expiry)')}`);
|
|
48
|
+
console.log('');
|
|
49
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import { request, getClaimToken } from '../util/api.js';
|
|
4
|
+
|
|
5
|
+
export function registerDelete(program) {
|
|
6
|
+
program
|
|
7
|
+
.command('delete <slug>')
|
|
8
|
+
.description('Delete a drop')
|
|
9
|
+
.option('-t, --token <token>', 'Claim token (uses saved token if not provided)')
|
|
10
|
+
.option('-y, --yes', 'Skip confirmation')
|
|
11
|
+
.action(async (slug, opts) => {
|
|
12
|
+
try {
|
|
13
|
+
await deleteDrop(slug, opts);
|
|
14
|
+
} catch (err) {
|
|
15
|
+
console.error(chalk.red(`Error: ${err.message}`));
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function deleteDrop(slug, opts) {
|
|
22
|
+
// Confirm unless --yes
|
|
23
|
+
if (!opts.yes) {
|
|
24
|
+
const readline = await import('node:readline');
|
|
25
|
+
const rl = readline.createInterface({
|
|
26
|
+
input: process.stdin,
|
|
27
|
+
output: process.stdout,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const answer = await new Promise((resolve) => {
|
|
31
|
+
rl.question(chalk.yellow(`Delete drop "${slug}"? This cannot be undone. [y/N] `), resolve);
|
|
32
|
+
});
|
|
33
|
+
rl.close();
|
|
34
|
+
|
|
35
|
+
if (answer.toLowerCase() !== 'y' && answer.toLowerCase() !== 'yes') {
|
|
36
|
+
console.log(chalk.gray('Cancelled.'));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const claimToken = opts.token || getClaimToken(slug);
|
|
42
|
+
|
|
43
|
+
const spinner = ora(`Deleting ${slug}...`).start();
|
|
44
|
+
|
|
45
|
+
await request('DELETE', `/drops/${slug}`, { claimToken });
|
|
46
|
+
|
|
47
|
+
spinner.succeed(`Deleted ${chalk.bold(slug)}`);
|
|
48
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import { readFileSync, existsSync } from 'fs';
|
|
4
|
+
import { EventSource } from 'eventsource';
|
|
5
|
+
import { zipDirectory } from '../util/zip.js';
|
|
6
|
+
import { request, saveClaimToken, API_BASE } from '../util/api.js';
|
|
7
|
+
|
|
8
|
+
export function registerDeploy(program) {
|
|
9
|
+
program
|
|
10
|
+
.command('deploy [directory]')
|
|
11
|
+
.description('Deploy a directory to sirup.gg')
|
|
12
|
+
.option('-n, --name <name>', 'Display name for the drop')
|
|
13
|
+
.option('-f, --framework <framework>', 'Override framework detection')
|
|
14
|
+
.option('-e, --env <KEY=VALUE...>', 'Set environment variables (repeatable)')
|
|
15
|
+
.option('--env-file <path>', 'Load environment variables from a .env file')
|
|
16
|
+
.action(async (directory = '.', opts) => {
|
|
17
|
+
try {
|
|
18
|
+
await deploy(directory, opts);
|
|
19
|
+
} catch (err) {
|
|
20
|
+
console.error(chalk.red(`\nError: ${err.message}`));
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function deploy(directory, opts) {
|
|
27
|
+
// Collect environment variables
|
|
28
|
+
const envVars = collectEnvVars(opts);
|
|
29
|
+
const envCount = Object.keys(envVars).length;
|
|
30
|
+
|
|
31
|
+
// Step 1: Zip the directory
|
|
32
|
+
const spinner = ora('Packaging files...').start();
|
|
33
|
+
const { buffer, fileCount } = await zipDirectory(directory);
|
|
34
|
+
const sizeMB = (buffer.length / 1024 / 1024).toFixed(2);
|
|
35
|
+
spinner.succeed(`Packaged ${fileCount} files (${sizeMB} MB)`);
|
|
36
|
+
|
|
37
|
+
// Step 2: Upload
|
|
38
|
+
spinner.start('Uploading to sirup.gg...');
|
|
39
|
+
|
|
40
|
+
const formData = new FormData();
|
|
41
|
+
formData.append('files', new Blob([buffer], { type: 'application/zip' }), 'project.zip');
|
|
42
|
+
if (opts.name) formData.append('name', opts.name);
|
|
43
|
+
if (opts.framework) formData.append('framework', opts.framework);
|
|
44
|
+
if (envCount > 0) formData.append('env', JSON.stringify(envVars));
|
|
45
|
+
|
|
46
|
+
const data = await request('POST', '/deploy', { body: formData });
|
|
47
|
+
const drop = data.drop;
|
|
48
|
+
|
|
49
|
+
spinner.succeed('Uploaded successfully');
|
|
50
|
+
|
|
51
|
+
// Save claim token
|
|
52
|
+
if (drop.claim_token) {
|
|
53
|
+
saveClaimToken(drop.slug, drop.claim_token);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
console.log('');
|
|
57
|
+
console.log(chalk.bold(' Drop created:'));
|
|
58
|
+
console.log(` ${chalk.gray('Slug:')} ${drop.slug}`);
|
|
59
|
+
console.log(` ${chalk.gray('URL:')} ${chalk.cyan(drop.url)}`);
|
|
60
|
+
console.log(` ${chalk.gray('Type:')} ${drop.type}`);
|
|
61
|
+
console.log(` ${chalk.gray('Framework:')} ${drop.framework || 'auto-detected'}`);
|
|
62
|
+
console.log(` ${chalk.gray('Status:')} ${formatStatus(drop.status)}`);
|
|
63
|
+
if (envCount > 0) {
|
|
64
|
+
console.log(` ${chalk.gray('Env vars:')} ${envCount} variable${envCount > 1 ? 's' : ''} set`);
|
|
65
|
+
}
|
|
66
|
+
console.log(` ${chalk.gray('Expires:')} ${new Date(drop.expires_at).toLocaleDateString()}`);
|
|
67
|
+
console.log('');
|
|
68
|
+
|
|
69
|
+
if (drop.claim_token) {
|
|
70
|
+
console.log(chalk.yellow(` Claim token: ${drop.claim_token}`));
|
|
71
|
+
console.log(chalk.gray(' (saved to ~/.sirup/tokens.json)'));
|
|
72
|
+
console.log('');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Step 3: Stream build logs if building
|
|
76
|
+
if (drop.status === 'building') {
|
|
77
|
+
console.log(chalk.gray(' Streaming build logs...\n'));
|
|
78
|
+
await streamBuildLogs(drop.slug);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function streamBuildLogs(slug) {
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
const url = `${API_BASE}/v1/drops/${slug}/logs?type=build&follow=true`;
|
|
85
|
+
const es = new EventSource(url);
|
|
86
|
+
|
|
87
|
+
es.onmessage = (event) => {
|
|
88
|
+
try {
|
|
89
|
+
const data = JSON.parse(event.data);
|
|
90
|
+
|
|
91
|
+
if (data.stream === 'stderr') {
|
|
92
|
+
process.stderr.write(chalk.red(data.line) + '\n');
|
|
93
|
+
} else {
|
|
94
|
+
process.stdout.write(chalk.gray(data.line) + '\n');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Check for build completion signals
|
|
98
|
+
if (data.line?.includes('Build complete') || data.line?.includes('Deploy complete')) {
|
|
99
|
+
console.log('');
|
|
100
|
+
console.log(chalk.green(' Build complete! Your drop is live.'));
|
|
101
|
+
es.close();
|
|
102
|
+
resolve();
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// Raw text line
|
|
106
|
+
process.stdout.write(chalk.gray(event.data) + '\n');
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
es.onerror = () => {
|
|
111
|
+
es.close();
|
|
112
|
+
// Build might be done — check status
|
|
113
|
+
checkFinalStatus(slug).then(resolve);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Timeout after 10 minutes
|
|
117
|
+
setTimeout(() => {
|
|
118
|
+
es.close();
|
|
119
|
+
console.log(chalk.yellow('\n Build log stream timed out. Check status with: sirup status ' + slug));
|
|
120
|
+
resolve();
|
|
121
|
+
}, 10 * 60 * 1000);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function checkFinalStatus(slug) {
|
|
126
|
+
try {
|
|
127
|
+
const data = await request('GET', `/drops/${slug}`);
|
|
128
|
+
const status = data.drop.status;
|
|
129
|
+
if (status === 'live') {
|
|
130
|
+
console.log(chalk.green(`\n Drop is live at ${data.drop.url}`));
|
|
131
|
+
} else if (status === 'failed') {
|
|
132
|
+
console.log(chalk.red(`\n Build failed. Check logs: sirup logs ${slug} --type build`));
|
|
133
|
+
} else {
|
|
134
|
+
console.log(chalk.yellow(`\n Status: ${status}. Check again: sirup status ${slug}`));
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
// Ignore
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Collect environment variables from --env and --env-file flags.
|
|
143
|
+
* --env accepts KEY=VALUE pairs (array from commander)
|
|
144
|
+
* --env-file reads a .env file (KEY=VALUE per line, # comments, blank lines skipped)
|
|
145
|
+
*/
|
|
146
|
+
function collectEnvVars(opts) {
|
|
147
|
+
const vars = {};
|
|
148
|
+
|
|
149
|
+
// Load from --env-file first (so --env can override)
|
|
150
|
+
if (opts.envFile) {
|
|
151
|
+
if (!existsSync(opts.envFile)) {
|
|
152
|
+
throw new Error(`Env file not found: ${opts.envFile}`);
|
|
153
|
+
}
|
|
154
|
+
const content = readFileSync(opts.envFile, 'utf-8');
|
|
155
|
+
for (const line of content.split('\n')) {
|
|
156
|
+
const trimmed = line.trim();
|
|
157
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
158
|
+
const eqIndex = trimmed.indexOf('=');
|
|
159
|
+
if (eqIndex === -1) continue;
|
|
160
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
161
|
+
let value = trimmed.slice(eqIndex + 1).trim();
|
|
162
|
+
// Strip surrounding quotes
|
|
163
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
164
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
165
|
+
value = value.slice(1, -1);
|
|
166
|
+
}
|
|
167
|
+
if (key) vars[key] = value;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Load from --env flags (overrides file values)
|
|
172
|
+
if (opts.env && Array.isArray(opts.env)) {
|
|
173
|
+
for (const pair of opts.env) {
|
|
174
|
+
const eqIndex = pair.indexOf('=');
|
|
175
|
+
if (eqIndex === -1) {
|
|
176
|
+
throw new Error(`Invalid env var format: "${pair}" — expected KEY=VALUE`);
|
|
177
|
+
}
|
|
178
|
+
const key = pair.slice(0, eqIndex).trim();
|
|
179
|
+
const value = pair.slice(eqIndex + 1);
|
|
180
|
+
if (key) vars[key] = value;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return vars;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function formatStatus(status) {
|
|
188
|
+
const colors = {
|
|
189
|
+
building: chalk.yellow,
|
|
190
|
+
live: chalk.green,
|
|
191
|
+
failed: chalk.red,
|
|
192
|
+
expired: chalk.gray,
|
|
193
|
+
suspended: chalk.red,
|
|
194
|
+
};
|
|
195
|
+
return (colors[status] || chalk.white)(status);
|
|
196
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import { request, getAuthToken } from '../util/api.js';
|
|
4
|
+
|
|
5
|
+
export function registerList(program) {
|
|
6
|
+
program
|
|
7
|
+
.command('list')
|
|
8
|
+
.description('List your owned drops (requires login)')
|
|
9
|
+
.action(async () => {
|
|
10
|
+
try {
|
|
11
|
+
await list();
|
|
12
|
+
} catch (err) {
|
|
13
|
+
console.error(chalk.red(`Error: ${err.message}`));
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function list() {
|
|
20
|
+
if (!getAuthToken()) {
|
|
21
|
+
console.error(chalk.red('Error: Not logged in. Run: sirup login'));
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const spinner = ora('Fetching your drops...').start();
|
|
26
|
+
const data = await request('GET', '/drops');
|
|
27
|
+
spinner.stop();
|
|
28
|
+
|
|
29
|
+
const drops = data.drops || [];
|
|
30
|
+
|
|
31
|
+
if (drops.length === 0) {
|
|
32
|
+
console.log(chalk.gray('\n No drops found. Deploy something with: sirup deploy .\n'));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.log('');
|
|
37
|
+
console.log(chalk.bold(` Your drops (${drops.length}):`));
|
|
38
|
+
console.log('');
|
|
39
|
+
|
|
40
|
+
// Table header
|
|
41
|
+
console.log(
|
|
42
|
+
` ${pad('SLUG', 12)} ${pad('NAME', 20)} ${pad('STATUS', 10)} ${pad('FRAMEWORK', 12)} ${pad('URL', 30)}`
|
|
43
|
+
);
|
|
44
|
+
console.log(chalk.gray(' ' + '─'.repeat(88)));
|
|
45
|
+
|
|
46
|
+
for (const drop of drops) {
|
|
47
|
+
const status = formatStatus(drop.status);
|
|
48
|
+
const name = drop.name || chalk.gray('—');
|
|
49
|
+
const framework = drop.framework || chalk.gray('—');
|
|
50
|
+
const url = chalk.cyan(`https://${drop.slug}.sirup.gg`);
|
|
51
|
+
|
|
52
|
+
console.log(
|
|
53
|
+
` ${pad(drop.slug, 12)} ${pad(drop.name || '—', 20)} ${pad(drop.status, 10, status)} ${pad(drop.framework || '—', 12)} ${url}`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
console.log('');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function pad(str, len, display) {
|
|
61
|
+
const text = display || str;
|
|
62
|
+
const padding = Math.max(0, len - str.length);
|
|
63
|
+
return text + ' '.repeat(padding);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function formatStatus(status) {
|
|
67
|
+
const colors = {
|
|
68
|
+
building: chalk.yellow,
|
|
69
|
+
live: chalk.green,
|
|
70
|
+
failed: chalk.red,
|
|
71
|
+
expired: chalk.gray,
|
|
72
|
+
suspended: chalk.red,
|
|
73
|
+
};
|
|
74
|
+
return (colors[status] || chalk.white)(status);
|
|
75
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import { saveAuthToken, clearAuthToken, getAuthToken, API_BASE } from '../util/api.js';
|
|
4
|
+
|
|
5
|
+
export function registerLogin(program) {
|
|
6
|
+
program
|
|
7
|
+
.command('login')
|
|
8
|
+
.description('Sign in with GitHub')
|
|
9
|
+
.action(async () => {
|
|
10
|
+
try {
|
|
11
|
+
await login();
|
|
12
|
+
} catch (err) {
|
|
13
|
+
console.error(chalk.red(`Error: ${err.message}`));
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
program
|
|
19
|
+
.command('logout')
|
|
20
|
+
.description('Sign out and clear saved credentials')
|
|
21
|
+
.action(async () => {
|
|
22
|
+
clearAuthToken();
|
|
23
|
+
console.log(chalk.green('Logged out successfully.'));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
program
|
|
27
|
+
.command('whoami')
|
|
28
|
+
.description('Show current logged-in user')
|
|
29
|
+
.action(async () => {
|
|
30
|
+
try {
|
|
31
|
+
await whoami();
|
|
32
|
+
} catch (err) {
|
|
33
|
+
console.error(chalk.red(`Error: ${err.message}`));
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function login() {
|
|
40
|
+
const token = getAuthToken();
|
|
41
|
+
if (token) {
|
|
42
|
+
console.log(chalk.yellow('Already logged in. Run "sirup logout" first to switch accounts.'));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Use GitHub Device Flow for CLI auth
|
|
47
|
+
// Step 1: Request device code from our API
|
|
48
|
+
console.log(chalk.bold('\n Sign in with GitHub\n'));
|
|
49
|
+
|
|
50
|
+
const spinner = ora('Requesting device code...').start();
|
|
51
|
+
|
|
52
|
+
const res = await fetch(`${API_BASE}/v1/auth/device/code`, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: { 'Content-Type': 'application/json' },
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (!res.ok) {
|
|
58
|
+
spinner.fail('Failed to start login flow');
|
|
59
|
+
// Fallback: manual token entry
|
|
60
|
+
console.log(chalk.gray('\n Alternatively, visit the dashboard and copy your API token:\n'));
|
|
61
|
+
console.log(` ${chalk.cyan(`${API_BASE.replace('api.', '')}/dashboard`)}`);
|
|
62
|
+
console.log('');
|
|
63
|
+
|
|
64
|
+
const readline = await import('node:readline');
|
|
65
|
+
const rl = readline.createInterface({
|
|
66
|
+
input: process.stdin,
|
|
67
|
+
output: process.stdout,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const manualToken = await new Promise((resolve) => {
|
|
71
|
+
rl.question(' Paste your API token: ', resolve);
|
|
72
|
+
});
|
|
73
|
+
rl.close();
|
|
74
|
+
|
|
75
|
+
if (manualToken?.trim()) {
|
|
76
|
+
saveAuthToken(manualToken.trim());
|
|
77
|
+
console.log(chalk.green('\n Logged in successfully!'));
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const deviceData = await res.json();
|
|
83
|
+
spinner.stop();
|
|
84
|
+
|
|
85
|
+
console.log(` Open this URL in your browser:\n`);
|
|
86
|
+
console.log(` ${chalk.cyan.bold(deviceData.verification_uri)}\n`);
|
|
87
|
+
console.log(` Enter code: ${chalk.bold.yellow(deviceData.user_code)}\n`);
|
|
88
|
+
|
|
89
|
+
// Try to open the browser automatically
|
|
90
|
+
try {
|
|
91
|
+
const { exec } = await import('node:child_process');
|
|
92
|
+
const platform = process.platform;
|
|
93
|
+
const cmd = platform === 'win32' ? 'start' : platform === 'darwin' ? 'open' : 'xdg-open';
|
|
94
|
+
exec(`${cmd} ${deviceData.verification_uri}`);
|
|
95
|
+
} catch {
|
|
96
|
+
// Ignore — user can open manually
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Step 2: Poll for completion
|
|
100
|
+
const pollSpinner = ora('Waiting for authorization...').start();
|
|
101
|
+
const interval = (deviceData.interval || 5) * 1000;
|
|
102
|
+
const expiresAt = Date.now() + (deviceData.expires_in || 900) * 1000;
|
|
103
|
+
|
|
104
|
+
while (Date.now() < expiresAt) {
|
|
105
|
+
await sleep(interval);
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const pollRes = await fetch(`${API_BASE}/v1/auth/device/token`, {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers: { 'Content-Type': 'application/json' },
|
|
111
|
+
body: JSON.stringify({ device_code: deviceData.device_code }),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (pollRes.ok) {
|
|
115
|
+
const tokenData = await pollRes.json();
|
|
116
|
+
saveAuthToken(tokenData.token);
|
|
117
|
+
pollSpinner.succeed('Logged in successfully!');
|
|
118
|
+
console.log(chalk.gray(`\n Credentials saved to ~/.sirup/auth.json\n`));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const pollError = await pollRes.json();
|
|
123
|
+
if (pollError.error === 'authorization_pending') {
|
|
124
|
+
continue; // Keep polling
|
|
125
|
+
} else if (pollError.error === 'slow_down') {
|
|
126
|
+
await sleep(5000); // Back off
|
|
127
|
+
} else {
|
|
128
|
+
pollSpinner.fail(`Login failed: ${pollError.error}`);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// Network error, keep trying
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
pollSpinner.fail('Login timed out. Please try again.');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function whoami() {
|
|
140
|
+
const token = getAuthToken();
|
|
141
|
+
if (!token) {
|
|
142
|
+
console.log(chalk.yellow('Not logged in. Run: sirup login'));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const spinner = ora('Checking...').start();
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const res = await fetch(`${API_BASE}/v1/auth/me`, {
|
|
150
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
if (!res.ok) {
|
|
154
|
+
spinner.fail('Session expired. Run: sirup login');
|
|
155
|
+
clearAuthToken();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const data = await res.json();
|
|
160
|
+
spinner.stop();
|
|
161
|
+
|
|
162
|
+
console.log('');
|
|
163
|
+
console.log(` ${chalk.gray('User:')} ${chalk.bold(data.user?.name || data.user?.github_username)}`);
|
|
164
|
+
console.log(` ${chalk.gray('Email:')} ${data.user?.email || chalk.gray('—')}`);
|
|
165
|
+
console.log('');
|
|
166
|
+
} catch {
|
|
167
|
+
spinner.fail('Failed to check auth status');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function sleep(ms) {
|
|
172
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
173
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { EventSource } from 'eventsource';
|
|
3
|
+
import { getLogsUrl } from '../util/api.js';
|
|
4
|
+
|
|
5
|
+
export function registerLogs(program) {
|
|
6
|
+
program
|
|
7
|
+
.command('logs <slug>')
|
|
8
|
+
.description('Stream logs from a drop')
|
|
9
|
+
.option('--follow', 'Follow log output (default: true)')
|
|
10
|
+
.option('--no-follow', 'Don\'t follow, just print existing logs')
|
|
11
|
+
.option('--tail <n>', 'Number of lines to show from the end', '200')
|
|
12
|
+
.option('--type <type>', 'Log type: runtime, build, access', 'runtime')
|
|
13
|
+
.action(async (slug, opts) => {
|
|
14
|
+
try {
|
|
15
|
+
await logs(slug, opts);
|
|
16
|
+
} catch (err) {
|
|
17
|
+
console.error(chalk.red(`Error: ${err.message}`));
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function logs(slug, opts) {
|
|
24
|
+
const follow = opts.follow !== false;
|
|
25
|
+
const params = {
|
|
26
|
+
type: opts.type,
|
|
27
|
+
tail: opts.tail,
|
|
28
|
+
follow: follow ? 'true' : 'false',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const url = getLogsUrl(slug, params);
|
|
32
|
+
|
|
33
|
+
console.log(chalk.gray(`Connecting to ${slug} ${opts.type} logs...\n`));
|
|
34
|
+
|
|
35
|
+
const es = new EventSource(url);
|
|
36
|
+
let lineCount = 0;
|
|
37
|
+
|
|
38
|
+
es.onmessage = (event) => {
|
|
39
|
+
try {
|
|
40
|
+
const data = JSON.parse(event.data);
|
|
41
|
+
const ts = data.ts ? chalk.gray(new Date(data.ts).toLocaleTimeString()) + ' ' : '';
|
|
42
|
+
|
|
43
|
+
if (data.stream === 'stderr') {
|
|
44
|
+
process.stderr.write(`${ts}${chalk.red(data.line)}\n`);
|
|
45
|
+
} else if (data.method) {
|
|
46
|
+
// Access log format
|
|
47
|
+
const statusColor = data.status >= 400 ? chalk.red : data.status >= 300 ? chalk.yellow : chalk.green;
|
|
48
|
+
process.stdout.write(
|
|
49
|
+
`${ts}${chalk.blue(data.method)} ${data.path} ${statusColor(data.status)} ${chalk.gray(data.duration)}\n`
|
|
50
|
+
);
|
|
51
|
+
} else {
|
|
52
|
+
process.stdout.write(`${ts}${data.line}\n`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
lineCount++;
|
|
56
|
+
} catch {
|
|
57
|
+
// Raw text
|
|
58
|
+
process.stdout.write(event.data + '\n');
|
|
59
|
+
lineCount++;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
es.onerror = (err) => {
|
|
64
|
+
if (!follow) {
|
|
65
|
+
// Expected close when not following
|
|
66
|
+
es.close();
|
|
67
|
+
if (lineCount === 0) {
|
|
68
|
+
console.log(chalk.yellow('No logs available.'));
|
|
69
|
+
}
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Reconnect for follow mode happens automatically via EventSource
|
|
74
|
+
console.error(chalk.yellow('\nConnection lost, reconnecting...'));
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
es.onopen = () => {
|
|
78
|
+
// Connection established
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Handle Ctrl+C gracefully
|
|
82
|
+
process.on('SIGINT', () => {
|
|
83
|
+
es.close();
|
|
84
|
+
console.log(chalk.gray('\nDisconnected.'));
|
|
85
|
+
process.exit(0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// If not following, set a timeout to close
|
|
89
|
+
if (!follow) {
|
|
90
|
+
setTimeout(() => {
|
|
91
|
+
es.close();
|
|
92
|
+
if (lineCount === 0) {
|
|
93
|
+
console.log(chalk.yellow('No logs available.'));
|
|
94
|
+
}
|
|
95
|
+
process.exit(0);
|
|
96
|
+
}, 5000);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import { request } from '../util/api.js';
|
|
4
|
+
|
|
5
|
+
export function registerStatus(program) {
|
|
6
|
+
program
|
|
7
|
+
.command('status <slug>')
|
|
8
|
+
.description('Check the status of a drop')
|
|
9
|
+
.action(async (slug) => {
|
|
10
|
+
try {
|
|
11
|
+
await status(slug);
|
|
12
|
+
} catch (err) {
|
|
13
|
+
console.error(chalk.red(`Error: ${err.message}`));
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function status(slug) {
|
|
20
|
+
const spinner = ora('Fetching status...').start();
|
|
21
|
+
const data = await request('GET', `/drops/${slug}`);
|
|
22
|
+
spinner.stop();
|
|
23
|
+
|
|
24
|
+
const drop = data.drop;
|
|
25
|
+
|
|
26
|
+
console.log('');
|
|
27
|
+
console.log(chalk.bold(` Drop: ${drop.name || drop.slug}`));
|
|
28
|
+
console.log('');
|
|
29
|
+
console.log(` ${chalk.gray('Slug:')} ${drop.slug}`);
|
|
30
|
+
console.log(` ${chalk.gray('URL:')} ${chalk.cyan(drop.url)}`);
|
|
31
|
+
console.log(` ${chalk.gray('Status:')} ${formatStatus(drop.status)}`);
|
|
32
|
+
console.log(` ${chalk.gray('Type:')} ${drop.type || 'unknown'}`);
|
|
33
|
+
console.log(` ${chalk.gray('Framework:')} ${drop.framework || 'unknown'}`);
|
|
34
|
+
console.log(` ${chalk.gray('Claimed:')} ${drop.claimed ? chalk.green('yes') : chalk.yellow('no')}`);
|
|
35
|
+
|
|
36
|
+
if (drop.owner_name) {
|
|
37
|
+
console.log(` ${chalk.gray('Owner:')} ${drop.owner_name}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
console.log(` ${chalk.gray('Created:')} ${new Date(drop.created_at).toLocaleString()}`);
|
|
41
|
+
|
|
42
|
+
if (drop.expires_at && !drop.claimed) {
|
|
43
|
+
const expires = new Date(drop.expires_at);
|
|
44
|
+
const now = new Date();
|
|
45
|
+
const daysLeft = Math.ceil((expires - now) / (1000 * 60 * 60 * 24));
|
|
46
|
+
const expiresStr = daysLeft > 0
|
|
47
|
+
? chalk.yellow(`${expires.toLocaleDateString()} (${daysLeft} days left)`)
|
|
48
|
+
: chalk.red('expired');
|
|
49
|
+
console.log(` ${chalk.gray('Expires:')} ${expiresStr}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (drop.node_id) {
|
|
53
|
+
console.log(` ${chalk.gray('Node:')} ${drop.node_id}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
console.log('');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function formatStatus(status) {
|
|
60
|
+
const colors = {
|
|
61
|
+
building: chalk.yellow,
|
|
62
|
+
live: chalk.green,
|
|
63
|
+
failed: chalk.red,
|
|
64
|
+
expired: chalk.gray,
|
|
65
|
+
suspended: chalk.red,
|
|
66
|
+
};
|
|
67
|
+
return (colors[status] || chalk.white)(status);
|
|
68
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { registerDeploy } from './commands/deploy.js';
|
|
5
|
+
import { registerStatus } from './commands/status.js';
|
|
6
|
+
import { registerClaim } from './commands/claim.js';
|
|
7
|
+
import { registerLogs } from './commands/logs.js';
|
|
8
|
+
import { registerDelete } from './commands/delete.js';
|
|
9
|
+
import { registerList } from './commands/list.js';
|
|
10
|
+
import { registerLogin } from './commands/login.js';
|
|
11
|
+
|
|
12
|
+
const program = new Command();
|
|
13
|
+
|
|
14
|
+
program
|
|
15
|
+
.name('sirup')
|
|
16
|
+
.description('Deploy anything instantly — no auth required')
|
|
17
|
+
.version('0.1.0');
|
|
18
|
+
|
|
19
|
+
// Register all commands
|
|
20
|
+
registerDeploy(program);
|
|
21
|
+
registerStatus(program);
|
|
22
|
+
registerClaim(program);
|
|
23
|
+
registerLogs(program);
|
|
24
|
+
registerDelete(program);
|
|
25
|
+
registerList(program);
|
|
26
|
+
registerLogin(program); // Also registers logout + whoami
|
|
27
|
+
|
|
28
|
+
program.parse();
|
package/src/util/api.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
|
|
5
|
+
const API_BASE = process.env.SIRUP_API_URL || 'https://api.sirup.gg';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Make an API request to the Sirup API.
|
|
9
|
+
*/
|
|
10
|
+
export async function request(method, endpoint, opts = {}) {
|
|
11
|
+
const url = `${API_BASE}/v1${endpoint}`;
|
|
12
|
+
const headers = { ...opts.headers };
|
|
13
|
+
|
|
14
|
+
// Attach claim token if provided
|
|
15
|
+
if (opts.claimToken) {
|
|
16
|
+
headers['X-Claim-Token'] = opts.claimToken;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Attach auth token if available
|
|
20
|
+
const token = getAuthToken();
|
|
21
|
+
if (token) {
|
|
22
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const fetchOpts = { method, headers };
|
|
26
|
+
|
|
27
|
+
if (opts.body instanceof FormData || opts.body instanceof globalThis.FormData) {
|
|
28
|
+
fetchOpts.body = opts.body;
|
|
29
|
+
// Let fetch set Content-Type with boundary for multipart
|
|
30
|
+
} else if (opts.body) {
|
|
31
|
+
headers['Content-Type'] = 'application/json';
|
|
32
|
+
fetchOpts.body = JSON.stringify(opts.body);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const res = await fetch(url, fetchOpts);
|
|
36
|
+
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
let message;
|
|
39
|
+
try {
|
|
40
|
+
const json = await res.json();
|
|
41
|
+
message = json.error?.message || json.message || res.statusText;
|
|
42
|
+
} catch {
|
|
43
|
+
message = res.statusText;
|
|
44
|
+
}
|
|
45
|
+
const err = new Error(message);
|
|
46
|
+
err.status = res.status;
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const contentType = res.headers.get('content-type') || '';
|
|
51
|
+
if (contentType.includes('application/json')) {
|
|
52
|
+
return res.json();
|
|
53
|
+
}
|
|
54
|
+
return res;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get the SSE URL for log streaming.
|
|
59
|
+
*/
|
|
60
|
+
export function getLogsUrl(slug, params = {}) {
|
|
61
|
+
const url = new URL(`${API_BASE}/v1/drops/${slug}/logs`);
|
|
62
|
+
for (const [key, value] of Object.entries(params)) {
|
|
63
|
+
if (value !== undefined && value !== null) {
|
|
64
|
+
url.searchParams.set(key, String(value));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return url.toString();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --- Token storage ---
|
|
71
|
+
|
|
72
|
+
const TOKEN_DIR = path.join(os.homedir(), '.sirup');
|
|
73
|
+
const TOKEN_FILE = path.join(TOKEN_DIR, 'tokens.json');
|
|
74
|
+
const AUTH_FILE = path.join(TOKEN_DIR, 'auth.json');
|
|
75
|
+
|
|
76
|
+
function ensureDir() {
|
|
77
|
+
if (!fs.existsSync(TOKEN_DIR)) {
|
|
78
|
+
fs.mkdirSync(TOKEN_DIR, { recursive: true });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Save a claim token for a slug.
|
|
84
|
+
*/
|
|
85
|
+
export function saveClaimToken(slug, token) {
|
|
86
|
+
ensureDir();
|
|
87
|
+
const tokens = loadClaimTokens();
|
|
88
|
+
tokens[slug] = token;
|
|
89
|
+
fs.writeFileSync(TOKEN_FILE, JSON.stringify(tokens, null, 2));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Load all saved claim tokens.
|
|
94
|
+
*/
|
|
95
|
+
export function loadClaimTokens() {
|
|
96
|
+
try {
|
|
97
|
+
return JSON.parse(fs.readFileSync(TOKEN_FILE, 'utf-8'));
|
|
98
|
+
} catch {
|
|
99
|
+
return {};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Get a saved claim token for a slug.
|
|
105
|
+
*/
|
|
106
|
+
export function getClaimToken(slug) {
|
|
107
|
+
return loadClaimTokens()[slug] || null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Save auth session token (from login).
|
|
112
|
+
*/
|
|
113
|
+
export function saveAuthToken(token) {
|
|
114
|
+
ensureDir();
|
|
115
|
+
fs.writeFileSync(AUTH_FILE, JSON.stringify({ token }, null, 2));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get saved auth session token.
|
|
120
|
+
*/
|
|
121
|
+
export function getAuthToken() {
|
|
122
|
+
try {
|
|
123
|
+
const data = JSON.parse(fs.readFileSync(AUTH_FILE, 'utf-8'));
|
|
124
|
+
return data.token || null;
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Clear auth session token.
|
|
132
|
+
*/
|
|
133
|
+
export function clearAuthToken() {
|
|
134
|
+
try {
|
|
135
|
+
fs.unlinkSync(AUTH_FILE);
|
|
136
|
+
} catch {
|
|
137
|
+
// ignore
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export { API_BASE };
|
package/src/util/zip.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import archiver from 'archiver';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Default ignore patterns (always excluded).
|
|
7
|
+
*/
|
|
8
|
+
const DEFAULT_IGNORES = [
|
|
9
|
+
'node_modules',
|
|
10
|
+
'.git',
|
|
11
|
+
'.env',
|
|
12
|
+
'.env.local',
|
|
13
|
+
'.env.*.local',
|
|
14
|
+
'.DS_Store',
|
|
15
|
+
'Thumbs.db',
|
|
16
|
+
'__pycache__',
|
|
17
|
+
'*.pyc',
|
|
18
|
+
'.venv',
|
|
19
|
+
'venv',
|
|
20
|
+
'dist',
|
|
21
|
+
'build',
|
|
22
|
+
'.next',
|
|
23
|
+
'.nuxt',
|
|
24
|
+
'.cache',
|
|
25
|
+
'coverage',
|
|
26
|
+
'.sirup',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse a .gitignore/.sirupignore file into an array of patterns.
|
|
31
|
+
*/
|
|
32
|
+
function parseIgnoreFile(filePath) {
|
|
33
|
+
try {
|
|
34
|
+
return fs
|
|
35
|
+
.readFileSync(filePath, 'utf-8')
|
|
36
|
+
.split('\n')
|
|
37
|
+
.map((line) => line.trim())
|
|
38
|
+
.filter((line) => line && !line.startsWith('#'));
|
|
39
|
+
} catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check if a relative path matches any ignore pattern (simple glob matching).
|
|
46
|
+
*/
|
|
47
|
+
function shouldIgnore(relativePath, patterns) {
|
|
48
|
+
const parts = relativePath.split(path.sep);
|
|
49
|
+
|
|
50
|
+
for (const pattern of patterns) {
|
|
51
|
+
// Exact directory name match (e.g., "node_modules")
|
|
52
|
+
if (!pattern.includes('/') && !pattern.includes('*')) {
|
|
53
|
+
if (parts.includes(pattern)) return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Simple wildcard extension match (e.g., "*.pyc")
|
|
57
|
+
if (pattern.startsWith('*.')) {
|
|
58
|
+
const ext = pattern.slice(1); // ".pyc"
|
|
59
|
+
if (relativePath.endsWith(ext)) return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Path prefix match (e.g., "dist/")
|
|
63
|
+
if (pattern.endsWith('/')) {
|
|
64
|
+
const dir = pattern.slice(0, -1);
|
|
65
|
+
if (parts.includes(dir)) return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Exact file match
|
|
69
|
+
if (relativePath === pattern || parts[parts.length - 1] === pattern) {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Collect all files in a directory, respecting ignore patterns.
|
|
79
|
+
* Returns an array of { absolute, relative } paths.
|
|
80
|
+
*/
|
|
81
|
+
function collectFiles(dir, baseDir, patterns) {
|
|
82
|
+
const results = [];
|
|
83
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
84
|
+
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
const abs = path.join(dir, entry.name);
|
|
87
|
+
const rel = path.relative(baseDir, abs);
|
|
88
|
+
|
|
89
|
+
if (shouldIgnore(rel, patterns)) continue;
|
|
90
|
+
|
|
91
|
+
if (entry.isDirectory()) {
|
|
92
|
+
results.push(...collectFiles(abs, baseDir, patterns));
|
|
93
|
+
} else if (entry.isFile() || entry.isSymbolicLink()) {
|
|
94
|
+
results.push({ absolute: abs, relative: rel });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return results;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Create a zip archive of a directory, respecting .gitignore and .sirupignore.
|
|
103
|
+
* Returns a Buffer of the zip contents.
|
|
104
|
+
*/
|
|
105
|
+
export async function zipDirectory(dir) {
|
|
106
|
+
const absDir = path.resolve(dir);
|
|
107
|
+
|
|
108
|
+
if (!fs.existsSync(absDir)) {
|
|
109
|
+
throw new Error(`Directory not found: ${dir}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!fs.statSync(absDir).isDirectory()) {
|
|
113
|
+
throw new Error(`Not a directory: ${dir}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Build ignore patterns
|
|
117
|
+
const patterns = [
|
|
118
|
+
...DEFAULT_IGNORES,
|
|
119
|
+
...parseIgnoreFile(path.join(absDir, '.gitignore')),
|
|
120
|
+
...parseIgnoreFile(path.join(absDir, '.sirupignore')),
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
const files = collectFiles(absDir, absDir, patterns);
|
|
124
|
+
|
|
125
|
+
if (files.length === 0) {
|
|
126
|
+
throw new Error('No files found to deploy (all files matched ignore patterns)');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
const chunks = [];
|
|
131
|
+
const archive = archiver('zip', { zlib: { level: 6 } });
|
|
132
|
+
|
|
133
|
+
archive.on('data', (chunk) => chunks.push(chunk));
|
|
134
|
+
archive.on('end', () => resolve({ buffer: Buffer.concat(chunks), fileCount: files.length }));
|
|
135
|
+
archive.on('error', reject);
|
|
136
|
+
|
|
137
|
+
for (const file of files) {
|
|
138
|
+
archive.file(file.absolute, { name: file.relative });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
archive.finalize();
|
|
142
|
+
});
|
|
143
|
+
}
|