@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.
- package/README.md +169 -0
- package/bin/joytree.js +277 -0
- package/commands/account.js +46 -0
- package/commands/auth.js +65 -0
- package/commands/db.js +106 -0
- package/commands/deploy.js +123 -0
- package/commands/domains.js +91 -0
- package/commands/env.js +101 -0
- package/commands/extras.js +101 -0
- package/commands/github.js +55 -0
- package/commands/logs.js +68 -0
- package/commands/projects.js +96 -0
- package/commands/ssh.js +77 -0
- package/commands/webhook.js +38 -0
- package/lib/api.js +104 -0
- package/lib/config.js +43 -0
- package/lib/ui.js +90 -0
- package/package.json +20 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const readline = require('readline');
|
|
4
|
+
const { api } = require('../lib/api');
|
|
5
|
+
const config = require('../lib/config');
|
|
6
|
+
const ui = require('../lib/ui');
|
|
7
|
+
|
|
8
|
+
async function prompt(question) {
|
|
9
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
10
|
+
return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans.trim()); }));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function deployGit(opts) {
|
|
14
|
+
const apiKey = config.getApiKey();
|
|
15
|
+
if (!apiKey) { ui.error('Not logged in. Run: joytree login'); process.exit(1); }
|
|
16
|
+
|
|
17
|
+
let { repo, branch, name, build, start, static: isStatic, message } = opts;
|
|
18
|
+
|
|
19
|
+
if (!repo) {
|
|
20
|
+
repo = await prompt(`${ui.c.bold}GitHub repo URL:${ui.c.reset} `);
|
|
21
|
+
if (!repo) { ui.error('Repository URL is required.'); process.exit(1); }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!name) {
|
|
25
|
+
const guessed = repo.split('/').pop().replace(/\.git$/, '').toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
26
|
+
const ans = await prompt(`${ui.c.bold}Project name/subdomain${ui.c.reset} [${guessed}]: `);
|
|
27
|
+
name = ans || guessed;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const body = {
|
|
31
|
+
repoUrl: repo,
|
|
32
|
+
branch: branch || 'main',
|
|
33
|
+
subdomain: name,
|
|
34
|
+
name,
|
|
35
|
+
buildCommand: build || '',
|
|
36
|
+
startCommand: start || '',
|
|
37
|
+
isStatic: !!isStatic,
|
|
38
|
+
message: message || `Deployed via joytree CLI`,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const spin = ui.spinner(`Deploying ${ui.c.bold}${name}${ui.c.reset}`);
|
|
42
|
+
try {
|
|
43
|
+
const data = await api.post('/api/deploy', body);
|
|
44
|
+
spin.stop(`Deploy started!`);
|
|
45
|
+
ui.header('Deployment');
|
|
46
|
+
ui.label('Project', name);
|
|
47
|
+
ui.label('Repo', repo);
|
|
48
|
+
ui.label('Branch', branch || 'main');
|
|
49
|
+
ui.label('Deploy ID', data.deployId || data.id || '—');
|
|
50
|
+
if (data.subdomain || name) {
|
|
51
|
+
const baseUrl = config.getBaseUrl().replace(/^https?:\/\//, '');
|
|
52
|
+
ui.label('URL', `https://${data.subdomain || name}.${baseUrl}`);
|
|
53
|
+
}
|
|
54
|
+
console.log(`\n${ui.c.dim}Watch logs: ${ui.c.cyan}joytree logs ${name} --follow${ui.c.reset}\n`);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
spin.stop();
|
|
57
|
+
ui.error(`Deploy failed: ${err.message}`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function redeploy(projectId) {
|
|
63
|
+
const spin = ui.spinner(`Redeploying ${projectId}`);
|
|
64
|
+
try {
|
|
65
|
+
const data = await api.post(`/api/projects/${projectId}/autodeploy/check`, {});
|
|
66
|
+
spin.stop(`Redeploy triggered for ${ui.c.bold}${projectId}${ui.c.reset}`);
|
|
67
|
+
if (data.deployId) ui.label('Deploy ID', data.deployId);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
// Fallback: try the redeploy-upload endpoint if the check one doesn't apply
|
|
70
|
+
try {
|
|
71
|
+
await api.post(`/api/projects/${projectId}/redeploy-upload`, {});
|
|
72
|
+
spin.stop(`Redeploy triggered for ${ui.c.bold}${projectId}${ui.c.reset}`);
|
|
73
|
+
} catch (e2) {
|
|
74
|
+
spin.stop();
|
|
75
|
+
ui.error(`Redeploy failed: ${err.message}`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function open(projectId) {
|
|
82
|
+
let url;
|
|
83
|
+
if (projectId) {
|
|
84
|
+
const baseUrl = config.getBaseUrl().replace(/^https?:\/\//, '');
|
|
85
|
+
url = `https://${projectId}.${baseUrl}`;
|
|
86
|
+
} else {
|
|
87
|
+
ui.error('Provide a project ID/subdomain. Example: joytree open my-site');
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
ui.info(`Opening ${url}`);
|
|
91
|
+
const { exec } = require('child_process');
|
|
92
|
+
const cmd = process.platform === 'darwin' ? `open "${url}"`
|
|
93
|
+
: process.platform === 'win32' ? `start "${url}"`
|
|
94
|
+
: `xdg-open "${url}"`;
|
|
95
|
+
exec(cmd);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function listDeployments(projectId, opts) {
|
|
99
|
+
const limit = parseInt(opts.limit, 10) || 10;
|
|
100
|
+
const spin = ui.spinner('Fetching deployments');
|
|
101
|
+
try {
|
|
102
|
+
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}&limit=${limit}` : `?limit=${limit}`;
|
|
103
|
+
const data = await api.get(`/api/deployments${query}`);
|
|
104
|
+
spin.stop();
|
|
105
|
+
const items = Array.isArray(data) ? data : (data.deployments || data.items || []);
|
|
106
|
+
if (!items.length) { ui.info('No deployments found.'); return; }
|
|
107
|
+
|
|
108
|
+
ui.header(`Recent Deployments${projectId ? ' — ' + projectId : ''}`);
|
|
109
|
+
ui.divider();
|
|
110
|
+
items.slice(0, limit).forEach(d => {
|
|
111
|
+
const ts = d.createdAt ? new Date(d.createdAt).toLocaleString() : '—';
|
|
112
|
+
console.log(` ${ui.statusBadge(d.status || d.deployStatus)} ${ui.c.bold}${d.projectName || d.subdomain || d.projectId || '—'}${ui.c.reset} ${ui.c.dim}${ts}${ui.c.reset}`);
|
|
113
|
+
if (d.branch) console.log(` ${ui.c.dim}branch: ${d.branch} sha: ${String(d.sha||'').slice(0,7)}${ui.c.reset}`);
|
|
114
|
+
});
|
|
115
|
+
console.log();
|
|
116
|
+
} catch (err) {
|
|
117
|
+
spin.stop();
|
|
118
|
+
ui.error(`Failed: ${err.message}`);
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { deployGit, redeploy, open, listDeployments };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { api } = require('../lib/api');
|
|
4
|
+
const ui = require('../lib/ui');
|
|
5
|
+
|
|
6
|
+
async function list() {
|
|
7
|
+
const spin = ui.spinner('Fetching domains');
|
|
8
|
+
try {
|
|
9
|
+
const data = await api.get('/api/domains/mine');
|
|
10
|
+
spin.stop();
|
|
11
|
+
const items = Array.isArray(data) ? data : (data.domains || []);
|
|
12
|
+
if (!items.length) { ui.info('No custom domains configured.'); return; }
|
|
13
|
+
ui.header(`Custom Domains (${items.length})`);
|
|
14
|
+
ui.divider();
|
|
15
|
+
items.forEach(d => {
|
|
16
|
+
const status = d.verified ? `${ui.c.green}✓ verified${ui.c.reset}` : `${ui.c.yellow}⚠ unverified${ui.c.reset}`;
|
|
17
|
+
console.log(` ${ui.c.bold}${d.domain}${ui.c.reset} ${status}`);
|
|
18
|
+
if (d.projectId || d.subdomain) console.log(` ${ui.c.dim}→ project: ${d.projectId || d.subdomain}${ui.c.reset}`);
|
|
19
|
+
});
|
|
20
|
+
console.log();
|
|
21
|
+
} catch (err) {
|
|
22
|
+
spin.stop();
|
|
23
|
+
ui.error(`Failed: ${err.message}`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function attach(domain, projectId) {
|
|
29
|
+
const spin = ui.spinner(`Attaching ${domain} to ${projectId}`);
|
|
30
|
+
try {
|
|
31
|
+
await api.post('/api/domains/attach', { domain, projectId });
|
|
32
|
+
spin.stop(`Domain ${ui.c.bold}${domain}${ui.c.reset} attached to ${projectId}`);
|
|
33
|
+
ui.info('DNS propagation may take a few minutes.');
|
|
34
|
+
console.log();
|
|
35
|
+
} catch (err) {
|
|
36
|
+
spin.stop();
|
|
37
|
+
ui.error(`Failed: ${err.message}`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function verify(domain) {
|
|
43
|
+
const spin = ui.spinner(`Verifying ${domain}`);
|
|
44
|
+
try {
|
|
45
|
+
const data = await api.post(`/api/domains/${encodeURIComponent(domain)}/verify`, {});
|
|
46
|
+
spin.stop();
|
|
47
|
+
if (data.verified) {
|
|
48
|
+
ui.success(`${domain} is verified!`);
|
|
49
|
+
} else {
|
|
50
|
+
ui.warn(`Verification pending. ${data.message || 'Check your DNS records.'}`);
|
|
51
|
+
}
|
|
52
|
+
console.log();
|
|
53
|
+
} catch (err) {
|
|
54
|
+
spin.stop();
|
|
55
|
+
ui.error(`Failed: ${err.message}`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function remove(domain) {
|
|
61
|
+
const spin = ui.spinner(`Removing ${domain}`);
|
|
62
|
+
try {
|
|
63
|
+
await api.delete(`/api/domains/${encodeURIComponent(domain)}`);
|
|
64
|
+
spin.stop(`Domain ${ui.c.bold}${domain}${ui.c.reset} removed.`);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
spin.stop();
|
|
67
|
+
ui.error(`Failed: ${err.message}`);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function check(domain) {
|
|
73
|
+
const spin = ui.spinner(`Checking availability of ${domain}`);
|
|
74
|
+
try {
|
|
75
|
+
const data = await api.get(`/api/domains/check?domain=${encodeURIComponent(domain)}`);
|
|
76
|
+
spin.stop();
|
|
77
|
+
if (data.available) {
|
|
78
|
+
ui.success(`${ui.c.bold}${domain}${ui.c.reset} is ${ui.c.green}available${ui.c.reset}!`);
|
|
79
|
+
if (data.price) ui.label('Price', data.price);
|
|
80
|
+
} else {
|
|
81
|
+
ui.warn(`${domain} is ${ui.c.red}not available${ui.c.reset}.`);
|
|
82
|
+
}
|
|
83
|
+
console.log();
|
|
84
|
+
} catch (err) {
|
|
85
|
+
spin.stop();
|
|
86
|
+
ui.error(`Failed: ${err.message}`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { list, attach, verify, remove, check };
|
package/commands/env.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const { api } = require('../lib/api');
|
|
5
|
+
const ui = require('../lib/ui');
|
|
6
|
+
|
|
7
|
+
async function list(projectId) {
|
|
8
|
+
const spin = ui.spinner(`Fetching env vars for ${projectId}`);
|
|
9
|
+
try {
|
|
10
|
+
const data = await api.get(`/api/projects/${encodeURIComponent(projectId)}/env`);
|
|
11
|
+
spin.stop();
|
|
12
|
+
const env = data.env || data.envVars || data || {};
|
|
13
|
+
const keys = Object.keys(env);
|
|
14
|
+
if (!keys.length) { ui.info('No env vars set.'); return; }
|
|
15
|
+
ui.header(`Env vars — ${projectId}`);
|
|
16
|
+
ui.divider();
|
|
17
|
+
keys.forEach(k => {
|
|
18
|
+
const v = String(env[k]);
|
|
19
|
+
const masked = v.length > 4 ? v.slice(0,2) + '*'.repeat(Math.min(v.length-2, 10)) : '****';
|
|
20
|
+
console.log(` ${ui.c.bold}${k}${ui.c.reset}=${ui.c.dim}${masked}${ui.c.reset}`);
|
|
21
|
+
});
|
|
22
|
+
console.log(`\n ${ui.c.dim}${keys.length} variable(s)${ui.c.reset}\n`);
|
|
23
|
+
} catch (err) {
|
|
24
|
+
spin.stop();
|
|
25
|
+
ui.error(`Failed: ${err.message}`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function set(projectId, pairs) {
|
|
31
|
+
// pairs is an array like ["KEY=VALUE", "FOO=bar"]
|
|
32
|
+
const env = {};
|
|
33
|
+
for (const pair of pairs) {
|
|
34
|
+
const idx = pair.indexOf('=');
|
|
35
|
+
if (idx < 1) { ui.error(`Invalid format: "${pair}". Use KEY=VALUE`); process.exit(1); }
|
|
36
|
+
env[pair.slice(0, idx)] = pair.slice(idx + 1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const spin = ui.spinner(`Setting ${Object.keys(env).length} env var(s)`);
|
|
40
|
+
try {
|
|
41
|
+
await api.post(`/api/projects/${encodeURIComponent(projectId)}/env`, { env });
|
|
42
|
+
spin.stop(`Env var(s) updated on ${ui.c.bold}${projectId}${ui.c.reset}`);
|
|
43
|
+
Object.keys(env).forEach(k => ui.label(k, '(set)'));
|
|
44
|
+
console.log();
|
|
45
|
+
} catch (err) {
|
|
46
|
+
spin.stop();
|
|
47
|
+
ui.error(`Failed: ${err.message}`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function del(projectId, key) {
|
|
53
|
+
const spin = ui.spinner(`Deleting ${key}`);
|
|
54
|
+
try {
|
|
55
|
+
await api.delete(`/api/projects/${encodeURIComponent(projectId)}/env/${encodeURIComponent(key)}`);
|
|
56
|
+
spin.stop(`Deleted env var ${ui.c.bold}${key}${ui.c.reset} from ${projectId}`);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
spin.stop();
|
|
59
|
+
ui.error(`Failed: ${err.message}`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function push(projectId, opts) {
|
|
65
|
+
const filePath = opts.file || '.env';
|
|
66
|
+
if (!fs.existsSync(filePath)) {
|
|
67
|
+
ui.error(`File not found: ${filePath}`);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
72
|
+
const env = {};
|
|
73
|
+
let count = 0;
|
|
74
|
+
for (const line of raw.split('\n')) {
|
|
75
|
+
const trimmed = line.trim();
|
|
76
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
77
|
+
const idx = trimmed.indexOf('=');
|
|
78
|
+
if (idx < 1) continue;
|
|
79
|
+
env[trimmed.slice(0, idx).trim()] = trimmed.slice(idx + 1).trim().replace(/^["']|["']$/g, '');
|
|
80
|
+
count++;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!count) { ui.warn(`No variables found in ${filePath}`); return; }
|
|
84
|
+
|
|
85
|
+
const spin = ui.spinner(`Pushing ${count} env var(s) from ${filePath}`);
|
|
86
|
+
try {
|
|
87
|
+
if (opts.force) {
|
|
88
|
+
await api.put(`/api/projects/${encodeURIComponent(projectId)}/env`, { env });
|
|
89
|
+
} else {
|
|
90
|
+
await api.post(`/api/projects/${encodeURIComponent(projectId)}/env`, { env });
|
|
91
|
+
}
|
|
92
|
+
spin.stop(`Pushed ${count} env var(s) to ${ui.c.bold}${projectId}${ui.c.reset}`);
|
|
93
|
+
console.log();
|
|
94
|
+
} catch (err) {
|
|
95
|
+
spin.stop();
|
|
96
|
+
ui.error(`Failed: ${err.message}`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { list, set, del, push };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { api } = require('../lib/api');
|
|
4
|
+
const ui = require('../lib/ui');
|
|
5
|
+
|
|
6
|
+
// ── Activity feed ─────────────────────────────────────────────────────
|
|
7
|
+
async function activity(opts) {
|
|
8
|
+
const limit = parseInt(opts.limit, 10) || 20;
|
|
9
|
+
const spin = ui.spinner('Fetching activity');
|
|
10
|
+
try {
|
|
11
|
+
const data = await api.get(`/api/activity?limit=${limit}`);
|
|
12
|
+
spin.stop();
|
|
13
|
+
const items = Array.isArray(data) ? data : (data.activity || data.events || data.items || []);
|
|
14
|
+
if (!items.length) { ui.info('No recent activity.'); return; }
|
|
15
|
+
ui.header('Recent Activity');
|
|
16
|
+
ui.divider();
|
|
17
|
+
items.slice(0, limit).forEach(a => {
|
|
18
|
+
const ts = a.createdAt || a.timestamp ? `${ui.c.dim}${new Date(a.createdAt || a.timestamp).toLocaleString()}${ui.c.reset}` : '';
|
|
19
|
+
const type = a.type || a.event || '';
|
|
20
|
+
const project = a.projectName || a.subdomain || a.projectId || '';
|
|
21
|
+
console.log(` ${ui.c.bold}${type}${ui.c.reset}${project ? ' ' + project : ''} ${ts}`);
|
|
22
|
+
if (a.message || a.description) console.log(` ${ui.c.dim}${a.message || a.description}${ui.c.reset}`);
|
|
23
|
+
});
|
|
24
|
+
console.log();
|
|
25
|
+
} catch (err) {
|
|
26
|
+
spin.stop();
|
|
27
|
+
ui.error(`Failed: ${err.message}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Stop a running deployment ─────────────────────────────────────────
|
|
33
|
+
async function stopDeploy(deployId) {
|
|
34
|
+
const spin = ui.spinner(`Stopping deployment ${deployId}`);
|
|
35
|
+
try {
|
|
36
|
+
await api.post(`/api/deploy/${encodeURIComponent(deployId)}/stop`, {});
|
|
37
|
+
spin.stop(`Deployment ${ui.c.bold}${deployId}${ui.c.reset} stopped.`);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
spin.stop();
|
|
40
|
+
ui.error(`Failed: ${err.message}`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Autodeploy toggle ─────────────────────────────────────────────────
|
|
46
|
+
async function autodeploy(projectId, opts) {
|
|
47
|
+
const enable = opts.enable !== undefined ? true : opts.disable !== undefined ? false : null;
|
|
48
|
+
if (enable === null) {
|
|
49
|
+
ui.error('Specify --enable or --disable. Example: joytree autodeploy my-site --enable');
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
const spin = ui.spinner(`${enable ? 'Enabling' : 'Disabling'} auto-deploy for ${projectId}`);
|
|
53
|
+
try {
|
|
54
|
+
await api.post(`/api/projects/${encodeURIComponent(projectId)}/autodeploy`, { enabled: enable });
|
|
55
|
+
spin.stop(`Auto-deploy ${enable ? ui.c.green + 'enabled' : ui.c.yellow + 'disabled'}${ui.c.reset} for ${ui.c.bold}${projectId}${ui.c.reset}`);
|
|
56
|
+
console.log();
|
|
57
|
+
} catch (err) {
|
|
58
|
+
spin.stop();
|
|
59
|
+
ui.error(`Failed: ${err.message}`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── API key management ────────────────────────────────────────────────
|
|
65
|
+
async function apiKey() {
|
|
66
|
+
const spin = ui.spinner('Fetching API key');
|
|
67
|
+
try {
|
|
68
|
+
const data = await api.get('/api/account/api-key');
|
|
69
|
+
spin.stop();
|
|
70
|
+
ui.header('API Key');
|
|
71
|
+
ui.divider();
|
|
72
|
+
ui.label('Key', data.key || '—');
|
|
73
|
+
ui.label('Created', data.createdAt ? new Date(data.createdAt).toLocaleString() : '—');
|
|
74
|
+
ui.label('Last Used', data.lastUsed ? new Date(data.lastUsed).toLocaleString() : 'never');
|
|
75
|
+
ui.label('Status', data.disabled ? `${ui.c.red}disabled${ui.c.reset}` : `${ui.c.green}active${ui.c.reset}`);
|
|
76
|
+
ui.label('Projects', String(data.projectCount || 0));
|
|
77
|
+
if (data.transferUrl) ui.label('Transfer URL', data.transferUrl);
|
|
78
|
+
console.log(`\n ${ui.c.dim}To rotate your key run: joytree apikey rotate${ui.c.reset}\n`);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
spin.stop();
|
|
81
|
+
ui.error(`Failed: ${err.message}`);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function rotateApiKey() {
|
|
87
|
+
const spin = ui.spinner('Rotating API key');
|
|
88
|
+
try {
|
|
89
|
+
const data = await api.post('/api/account/api-key/rotate', {});
|
|
90
|
+
spin.stop(`API key rotated!`);
|
|
91
|
+
ui.label('New Key', data.key || '—');
|
|
92
|
+
ui.warn('Your old key is now revoked. Update your CLI: joytree login --api-key <new-key>');
|
|
93
|
+
console.log();
|
|
94
|
+
} catch (err) {
|
|
95
|
+
spin.stop();
|
|
96
|
+
ui.error(`Failed: ${err.message}`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { activity, stopDeploy, autodeploy, apiKey, rotateApiKey };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { api } = require('../lib/api');
|
|
4
|
+
const ui = require('../lib/ui');
|
|
5
|
+
|
|
6
|
+
async function repos(opts) {
|
|
7
|
+
const spin = ui.spinner('Fetching your GitHub repositories');
|
|
8
|
+
try {
|
|
9
|
+
const data = await api.get('/api/github/repos');
|
|
10
|
+
spin.stop();
|
|
11
|
+
const items = Array.isArray(data) ? data : (data.repos || data.repositories || []);
|
|
12
|
+
if (!items.length) {
|
|
13
|
+
ui.warn('No GitHub repos found. Make sure your GitHub account is linked on the dashboard.');
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
ui.header(`GitHub Repositories (${items.length})`);
|
|
17
|
+
ui.divider();
|
|
18
|
+
items.forEach(r => {
|
|
19
|
+
const visibility = r.private ? `${ui.c.dim}private${ui.c.reset}` : `${ui.c.green}public${ui.c.reset}`;
|
|
20
|
+
console.log(` ${ui.c.bold}${r.full_name || r.name}${ui.c.reset} [${visibility}]`);
|
|
21
|
+
if (r.description) console.log(` ${ui.c.dim}${r.description}${ui.c.reset}`);
|
|
22
|
+
if (r.default_branch) console.log(` ${ui.c.dim}default branch: ${r.default_branch}${ui.c.reset}`);
|
|
23
|
+
});
|
|
24
|
+
console.log(`\n ${ui.c.dim}Run: joytree deploy --repo <url> to deploy any repo above.${ui.c.reset}\n`);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
spin.stop();
|
|
27
|
+
ui.error(`Failed: ${err.message}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function branches(repoUrl) {
|
|
33
|
+
if (!repoUrl) { ui.error('Provide a repo URL. Example: joytree pull branches https://github.com/you/repo'); process.exit(1); }
|
|
34
|
+
const spin = ui.spinner(`Fetching branches for ${repoUrl}`);
|
|
35
|
+
try {
|
|
36
|
+
const data = await api.get(`/api/github/branches?repoUrl=${encodeURIComponent(repoUrl)}`);
|
|
37
|
+
spin.stop();
|
|
38
|
+
const items = Array.isArray(data) ? data : (data.branches || []);
|
|
39
|
+
if (!items.length) { ui.info('No branches found.'); return; }
|
|
40
|
+
ui.header(`Branches — ${repoUrl}`);
|
|
41
|
+
ui.divider();
|
|
42
|
+
items.forEach(b => {
|
|
43
|
+
const name = b.name || b;
|
|
44
|
+
const isDefault = b.default ? ` ${ui.c.green}(default)${ui.c.reset}` : '';
|
|
45
|
+
console.log(` ${ui.c.bold}${name}${ui.c.reset}${isDefault}`);
|
|
46
|
+
});
|
|
47
|
+
console.log();
|
|
48
|
+
} catch (err) {
|
|
49
|
+
spin.stop();
|
|
50
|
+
ui.error(`Failed: ${err.message}`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { repos, branches };
|
package/commands/logs.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { api } = require('../lib/api');
|
|
4
|
+
const ui = require('../lib/ui');
|
|
5
|
+
|
|
6
|
+
function printLogLines(lines) {
|
|
7
|
+
if (!Array.isArray(lines)) return;
|
|
8
|
+
lines.forEach(entry => {
|
|
9
|
+
const ts = entry.timestamp ? `${ui.c.dim}[${new Date(entry.timestamp).toLocaleTimeString()}]${ui.c.reset} ` : '';
|
|
10
|
+
const lvl = (entry.level || '').toLowerCase();
|
|
11
|
+
const msg = entry.message || entry.text || String(entry);
|
|
12
|
+
const colored = lvl === 'error' || lvl === 'stderr'
|
|
13
|
+
? `${ui.c.red}${msg}${ui.c.reset}`
|
|
14
|
+
: lvl === 'warn'
|
|
15
|
+
? `${ui.c.yellow}${msg}${ui.c.reset}`
|
|
16
|
+
: msg;
|
|
17
|
+
console.log(`${ts}${colored}`);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function fetchLogs(projectId, opts) {
|
|
22
|
+
const lines = parseInt(opts.lines, 10) || 50;
|
|
23
|
+
|
|
24
|
+
if (opts.follow) {
|
|
25
|
+
ui.info(`Streaming logs for ${ui.c.bold}${projectId}${ui.c.reset} (Ctrl+C to stop)`);
|
|
26
|
+
ui.divider();
|
|
27
|
+
let seen = new Set();
|
|
28
|
+
const poll = async () => {
|
|
29
|
+
try {
|
|
30
|
+
const data = await api.get(`/api/projects/${encodeURIComponent(projectId)}/runtime-logs?lines=${lines}`);
|
|
31
|
+
const entries = Array.isArray(data) ? data : (data.logs || data.lines || []);
|
|
32
|
+
const fresh = entries.filter(e => {
|
|
33
|
+
const key = e.timestamp + (e.message || '');
|
|
34
|
+
if (seen.has(key)) return false;
|
|
35
|
+
seen.add(key);
|
|
36
|
+
return true;
|
|
37
|
+
});
|
|
38
|
+
if (fresh.length) printLogLines(fresh);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
ui.warn(`Log fetch error: ${err.message}`);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
await poll();
|
|
44
|
+
const iv = setInterval(poll, 3000);
|
|
45
|
+
process.on('SIGINT', () => { clearInterval(iv); console.log('\n'); process.exit(0); });
|
|
46
|
+
// Keep alive
|
|
47
|
+
await new Promise(() => {});
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const spin = ui.spinner(`Fetching logs for ${projectId}`);
|
|
52
|
+
try {
|
|
53
|
+
const data = await api.get(`/api/projects/${encodeURIComponent(projectId)}/runtime-logs?lines=${lines}`);
|
|
54
|
+
spin.stop();
|
|
55
|
+
const entries = Array.isArray(data) ? data : (data.logs || data.lines || []);
|
|
56
|
+
if (!entries.length) { ui.info('No log entries found.'); return; }
|
|
57
|
+
ui.header(`Logs — ${projectId} (last ${entries.length})`);
|
|
58
|
+
ui.divider();
|
|
59
|
+
printLogLines(entries);
|
|
60
|
+
console.log();
|
|
61
|
+
} catch (err) {
|
|
62
|
+
spin.stop();
|
|
63
|
+
ui.error(`Failed: ${err.message}`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { fetchLogs };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const readline = require('readline');
|
|
4
|
+
const { api } = require('../lib/api');
|
|
5
|
+
const ui = require('../lib/ui');
|
|
6
|
+
const config = require('../lib/config');
|
|
7
|
+
|
|
8
|
+
async function prompt(q) {
|
|
9
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
10
|
+
return new Promise(r => rl.question(q, a => { rl.close(); r(a.trim()); }));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function list(opts) {
|
|
14
|
+
const spin = ui.spinner('Fetching projects');
|
|
15
|
+
try {
|
|
16
|
+
// Use the transfer endpoint — it returns the full workspace with all projects
|
|
17
|
+
const apiKey = config.getApiKey();
|
|
18
|
+
const baseUrl = config.getBaseUrl();
|
|
19
|
+
const { validateApiKey } = require('../lib/api');
|
|
20
|
+
const data = await validateApiKey(apiKey, baseUrl);
|
|
21
|
+
spin.stop();
|
|
22
|
+
|
|
23
|
+
const projects = Array.isArray(data.projects) ? data.projects : [];
|
|
24
|
+
if (!projects.length) {
|
|
25
|
+
ui.info('No projects yet. Deploy one: joytree deploy');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (opts && opts.json) {
|
|
30
|
+
console.log(JSON.stringify(projects, null, 2));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
ui.header(`Projects (${projects.length})`);
|
|
35
|
+
ui.divider();
|
|
36
|
+
const base = config.getBaseUrl().replace(/^https?:\/\//, '');
|
|
37
|
+
projects.forEach(p => {
|
|
38
|
+
const subdomain = p.subdomain || p.name || p.id;
|
|
39
|
+
console.log(` ${ui.c.bold}${subdomain}${ui.c.reset} ${ui.c.dim}→ https://${subdomain}.${base}${ui.c.reset}`);
|
|
40
|
+
if (p.repoUrl) console.log(` ${ui.c.dim}repo: ${p.repoUrl} branch: ${p.branch||'main'}${ui.c.reset}`);
|
|
41
|
+
if (p.buildCommand) console.log(` ${ui.c.dim}build: ${p.buildCommand}${ui.c.reset}`);
|
|
42
|
+
});
|
|
43
|
+
console.log();
|
|
44
|
+
} catch (err) {
|
|
45
|
+
spin.stop();
|
|
46
|
+
ui.error(`Failed: ${err.message}`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function inspect(projectId) {
|
|
52
|
+
const spin = ui.spinner(`Loading ${projectId}`);
|
|
53
|
+
try {
|
|
54
|
+
const data = await api.get(`/api/projects/${encodeURIComponent(projectId)}`);
|
|
55
|
+
spin.stop();
|
|
56
|
+
const p = data.project || data;
|
|
57
|
+
const base = config.getBaseUrl().replace(/^https?:\/\//, '');
|
|
58
|
+
|
|
59
|
+
ui.header(`Project: ${p.name || p.subdomain || projectId}`);
|
|
60
|
+
ui.divider();
|
|
61
|
+
ui.label('ID', p.id || p._id || projectId);
|
|
62
|
+
ui.label('Subdomain', p.subdomain || '—');
|
|
63
|
+
ui.label('Live URL', `https://${p.subdomain||projectId}.${base}`);
|
|
64
|
+
ui.label('Repo', p.repoUrl || '—');
|
|
65
|
+
ui.label('Branch', p.branch || 'main');
|
|
66
|
+
ui.label('Build Cmd', p.buildCommand || '—');
|
|
67
|
+
ui.label('Start Cmd', p.startCommand || '—');
|
|
68
|
+
ui.label('Static', p.isStatic ? 'yes' : 'no');
|
|
69
|
+
ui.label('Auto-deploy', p.autoDeploy ? 'enabled' : 'disabled');
|
|
70
|
+
ui.label('Node Version', p.nodeVersion || '20');
|
|
71
|
+
if (p.createdAt) ui.label('Created', new Date(p.createdAt).toLocaleString());
|
|
72
|
+
console.log();
|
|
73
|
+
} catch (err) {
|
|
74
|
+
spin.stop();
|
|
75
|
+
ui.error(`Failed: ${err.message}`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function deleteProject(projectId, opts) {
|
|
81
|
+
if (!opts.yes) {
|
|
82
|
+
const ans = await prompt(`${ui.c.yellow}Delete project "${projectId}"? This is irreversible. Type yes to confirm: ${ui.c.reset}`);
|
|
83
|
+
if (ans.toLowerCase() !== 'yes') { ui.info('Cancelled.'); return; }
|
|
84
|
+
}
|
|
85
|
+
const spin = ui.spinner(`Deleting ${projectId}`);
|
|
86
|
+
try {
|
|
87
|
+
await api.delete(`/api/projects/${encodeURIComponent(projectId)}`);
|
|
88
|
+
spin.stop(`Project ${ui.c.bold}${projectId}${ui.c.reset} deleted.`);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
spin.stop();
|
|
91
|
+
ui.error(`Failed: ${err.message}`);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { list, inspect, deleteProject };
|