@joytreesite/joytree 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.
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+
3
+ const readline = require('readline');
4
+ const { api } = require('../lib/api');
5
+ const ui = require('../lib/ui');
6
+
7
+ async function prompt(q) {
8
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
9
+ return new Promise(r => rl.question(q, a => { rl.close(); r(a.trim()); }));
10
+ }
11
+
12
+ async function list() {
13
+ const spin = ui.spinner('Fetching SSH keys');
14
+ try {
15
+ const data = await api.get('/api/ssh-keys/list');
16
+ spin.stop();
17
+ const keys = Array.isArray(data) ? data : (data.keys || data.sshKeys || []);
18
+ if (!keys.length) {
19
+ ui.info('No SSH keys. Generate one: joytree ssh generate');
20
+ return;
21
+ }
22
+ ui.header(`SSH Keys (${keys.length})`);
23
+ ui.divider();
24
+ keys.forEach(k => {
25
+ console.log(` ${ui.c.bold}${k.name || k.label || k.id}${ui.c.reset} ${ui.c.dim}${k.id}${ui.c.reset}`);
26
+ if (k.fingerprint) console.log(` ${ui.c.dim}fingerprint: ${k.fingerprint}${ui.c.reset}`);
27
+ if (k.createdAt) console.log(` ${ui.c.dim}created: ${new Date(k.createdAt).toLocaleString()}${ui.c.reset}`);
28
+ });
29
+ console.log();
30
+ } catch (err) {
31
+ spin.stop();
32
+ ui.error(`Failed: ${err.message}`);
33
+ process.exit(1);
34
+ }
35
+ }
36
+
37
+ async function generate(opts) {
38
+ let name = opts.name;
39
+ if (!name) {
40
+ name = await prompt(`${ui.c.bold}Key name/label:${ui.c.reset} `);
41
+ if (!name) { ui.error('Name is required.'); process.exit(1); }
42
+ }
43
+ const spin = ui.spinner(`Generating SSH key "${name}"`);
44
+ try {
45
+ const data = await api.post('/api/ssh-keys/generate', { name });
46
+ spin.stop(`SSH key generated!`);
47
+ ui.label('Name', data.name || name);
48
+ ui.label('ID', data.id || data.keyId || '—');
49
+ if (data.publicKey) {
50
+ ui.label('Public Key', '');
51
+ console.log(`\n${ui.c.dim}${data.publicKey}${ui.c.reset}\n`);
52
+ ui.info('Add this public key to your GitHub/GitLab account.');
53
+ }
54
+ } catch (err) {
55
+ spin.stop();
56
+ ui.error(`Failed: ${err.message}`);
57
+ process.exit(1);
58
+ }
59
+ }
60
+
61
+ async function del(keyId, opts) {
62
+ if (!opts.yes) {
63
+ const ans = await prompt(`${ui.c.yellow}Delete SSH key "${keyId}"? Type yes to confirm: ${ui.c.reset}`);
64
+ if (ans.toLowerCase() !== 'yes') { ui.info('Cancelled.'); return; }
65
+ }
66
+ const spin = ui.spinner(`Deleting SSH key ${keyId}`);
67
+ try {
68
+ await api.delete(`/api/ssh-keys/${encodeURIComponent(keyId)}`);
69
+ spin.stop(`SSH key ${ui.c.bold}${keyId}${ui.c.reset} deleted.`);
70
+ } catch (err) {
71
+ spin.stop();
72
+ ui.error(`Failed: ${err.message}`);
73
+ process.exit(1);
74
+ }
75
+ }
76
+
77
+ module.exports = { list, generate, del };
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+
3
+ const { api } = require('../lib/api');
4
+ const ui = require('../lib/ui');
5
+
6
+ async function getSecret() {
7
+ const spin = ui.spinner('Fetching global webhook secret');
8
+ try {
9
+ const data = await api.get('/api/webhook/global-secret');
10
+ spin.stop();
11
+ ui.header('Global Webhook Secret');
12
+ ui.divider();
13
+ ui.label('Secret', data.secret || data.webhookSecret || '—');
14
+ if (data.webhookUrl) ui.label('Webhook URL', data.webhookUrl);
15
+ console.log(`\n ${ui.c.dim}Use this secret to verify incoming webhook payloads from GitHub.${ui.c.reset}\n`);
16
+ } catch (err) {
17
+ spin.stop();
18
+ ui.error(`Failed: ${err.message}`);
19
+ process.exit(1);
20
+ }
21
+ }
22
+
23
+ async function rotateSecret() {
24
+ const spin = ui.spinner('Rotating webhook secret');
25
+ try {
26
+ const data = await api.post('/api/webhook/global-secret/regenerate', {});
27
+ spin.stop(`Webhook secret rotated!`);
28
+ ui.label('New Secret', data.secret || data.webhookSecret || '—');
29
+ ui.warn('Update this secret in your GitHub webhook settings.');
30
+ console.log();
31
+ } catch (err) {
32
+ spin.stop();
33
+ ui.error(`Failed: ${err.message}`);
34
+ process.exit(1);
35
+ }
36
+ }
37
+
38
+ module.exports = { getSecret, rotateSecret };
package/lib/api.js ADDED
@@ -0,0 +1,104 @@
1
+ 'use strict';
2
+
3
+ const https = require('https');
4
+ const http = require('http');
5
+ const { URL } = require('url');
6
+ const config = require('./config');
7
+
8
+ /**
9
+ * Make an authenticated request to the Joytree API.
10
+ * Auth is done via ?api_key=jtk_... (same pattern as /api/v1/transfer).
11
+ * For session-based endpoints we pass token as Bearer header.
12
+ */
13
+ async function request(method, endpoint, body = null, opts = {}) {
14
+ const apiKey = config.getApiKey();
15
+ const baseUrl = config.getBaseUrl();
16
+
17
+ if (!apiKey && !opts.noAuth) {
18
+ throw new Error('Not authenticated. Run: joytree login');
19
+ }
20
+
21
+ const separator = endpoint.includes('?') ? '&' : '?';
22
+ const url = new URL(`${baseUrl}${endpoint}${apiKey ? `${separator}api_key=${encodeURIComponent(apiKey)}` : ''}`);
23
+
24
+ const payload = body ? JSON.stringify(body) : null;
25
+
26
+ return new Promise((resolve, reject) => {
27
+ const mod = url.protocol === 'https:' ? https : http;
28
+ const reqOpts = {
29
+ hostname: url.hostname,
30
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
31
+ path: url.pathname + url.search,
32
+ method: method.toUpperCase(),
33
+ headers: {
34
+ 'Content-Type': 'application/json',
35
+ 'User-Agent': `joytree-cli/${require('../package.json').version}`,
36
+ ...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}),
37
+ ...(opts.headers || {}),
38
+ },
39
+ };
40
+
41
+ const req = mod.request(reqOpts, (res) => {
42
+ let raw = '';
43
+ res.on('data', chunk => raw += chunk);
44
+ res.on('end', () => {
45
+ if (res.statusCode === 204) return resolve({ ok: true });
46
+ try {
47
+ const data = JSON.parse(raw);
48
+ if (res.statusCode >= 400) {
49
+ const msg = data.error || data.message || `HTTP ${res.statusCode}`;
50
+ reject(new Error(msg));
51
+ } else {
52
+ resolve(data);
53
+ }
54
+ } catch {
55
+ reject(new Error(`Non-JSON response (${res.statusCode}): ${raw.slice(0, 200)}`));
56
+ }
57
+ });
58
+ });
59
+
60
+ req.on('error', reject);
61
+ if (payload) req.write(payload);
62
+ req.end();
63
+ });
64
+ }
65
+
66
+ // Convenience wrappers
67
+ const api = {
68
+ get: (path, opts) => request('GET', path, null, opts),
69
+ post: (path, body, opts) => request('POST', path, body, opts),
70
+ put: (path, body, opts) => request('PUT', path, body, opts),
71
+ patch: (path, body, opts) => request('PATCH', path, body, opts),
72
+ delete: (path, opts) => request('DELETE', path, null, opts),
73
+ };
74
+
75
+ /**
76
+ * Validate an API key by calling /api/v1/transfer and reading the response.
77
+ * Returns { ok, email, projectCount } or throws.
78
+ */
79
+ async function validateApiKey(apiKey, baseUrl) {
80
+ const url = `${baseUrl}/api/v1/transfer?api_key=${encodeURIComponent(apiKey)}`;
81
+ const parsed = new URL(url);
82
+ const mod = parsed.protocol === 'https:' ? require('https') : require('http');
83
+
84
+ return new Promise((resolve, reject) => {
85
+ const req = mod.get(url, {
86
+ headers: { 'User-Agent': `joytree-cli/${require('../package.json').version}` }
87
+ }, (res) => {
88
+ let raw = '';
89
+ res.on('data', c => raw += c);
90
+ res.on('end', () => {
91
+ try {
92
+ const data = JSON.parse(raw);
93
+ if (res.statusCode >= 400) reject(new Error(data.error || `HTTP ${res.statusCode}`));
94
+ else resolve(data);
95
+ } catch {
96
+ reject(new Error('Invalid response from server'));
97
+ }
98
+ });
99
+ });
100
+ req.on('error', reject);
101
+ });
102
+ }
103
+
104
+ module.exports = { api, validateApiKey };
package/lib/config.js ADDED
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ const CONFIG_DIR = path.join(os.homedir(), '.joytree');
8
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'credentials.json');
9
+
10
+ function load() {
11
+ try {
12
+ if (!fs.existsSync(CONFIG_FILE)) return {};
13
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
14
+ } catch {
15
+ return {};
16
+ }
17
+ }
18
+
19
+ function save(data) {
20
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
21
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
22
+ }
23
+
24
+ function getApiKey() {
25
+ return load().apiKey || process.env.JOYTREE_API_KEY || null;
26
+ }
27
+
28
+ function getBaseUrl() {
29
+ return load().baseUrl
30
+ || process.env.JOYTREE_BASE_URL
31
+ || 'https://joytree.app';
32
+ }
33
+
34
+ function setCredentials({ apiKey, baseUrl, email }) {
35
+ const existing = load();
36
+ save({ ...existing, apiKey, baseUrl: baseUrl || existing.baseUrl || 'https://joytree.app', email });
37
+ }
38
+
39
+ function clearCredentials() {
40
+ save({});
41
+ }
42
+
43
+ module.exports = { load, save, getApiKey, getBaseUrl, setCredentials, clearCredentials, CONFIG_FILE };
package/lib/ui.js ADDED
@@ -0,0 +1,90 @@
1
+ 'use strict';
2
+
3
+ // ANSI colours (auto-disabled if not a TTY)
4
+ const isTTY = process.stdout.isTTY;
5
+
6
+ const c = {
7
+ reset: isTTY ? '\x1b[0m' : '',
8
+ bold: isTTY ? '\x1b[1m' : '',
9
+ dim: isTTY ? '\x1b[2m' : '',
10
+ green: isTTY ? '\x1b[32m' : '',
11
+ cyan: isTTY ? '\x1b[36m' : '',
12
+ yellow: isTTY ? '\x1b[33m' : '',
13
+ red: isTTY ? '\x1b[31m' : '',
14
+ blue: isTTY ? '\x1b[34m' : '',
15
+ magenta:isTTY ? '\x1b[35m' : '',
16
+ white: isTTY ? '\x1b[37m' : '',
17
+ };
18
+
19
+ function logo() {
20
+ const pkg = require('../package.json');
21
+ console.log('');
22
+ console.log(`${c.bold}${c.white} ██╗ ██████╗ ██╗ ██╗████████╗██████╗ ███████╗███████╗${c.reset}`);
23
+ console.log(`${c.bold}${c.white} ██║██╔═══██╗╚██╗ ██╔╝╚══██╔══╝██╔══██╗██╔════╝██╔════╝${c.reset}`);
24
+ console.log(`${c.bold}${c.white} ██║██║ ██║ ╚████╔╝ ██║ ██████╔╝█████╗ █████╗ ${c.reset}`);
25
+ console.log(`${c.bold}${c.white}██ ██║██║ ██║ ╚██╔╝ ██║ ██╔══██╗██╔══╝ ██╔══╝ ${c.reset}`);
26
+ console.log(`${c.bold}${c.white}╚█████╔╝╚██████╔╝ ██║ ██║ ██║ ██║███████╗███████╗${c.reset}`);
27
+ console.log(`${c.bold}${c.white} ╚════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝${c.reset}`);
28
+ console.log('');
29
+ console.log(` Go live on Joytree in seconds! ${c.dim}v${pkg.version}${c.reset}`);
30
+ console.log(` Website ${c.cyan}https://joytree.app${c.reset}`);
31
+ console.log(` Docs ${c.cyan}https://docs.joytree.app${c.reset}`);
32
+ console.log('');
33
+ }
34
+
35
+ function success(msg) { console.log(`${c.green}✓${c.reset} ${msg}`); }
36
+ function info(msg) { console.log(`${c.cyan}ℹ${c.reset} ${msg}`); }
37
+ function warn(msg) { console.log(`${c.yellow}⚠${c.reset} ${msg}`); }
38
+ function error(msg) { console.error(`${c.red}✗${c.reset} ${c.red}${msg}${c.reset}`); }
39
+ function label(k, v) { console.log(` ${c.bold}${k}${c.reset}${c.dim}:${c.reset} ${v}`); }
40
+ function header(msg) { console.log(`\n${c.bold}${c.cyan}${msg}${c.reset}`); }
41
+ function divider() { console.log(c.dim + '─'.repeat(52) + c.reset); }
42
+
43
+ function table(rows) {
44
+ if (!rows.length) return;
45
+ // Find column widths
46
+ const keys = Object.keys(rows[0]);
47
+ const widths = keys.map(k => Math.max(k.length, ...rows.map(r => String(r[k] ?? '').length)));
48
+ const pad = (s, w) => String(s ?? '').padEnd(w);
49
+ const row = r => keys.map((k, i) => pad(r[k], widths[i])).join(' ');
50
+ const sep = widths.map(w => '─'.repeat(w)).join(' ');
51
+
52
+ console.log(c.bold + row(Object.fromEntries(keys.map(k => [k, k.toUpperCase()]))) + c.reset);
53
+ console.log(c.dim + sep + c.reset);
54
+ rows.forEach(r => console.log(row(r)));
55
+ }
56
+
57
+ function statusBadge(status) {
58
+ const map = {
59
+ success: `${c.green}● success${c.reset}`,
60
+ live: `${c.green}● live${c.reset}`,
61
+ running: `${c.green}● running${c.reset}`,
62
+ failed: `${c.red}● failed${c.reset}`,
63
+ error: `${c.red}● error${c.reset}`,
64
+ stopped: `${c.yellow}● stopped${c.reset}`,
65
+ pending: `${c.yellow}● pending${c.reset}`,
66
+ building:`${c.blue}● building${c.reset}`,
67
+ deploying:`${c.blue}● deploying${c.reset}`,
68
+ };
69
+ return map[String(status).toLowerCase()] || `${c.dim}● ${status}${c.reset}`;
70
+ }
71
+
72
+ function spinner(msg) {
73
+ if (!isTTY) { process.stdout.write(msg + '...\n'); return { stop: () => {} }; }
74
+ const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
75
+ let i = 0;
76
+ process.stdout.write('\x1b[?25l'); // hide cursor
77
+ const iv = setInterval(() => {
78
+ process.stdout.write(`\r${c.cyan}${frames[i++ % frames.length]}${c.reset} ${msg}`);
79
+ }, 80);
80
+ return {
81
+ stop(successMsg) {
82
+ clearInterval(iv);
83
+ process.stdout.write('\x1b[?25h'); // show cursor
84
+ process.stdout.write('\r\x1b[K'); // clear line
85
+ if (successMsg) success(successMsg);
86
+ }
87
+ };
88
+ }
89
+
90
+ module.exports = { c, logo, success, info, warn, error, label, header, divider, table, statusBadge, spinner };
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@joytreesite/joytree",
3
+ "version": "1.0.0",
4
+ "description": "Joytree CLI — deploy and manage your sites from the terminal",
5
+ "main": "bin/joytree.js",
6
+ "bin": {
7
+ "joytree": "bin/joytree.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node bin/joytree.js"
11
+ },
12
+ "keywords": ["joytree", "deploy", "hosting", "cli"],
13
+ "license": "MIT",
14
+ "engines": {
15
+ "node": ">=16.0.0"
16
+ },
17
+ "dependencies": {
18
+ "commander": "^12.1.0"
19
+ }
20
+ }