sloss-cli 1.0.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,60 @@
1
+ # sloss-cli
2
+
3
+ Command-line interface for [Sloss](https://github.com/aualdrich/sloss) — a self-hosted IPA/APK distribution server inspired by Diawi.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g sloss-cli
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```bash
14
+ # Authenticate (saves API key to ~/.config/sloss/credentials.json)
15
+ sloss login
16
+
17
+ # List recent builds
18
+ sloss list
19
+
20
+ # Upload an IPA
21
+ sloss upload path/to/App.ipa --platform ios --profile preview
22
+
23
+ # Upload an APK
24
+ sloss upload path/to/app.apk --platform android --profile development
25
+
26
+ # Get build details
27
+ sloss info <build-id>
28
+
29
+ # Delete a build
30
+ sloss delete <build-id>
31
+ ```
32
+
33
+ ## Authentication
34
+
35
+ API key resolution order (highest → lowest priority):
36
+ 1. `--api-key` CLI flag
37
+ 2. `SLOSS_API_KEY` environment variable
38
+ 3. `~/.config/sloss/credentials.json` (saved by `sloss login`)
39
+
40
+ ## Server URL
41
+
42
+ URL resolution order:
43
+ 1. `--url` CLI flag
44
+ 2. `SLOSS_URL` environment variable
45
+ 3. `~/.config/sloss/credentials.json`
46
+ 4. Default: `https://sloss.ngrok.app`
47
+
48
+ ## Global Options
49
+
50
+ ```
51
+ --api-key <key> Override API key
52
+ --url <url> Override server URL
53
+ --json Output as JSON
54
+ --version Show version
55
+ --help Show help
56
+ ```
57
+
58
+ ## License
59
+
60
+ MIT
package/bin/sloss.js ADDED
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Sloss CLI - Command-line interface for Sloss IPA/APK build server
5
+ */
6
+
7
+ import { Command } from 'commander';
8
+ import { resolveConfig, resolveUrl } from '../src/config.js';
9
+ import { loginCommand } from '../src/commands/login.js';
10
+ import { listCommand } from '../src/commands/list.js';
11
+ import { uploadCommand } from '../src/commands/upload.js';
12
+ import { infoCommand } from '../src/commands/info.js';
13
+ import { deleteCommand } from '../src/commands/delete.js';
14
+
15
+ const program = new Command();
16
+
17
+ program
18
+ .name('sloss')
19
+ .description('CLI for Sloss IPA/APK build server')
20
+ .version('1.0.0')
21
+ .option('--api-key <key>', 'API key (overrides SLOSS_API_KEY env var)')
22
+ .option('--url <url>', 'Server URL (overrides SLOSS_URL env var)')
23
+ .option('--json', 'Output as JSON instead of human-readable format');
24
+
25
+ // Login command
26
+ program
27
+ .command('login')
28
+ .description('Authenticate and save your API key to sloss.json')
29
+ .option('--email <email>', 'Email address')
30
+ .option('--password <password>', 'Password (prefer interactive prompt)')
31
+ .action(async (options) => {
32
+ try {
33
+ const baseUrl = resolveUrl(program.opts());
34
+ await loginCommand(options, baseUrl);
35
+ } catch (error) {
36
+ console.error(`Error: ${error.message}`);
37
+ process.exit(1);
38
+ }
39
+ });
40
+
41
+ // List command
42
+ program
43
+ .command('list')
44
+ .description('List recent builds')
45
+ .option('--limit <number>', 'Maximum number of builds to list', '10')
46
+ .action(async (options) => {
47
+ try {
48
+ const config = resolveConfig(program.opts());
49
+ await listCommand(options, config);
50
+ } catch (error) {
51
+ console.error(`Error: ${error.message}`);
52
+ process.exit(1);
53
+ }
54
+ });
55
+
56
+ // Upload command
57
+ program
58
+ .command('upload <file>')
59
+ .description('Upload an IPA or APK file')
60
+ .requiredOption('--platform <platform>', 'Platform (ios or android)')
61
+ .option('--app-name <name>', 'App name')
62
+ .option('--version <version>', 'Version number')
63
+ .option('--build-number <number>', 'Build number')
64
+ .option('--profile <profile>', 'Build profile (development, preview, production)')
65
+ .action(async (file, options) => {
66
+ try {
67
+ const config = resolveConfig(program.opts());
68
+ await uploadCommand(file, options, config);
69
+ } catch (error) {
70
+ console.error(`Error: ${error.message}`);
71
+ process.exit(1);
72
+ }
73
+ });
74
+
75
+ // Info command
76
+ program
77
+ .command('info <build-id>')
78
+ .description('Get details about a specific build')
79
+ .action(async (buildId) => {
80
+ try {
81
+ const config = resolveConfig(program.opts());
82
+ await infoCommand(buildId, config);
83
+ } catch (error) {
84
+ console.error(`Error: ${error.message}`);
85
+ process.exit(1);
86
+ }
87
+ });
88
+
89
+ // Delete command
90
+ program
91
+ .command('delete <build-id>')
92
+ .description('Delete a build')
93
+ .action(async (buildId) => {
94
+ try {
95
+ const config = resolveConfig(program.opts());
96
+ await deleteCommand(buildId, config);
97
+ } catch (error) {
98
+ console.error(`Error: ${error.message}`);
99
+ process.exit(1);
100
+ }
101
+ });
102
+
103
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "sloss-cli",
3
+ "version": "1.0.0",
4
+ "description": "CLI for Sloss — a self-hosted IPA/APK distribution server",
5
+ "type": "module",
6
+ "bin": {
7
+ "sloss": "./bin/sloss.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "README.md"
13
+ ],
14
+ "keywords": ["sloss", "ios", "android", "ipa", "apk", "build", "distribution", "testflight", "diawi"],
15
+ "author": "Front Porch Software",
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/aualdrich/sloss-cli.git"
20
+ },
21
+ "engines": {
22
+ "node": ">=18.0.0"
23
+ },
24
+ "dependencies": {
25
+ "commander": "^12.0.0"
26
+ }
27
+ }
package/src/client.js ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * HTTP client for Sloss API
3
+ * Uses native fetch (Node 18+)
4
+ */
5
+
6
+ export class SlossClient {
7
+ constructor(baseUrl, apiKey) {
8
+ this.baseUrl = baseUrl;
9
+ this.apiKey = apiKey;
10
+ }
11
+
12
+ async request(method, path, body = null) {
13
+ const url = `${this.baseUrl}${path}`;
14
+ const headers = {
15
+ 'Authorization': `Bearer ${this.apiKey}`,
16
+ };
17
+
18
+ const options = { method, headers };
19
+
20
+ if (body && !(body instanceof FormData)) {
21
+ headers['Content-Type'] = 'application/json';
22
+ options.body = JSON.stringify(body);
23
+ } else if (body instanceof FormData) {
24
+ options.body = body;
25
+ // Don't set Content-Type for FormData - fetch will set it with boundary
26
+ }
27
+
28
+ const response = await fetch(url, options);
29
+
30
+ if (!response.ok) {
31
+ let errorMsg = `HTTP ${response.status} ${response.statusText}`;
32
+ try {
33
+ const errorBody = await response.json();
34
+ if (errorBody.error) {
35
+ errorMsg = errorBody.error;
36
+ if (errorBody.detail) errorMsg += `: ${errorBody.detail}`;
37
+ }
38
+ } catch {
39
+ // If JSON parsing fails, use the generic message
40
+ }
41
+ throw new Error(errorMsg);
42
+ }
43
+
44
+ // Handle 204 No Content
45
+ if (response.status === 204) {
46
+ return null;
47
+ }
48
+
49
+ return await response.json();
50
+ }
51
+
52
+ async listUploads(limit = 10) {
53
+ return await this.request('GET', `/api/uploads?limit=${limit}`);
54
+ }
55
+
56
+ async getUpload(id) {
57
+ return await this.request('GET', `/api/uploads/${id}`);
58
+ }
59
+
60
+ async deleteUpload(id) {
61
+ return await this.request('DELETE', `/api/uploads/${id}`);
62
+ }
63
+
64
+ async uploadFile(filePath, platform, metadata = {}) {
65
+ const fs = await import('fs/promises');
66
+ const path = await import('path');
67
+
68
+ // Read file as buffer
69
+ const fileBuffer = await fs.readFile(filePath);
70
+ const fileName = path.basename(filePath);
71
+
72
+ // Create FormData
73
+ const formData = new FormData();
74
+
75
+ // Determine field name based on platform
76
+ const fieldName = platform === 'android' ? 'apk' : 'ipa';
77
+ const blob = new Blob([fileBuffer]);
78
+ formData.append(fieldName, blob, fileName);
79
+
80
+ // Add platform
81
+ formData.append('platform', platform);
82
+
83
+ // Add optional metadata fields
84
+ if (metadata.appName) formData.append('app_name', metadata.appName);
85
+ if (metadata.version) formData.append('version', metadata.version);
86
+ if (metadata.buildNumber) formData.append('build_number', metadata.buildNumber);
87
+ if (metadata.profile) formData.append('profile', metadata.profile);
88
+
89
+ return await this.request('POST', '/upload', formData);
90
+ }
91
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Delete command - Delete a build
3
+ */
4
+
5
+ import { SlossClient } from '../client.js';
6
+ import { formatDelete } from '../format.js';
7
+
8
+ export async function deleteCommand(buildId, config) {
9
+ if (!buildId) {
10
+ throw new Error('Build ID is required');
11
+ }
12
+
13
+ const client = new SlossClient(config.baseUrl, config.apiKey);
14
+ await client.deleteUpload(buildId);
15
+
16
+ console.log(formatDelete(config.jsonMode));
17
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Info command - Get details about a specific build
3
+ */
4
+
5
+ import { SlossClient } from '../client.js';
6
+ import { formatInfo } from '../format.js';
7
+
8
+ export async function infoCommand(buildId, config) {
9
+ if (!buildId) {
10
+ throw new Error('Build ID is required');
11
+ }
12
+
13
+ const client = new SlossClient(config.baseUrl, config.apiKey);
14
+ const upload = await client.getUpload(buildId);
15
+
16
+ console.log(formatInfo(upload, config.jsonMode));
17
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * List command - List recent builds
3
+ */
4
+
5
+ import { SlossClient } from '../client.js';
6
+ import { formatList } from '../format.js';
7
+
8
+ export async function listCommand(options, config) {
9
+ const limit = parseInt(options.limit, 10) || 10;
10
+
11
+ const client = new SlossClient(config.baseUrl, config.apiKey);
12
+ const uploads = await client.listUploads(limit);
13
+
14
+ console.log(formatList(uploads, config.jsonMode));
15
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Login command - Authenticate with Sloss and store API key locally
3
+ *
4
+ * Flow:
5
+ * 1. Prompt for email + password (password hidden)
6
+ * 2. POST /auth/login → Cognito JWT
7
+ * 3. GET /api-keys (with JWT) → user's API key UUID
8
+ * 4. Write API key to sloss.json in cwd
9
+ */
10
+
11
+ import { createInterface } from 'readline';
12
+ import { writeFileSync, mkdirSync } from 'fs';
13
+ import { join } from 'path';
14
+ import { homedir } from 'os';
15
+
16
+ const CREDENTIALS_PATH = join(homedir(), '.config', 'sloss', 'credentials.json');
17
+
18
+ /**
19
+ * Prompt for a value, optionally hiding input (for passwords)
20
+ */
21
+ function prompt(question, hidden = false) {
22
+ return new Promise((resolve) => {
23
+ const rl = createInterface({
24
+ input: process.stdin,
25
+ output: process.stdout,
26
+ });
27
+
28
+ if (hidden) {
29
+ // Suppress echoed characters for password input
30
+ process.stdout.write(question);
31
+
32
+ let value = '';
33
+
34
+ const onData = (char) => {
35
+ char = char.toString();
36
+ if (char === '\n' || char === '\r' || char === '\u0004') {
37
+ // Enter pressed
38
+ process.stdin.setRawMode(false);
39
+ process.stdin.pause();
40
+ process.stdin.removeListener('data', onData);
41
+ process.stdout.write('\n');
42
+ rl.close();
43
+ resolve(value);
44
+ } else if (char === '\u0003') {
45
+ // Ctrl+C
46
+ process.stdout.write('\n');
47
+ process.exit(0);
48
+ } else if (char === '\u007f' || char === '\b') {
49
+ // Backspace
50
+ if (value.length > 0) {
51
+ value = value.slice(0, -1);
52
+ }
53
+ } else {
54
+ value += char;
55
+ }
56
+ };
57
+
58
+ process.stdin.setRawMode(true);
59
+ process.stdin.resume();
60
+ process.stdin.setEncoding('utf8');
61
+ process.stdin.on('data', onData);
62
+ } else {
63
+ rl.question(question, (answer) => {
64
+ rl.close();
65
+ resolve(answer);
66
+ });
67
+ }
68
+ });
69
+ }
70
+
71
+ export async function loginCommand(options, baseUrl) {
72
+ const email = options.email || await prompt('Email: ');
73
+ const password = options.password || await prompt('Password: ', true);
74
+
75
+ // Step 1: Authenticate → get Cognito JWT
76
+ process.stdout.write('Authenticating...');
77
+
78
+ let jwt;
79
+ try {
80
+ const loginRes = await fetch(`${baseUrl}/auth/login`, {
81
+ method: 'POST',
82
+ headers: { 'Content-Type': 'application/json' },
83
+ body: JSON.stringify({ email, password }),
84
+ });
85
+
86
+ const loginBody = await loginRes.json();
87
+
88
+ if (!loginRes.ok || !loginBody.success) {
89
+ process.stdout.write(' ✗\n');
90
+ throw new Error(loginBody.error || `Login failed (HTTP ${loginRes.status})`);
91
+ }
92
+
93
+ jwt = loginBody.token;
94
+ process.stdout.write(' ✓\n');
95
+ } catch (err) {
96
+ if (err.message.includes('fetch')) {
97
+ throw new Error(`Cannot reach Sloss server at ${baseUrl}`);
98
+ }
99
+ throw err;
100
+ }
101
+
102
+ // Step 2: Fetch the user's API key using the JWT
103
+ process.stdout.write('Fetching API key...');
104
+
105
+ let apiKey;
106
+ const keysRes = await fetch(`${baseUrl}/api-keys`, {
107
+ headers: { Authorization: `Bearer ${jwt}` },
108
+ });
109
+
110
+ const keysBody = await keysRes.json();
111
+
112
+ if (!keysRes.ok) {
113
+ process.stdout.write(' ✗\n');
114
+ throw new Error(keysBody.error || `Failed to fetch API key (HTTP ${keysRes.status})`);
115
+ }
116
+
117
+ apiKey = keysBody.apiKey?.id;
118
+
119
+ // If user has no API key yet, generate one
120
+ if (!apiKey) {
121
+ const genRes = await fetch(`${baseUrl}/api-keys/generate`, {
122
+ method: 'POST',
123
+ headers: { Authorization: `Bearer ${jwt}` },
124
+ });
125
+ const genBody = await genRes.json();
126
+ if (!genRes.ok || !genBody.success) {
127
+ process.stdout.write(' ✗\n');
128
+ throw new Error(genBody.error || 'Failed to generate API key');
129
+ }
130
+ apiKey = genBody.apiKey.id;
131
+ }
132
+
133
+ process.stdout.write(' ✓\n');
134
+
135
+ // Step 3: Write credentials to ~/.config/sloss/credentials.json
136
+ const config = { apiKey, url: baseUrl, email };
137
+ mkdirSync(join(homedir(), '.config', 'sloss'), { recursive: true });
138
+ writeFileSync(CREDENTIALS_PATH, JSON.stringify(config, null, 2) + '\n', 'utf8');
139
+
140
+ console.log(`\n✅ Logged in as ${email}`);
141
+ console.log(` API key saved to ${CREDENTIALS_PATH}`);
142
+ console.log(`\n Add to your shell (optional):\n export SLOSS_API_KEY="${apiKey}"`);
143
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Upload command - Upload an IPA or APK
3
+ */
4
+
5
+ import { SlossClient } from '../client.js';
6
+ import { formatUpload } from '../format.js';
7
+
8
+ export async function uploadCommand(filePath, options, config) {
9
+ if (!filePath) {
10
+ throw new Error('File path is required');
11
+ }
12
+
13
+ if (!options.platform) {
14
+ throw new Error('Platform is required (--platform ios or --platform android)');
15
+ }
16
+
17
+ const platform = options.platform.toLowerCase();
18
+ if (!['ios', 'android'].includes(platform)) {
19
+ throw new Error('Platform must be either "ios" or "android"');
20
+ }
21
+
22
+ const metadata = {
23
+ appName: options.appName,
24
+ version: options.version,
25
+ buildNumber: options.buildNumber,
26
+ profile: options.profile,
27
+ };
28
+
29
+ const client = new SlossClient(config.baseUrl, config.apiKey);
30
+ const result = await client.uploadFile(filePath, platform, metadata);
31
+
32
+ console.log(formatUpload(result, config.jsonMode));
33
+ }
package/src/config.js ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Configuration resolver for Sloss CLI
3
+ *
4
+ * API key resolution order (highest → lowest priority):
5
+ * 1. --api-key CLI flag
6
+ * 2. SLOSS_API_KEY environment variable
7
+ * 3. apiKey field in ~/.config/sloss/credentials.json
8
+ *
9
+ * URL resolution order:
10
+ * 1. --url CLI flag
11
+ * 2. SLOSS_URL environment variable
12
+ * 3. url field in ~/.config/sloss/credentials.json
13
+ * 4. Default production URL
14
+ */
15
+
16
+ import { readFileSync } from 'fs';
17
+ import { join } from 'path';
18
+ import { homedir } from 'os';
19
+
20
+ const CREDENTIALS_PATH = join(homedir(), '.config', 'sloss', 'credentials.json');
21
+ const DEFAULT_URL = 'https://sloss.ngrok.app';
22
+
23
+ /**
24
+ * Load and parse ~/.config/sloss/credentials.json, returning {} if not found / invalid.
25
+ */
26
+ function loadCredentials() {
27
+ try {
28
+ const raw = readFileSync(CREDENTIALS_PATH, 'utf8');
29
+ return JSON.parse(raw);
30
+ } catch {
31
+ return {};
32
+ }
33
+ }
34
+
35
+ export function resolveConfig(options = {}) {
36
+ const file = loadCredentials();
37
+
38
+ // API Key: flag > env > sloss.json > error
39
+ const apiKey = options.apiKey || process.env.SLOSS_API_KEY || file.apiKey;
40
+ if (!apiKey) {
41
+ throw new Error(
42
+ 'No API key found.\n' +
43
+ ' Run `sloss login` to authenticate, or\n' +
44
+ ' set SLOSS_API_KEY env var, or\n' +
45
+ ' pass --api-key <key>'
46
+ );
47
+ }
48
+
49
+ // URL: flag > env > sloss.json > default
50
+ const rawUrl = options.url || process.env.SLOSS_URL || file.url || DEFAULT_URL;
51
+ const baseUrl = rawUrl.replace(/\/$/, '');
52
+
53
+ return {
54
+ apiKey,
55
+ baseUrl,
56
+ jsonMode: options.json || false,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Resolve just the base URL (no API key required).
62
+ * Used by commands like `login` that don't have a key yet.
63
+ */
64
+ export function resolveUrl(options = {}) {
65
+ const file = loadCredentials();
66
+ const rawUrl = options.url || process.env.SLOSS_URL || file.url || DEFAULT_URL;
67
+ return rawUrl.replace(/\/$/, '');
68
+ }
package/src/format.js ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Output formatters for Sloss CLI
3
+ * Handles both human-readable table output and JSON mode
4
+ */
5
+
6
+ export function formatList(uploads, jsonMode = false) {
7
+ if (jsonMode) {
8
+ return JSON.stringify(uploads, null, 2);
9
+ }
10
+
11
+ if (uploads.length === 0) {
12
+ return 'No builds found.';
13
+ }
14
+
15
+ // Calculate column widths
16
+ const rows = uploads.map(u => ({
17
+ id: (u.id || '').substring(0, 8),
18
+ platform: u.platform || '—',
19
+ app: u.app_name || '—',
20
+ version: u.version || '—',
21
+ build: u.build_number || '—',
22
+ profile: u.profile || '—',
23
+ uploaded: formatDate(u.uploaded_at || u.created_at),
24
+ }));
25
+
26
+ // Build table
27
+ const header = 'ID PLATFORM APP VERSION BUILD PROFILE UPLOADED';
28
+ const separator = '─'.repeat(header.length);
29
+
30
+ const lines = [header, separator];
31
+
32
+ for (const row of rows) {
33
+ const line = [
34
+ row.id.padEnd(8),
35
+ row.platform.padEnd(8),
36
+ row.app.substring(0, 13).padEnd(13),
37
+ row.version.substring(0, 10).padEnd(10),
38
+ row.build.substring(0, 6).padEnd(6),
39
+ row.profile.substring(0, 12).padEnd(12),
40
+ row.uploaded,
41
+ ].join(' ');
42
+ lines.push(line);
43
+ }
44
+
45
+ return lines.join('\n');
46
+ }
47
+
48
+ export function formatInfo(upload, jsonMode = false) {
49
+ if (jsonMode) {
50
+ return JSON.stringify(upload, null, 2);
51
+ }
52
+
53
+ const lines = [
54
+ `ID: ${upload.id}`,
55
+ `Platform: ${upload.platform || '—'}`,
56
+ `App Name: ${upload.app_name || '—'}`,
57
+ `Bundle ID: ${upload.bundle_id || '—'}`,
58
+ `Version: ${upload.version || '—'}`,
59
+ `Build Number: ${upload.build_number || '—'}`,
60
+ `Profile: ${upload.profile || '—'}`,
61
+ `Uploaded: ${formatDate(upload.uploaded_at || upload.created_at)}`,
62
+ `Page URL: ${upload.pageUrl || '—'}`,
63
+ `Download URL: ${upload.downloadUrl || '—'}`,
64
+ ];
65
+
66
+ return lines.join('\n');
67
+ }
68
+
69
+ export function formatUpload(result, jsonMode = false) {
70
+ if (jsonMode) {
71
+ return JSON.stringify(result, null, 2);
72
+ }
73
+
74
+ const lines = [
75
+ '✅ Upload successful!',
76
+ '',
77
+ `Build ID: ${result.id}`,
78
+ `Page URL: ${result.page_url}`,
79
+ `Install URL: ${result.install_url}`,
80
+ ];
81
+
82
+ return lines.join('\n');
83
+ }
84
+
85
+ export function formatDelete(jsonMode = false) {
86
+ if (jsonMode) {
87
+ return JSON.stringify({ ok: true }, null, 2);
88
+ }
89
+
90
+ return '✅ Build deleted successfully';
91
+ }
92
+
93
+ function formatDate(dateStr) {
94
+ if (!dateStr) return '—';
95
+ try {
96
+ const date = new Date(dateStr);
97
+ return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
98
+ } catch {
99
+ return '—';
100
+ }
101
+ }