@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 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();
@@ -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 };
@@ -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
+ }