@a83/orbiter-admin 0.3.48 → 0.3.50

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 CHANGED
@@ -76,9 +76,10 @@ Login: `admin` / `admin`
76
76
 
77
77
  | Variable | Required | Default | Description |
78
78
  |-----------------|----------|---------|-------------|
79
- | `ORBITER_POD` | **yes** | — | Absolute path to the `.pod` file |
80
- | `PORT` | no | `4322` | HTTP port |
81
- | `ADMIN_ORIGIN` | no | `*` | Allowed CORS origins (comma-separated) |
79
+ | `ORBITER_POD` | **yes** | — | Absolute path to the `.pod` file |
80
+ | `PORT` | no | `4322` | HTTP port |
81
+ | `ADMIN_ORIGIN` | no | `*` | Allowed CORS origins (comma-separated) |
82
+ | `ORBITER_NO_TELEMETRY` | no | — | Set to `1` to disable the anonymous startup ping (version + node + platform, no personal data) |
82
83
 
83
84
  ---
84
85
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a83/orbiter-admin",
3
- "version": "0.3.48",
3
+ "version": "0.3.50",
4
4
  "description": "Standalone admin server for Orbiter CMS",
5
5
  "type": "module",
6
6
  "main": "./src/server.js",
@@ -30,6 +30,7 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@a83/orbiter-core": "^0.3.2",
33
+ "basic-ftp": "^5.0.5",
33
34
  "@hono/node-server": "^1.14.4",
34
35
  "hono": "^4.7.11",
35
36
  "nodemailer": "^8.0.10",
package/public/build.html CHANGED
@@ -10,20 +10,25 @@
10
10
  <link rel="stylesheet" href="/style.css" />
11
11
  <script src="/theme.js"></script>
12
12
  <style>
13
- .build-card { background:var(--bg2); border:1px solid var(--line); border-radius:var(--radius); padding:28px 32px; max-width:520px; }
14
- .build-status { display:flex; align-items:center; gap:10px; margin-bottom:20px; }
13
+ .build-cards { display:flex; flex-direction:column; gap:16px; max-width:560px; }
14
+ .build-card { background:var(--bg2); border:1px solid var(--line); border-radius:var(--radius); padding:24px 28px; }
15
+ .build-card-header { font-size:10px; letter-spacing:.16em; text-transform:uppercase; color:var(--muted); margin-bottom:16px; display:flex; align-items:center; gap:7px; }
16
+ .build-card-header::before { content:"◆"; color:var(--gold); font-size:5px; }
17
+ .build-status { display:flex; align-items:center; gap:10px; margin-bottom:14px; }
15
18
  .build-dot { width:8px; height:8px; border-radius:50%; background:var(--line); flex-shrink:0; }
16
- .build-dot.configured { background:var(--jade); }
17
- .build-dot.unconfigured { background:var(--muted); }
19
+ .build-dot.ok { background:var(--jade); }
20
+ .build-dot.err { background:var(--red); }
21
+ .build-dot.off { background:var(--muted); }
18
22
  .build-label { font-size:12px; color:var(--text); }
19
- .build-meta { font-size:10px; color:var(--muted); margin-bottom:24px; }
20
- .build-last { font-size:10px; color:var(--muted); margin-top:12px; }
21
- .banner { padding:8px 16px; font-size:10px; display:flex; align-items:center; gap:6px; border-radius:var(--radius); margin-bottom:14px; max-width:520px; }
23
+ .build-meta { font-size:10px; color:var(--muted); margin-bottom:20px; }
24
+ .build-last { font-size:10px; color:var(--muted); margin-top:10px; }
25
+ .build-err { font-size:10px; color:var(--red); margin-top:6px; font-family:var(--mono); white-space:pre-wrap; word-break:break-all; }
26
+ .banner { padding:8px 16px; font-size:10px; display:flex; align-items:center; gap:6px; border-radius:var(--radius); margin-bottom:14px; max-width:560px; }
22
27
  .banner-ok { background:var(--jade-bg); color:var(--jade); border:1px solid rgba(45,139,106,.2); }
23
28
  .banner-ok::before { content:"✓"; }
24
29
  .banner-err { background:rgba(139,38,53,.07); color:var(--red); border:1px solid rgba(139,38,53,.15); }
25
30
  .banner-err::before { content:"✕"; }
26
- .config-hint { font-size:11px; color:var(--muted); margin-top:12px; line-height:1.7; }
31
+ .config-hint { font-size:11px; color:var(--muted); margin-top:10px; line-height:1.7; }
27
32
  .config-hint a { color:var(--accent); text-decoration:none; }
28
33
  .config-hint a:hover { text-decoration:underline; }
29
34
  </style>
@@ -86,40 +91,76 @@
86
91
  setTimeout(()=>el.style.display='none', 4000);
87
92
  }
88
93
 
89
- const status = await fetch('/api/build/status',{credentials:'include'}).then(r=>r.json()).catch(()=>({configured:false,lastTriggered:null}));
94
+ const [status, ftp] = await Promise.all([
95
+ fetch('/api/build/status', {credentials:'include'}).then(r=>r.json()).catch(()=>({configured:false,lastTriggered:null})),
96
+ fetch('/api/deploy/ftp/status', {credentials:'include'}).then(r=>r.json()).catch(()=>({configured:false})),
97
+ ]);
98
+
90
99
  const lastEl = document.getElementById('last-triggered');
91
- lastEl.textContent = status.lastTriggered ? `Last: ${new Date(status.lastTriggered).toLocaleString()}` : 'Never triggered';
100
+ lastEl.textContent = status.lastTriggered ? `Last build: ${new Date(status.lastTriggered).toLocaleString()}` : '';
92
101
 
93
102
  const wrap = document.getElementById('content');
94
103
  wrap.innerHTML = `
95
- <div class="build-card">
96
- <div class="build-status">
97
- <div class="build-dot ${status.configured?'configured':'unconfigured'}"></div>
98
- <div class="build-label">${status.configured ? 'Webhook configured' : 'No webhook configured'}</div>
104
+ <div class="build-cards">
105
+
106
+ <!-- Build webhook card -->
107
+ <div class="build-card">
108
+ <div class="build-card-header">Build webhook</div>
109
+ <div class="build-status">
110
+ <div class="build-dot ${status.configured?'ok':'off'}"></div>
111
+ <div class="build-label">${status.configured ? 'Webhook configured' : 'No webhook configured'}</div>
112
+ </div>
113
+ ${status.configured
114
+ ? `<div class="build-meta">POSTs to the webhook URL to trigger a build on Netlify, Vercel, or GitHub Actions.</div>
115
+ <button class="btn btn-primary" id="trigger-btn" style="min-width:160px;">▲ Trigger build</button>
116
+ ${status.lastTriggered ? `<div class="build-last">Last triggered: ${new Date(status.lastTriggered).toLocaleString()}</div>` : ''}`
117
+ : `<div class="config-hint">No webhook URL configured. Add one in <a href="/settings.html">Settings → Build</a>.</div>`
118
+ }
119
+ </div>
120
+
121
+ <!-- FTP deploy card -->
122
+ <div class="build-card">
123
+ <div class="build-card-header">FTP / FTPS Deploy</div>
124
+ <div class="build-status">
125
+ <div class="build-dot ${ftp.configured ? (ftp.lastStatus==='error'?'err':'ok') : 'off'}"></div>
126
+ <div class="build-label">${ftp.configured
127
+ ? (ftp.lastStatus==='error' ? 'Last deploy failed' : ftp.lastDeploy ? 'Configured' : 'Configured — never deployed')
128
+ : 'Not configured'}</div>
129
+ </div>
130
+ ${ftp.configured
131
+ ? `<div class="build-meta">Uploads your Astro <code style="font-family:var(--mono);font-size:10px;background:var(--bg3);padding:1px 5px;border-radius:3px">dist/</code> folder directly to your FTP server (World4You, Strato, etc.).</div>
132
+ <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
133
+ <button class="btn btn-primary" id="ftp-deploy-btn" style="min-width:160px;">↑ Deploy via FTP</button>
134
+ ${ftp.autoDeploy ? '<span style="font-size:10px;color:var(--muted)">Auto-deploy after build: on</span>' : ''}
135
+ </div>
136
+ ${ftp.lastDeploy ? `<div class="build-last">Last deploy: ${new Date(ftp.lastDeploy).toLocaleString()}</div>` : ''}
137
+ ${ftp.lastStatus==='error' && ftp.lastError ? `<div class="build-err">${ftp.lastError}</div>` : ''}`
138
+ : `<div class="config-hint">Configure FTP credentials in <a href="/settings.html">Settings → FTP / FTPS Deploy</a> to upload directly to shared hosting.</div>`
139
+ }
99
140
  </div>
100
- ${status.configured
101
- ? `<div class="build-meta">Triggering a build will POST to the configured webhook URL.</div>
102
- <button class="btn btn-primary" id="trigger-btn" style="min-width:160px;">▲ Trigger build</button>
103
- ${status.lastTriggered ? `<div class="build-last">Last triggered: ${new Date(status.lastTriggered).toLocaleString()}</div>` : ''}`
104
- : `<div class="config-hint">
105
- No webhook URL is configured. To enable build triggers, add a webhook URL in
106
- <a href="/settings.html">Settings → Build</a>.
107
- </div>`
108
- }
141
+
109
142
  </div>
110
143
  `;
111
144
 
112
- document.getElementById('trigger-btn')?.addEventListener('click', async btn=>{
113
- btn = document.getElementById('trigger-btn');
114
- btn.disabled = true;
115
- btn.textContent = 'Triggering…';
116
- const res = await fetch('/api/build/trigger',{method:'POST',credentials:'include'});
145
+ document.getElementById('trigger-btn')?.addEventListener('click', async () => {
146
+ const btn = document.getElementById('trigger-btn');
147
+ btn.disabled = true; btn.textContent = 'Triggering…';
148
+ const res = await fetch('/api/build/trigger', {method:'POST', credentials:'include'});
117
149
  const json = await res.json();
118
- btn.disabled = false;
119
- btn.textContent = ' Trigger build';
120
- if (res.ok) showBanner('banner-ok','Build triggered');
150
+ btn.disabled = false; btn.textContent = '▲ Trigger build';
151
+ if (res.ok) showBanner('banner-ok', 'Build triggered');
121
152
  else showBanner('banner-err', json.error ?? 'Webhook error');
122
153
  });
154
+
155
+ document.getElementById('ftp-deploy-btn')?.addEventListener('click', async () => {
156
+ const btn = document.getElementById('ftp-deploy-btn');
157
+ btn.disabled = true; btn.textContent = '↑ Uploading…';
158
+ const res = await fetch('/api/deploy/ftp', {method:'POST', credentials:'include'});
159
+ const json = await res.json();
160
+ btn.disabled = false; btn.textContent = '↑ Deploy via FTP';
161
+ if (res.ok) showBanner('banner-ok', 'FTP deploy complete');
162
+ else showBanner('banner-err', json.error ?? 'FTP error');
163
+ });
123
164
  </script>
124
165
  </main>
125
166
  </div>
@@ -502,6 +502,46 @@
502
502
  </div>
503
503
  </div>
504
504
 
505
+ <div class="settings-group" style="grid-column:1/-1">
506
+ <div class="group-header">FTP / FTPS Deploy</div>
507
+ <div class="setting-row">
508
+ <div><div class="setting-label">FTP host</div><div class="setting-desc">e.g. ftp.world4you.com or ftp.strato.de</div></div>
509
+ <input class="input" name="ftp.host" value="${get('ftp.host')||''}" placeholder="ftp.example.com" autocomplete="off" />
510
+ </div>
511
+ <div class="setting-row">
512
+ <div><div class="setting-label">Port</div><div class="setting-desc">21 for FTP (default), 990 for implicit FTPS</div></div>
513
+ <input class="input" name="ftp.port" type="number" value="${get('ftp.port')||'21'}" placeholder="21" style="max-width:100px" />
514
+ </div>
515
+ <div class="setting-row">
516
+ <div><div class="setting-label">Username</div></div>
517
+ <input class="input" name="ftp.user" value="${get('ftp.user')||''}" placeholder="u12345678" autocomplete="off" />
518
+ </div>
519
+ <div class="setting-row">
520
+ <div><div class="setting-label">Password</div></div>
521
+ <input class="input" type="password" name="ftp.password" value="${get('ftp.password')||''}" autocomplete="off" />
522
+ </div>
523
+ <div class="setting-row">
524
+ <div><div class="setting-label">Use FTPS</div><div class="setting-desc">Secure FTP over TLS (explicit mode)</div></div>
525
+ <input type="checkbox" name="ftp.secure" value="1" ${get('ftp.secure')==='1'?'checked':''} style="accent-color:var(--accent);width:14px;height:14px;" />
526
+ </div>
527
+ <div class="setting-row">
528
+ <div><div class="setting-label">Remote path</div><div class="setting-desc">Target folder on the server</div></div>
529
+ <input class="input" name="ftp.remote_path" value="${get('ftp.remote_path')||''}" placeholder="/public_html" />
530
+ </div>
531
+ <div class="setting-row">
532
+ <div><div class="setting-label">Local dist path</div><div class="setting-desc">Absolute path to your Astro <code style="font-family:var(--mono);font-size:10px;background:var(--bg3);padding:1px 5px;border-radius:3px">dist/</code> folder on this machine</div></div>
533
+ <input class="input" name="ftp.local_path" value="${get('ftp.local_path')||''}" placeholder="/Users/me/my-site/dist" />
534
+ </div>
535
+ <div class="setting-row">
536
+ <div><div class="setting-label">Auto-deploy after build</div><div class="setting-desc">Upload via FTP automatically after a successful build webhook</div></div>
537
+ <input type="checkbox" name="ftp.auto_deploy" value="1" ${get('ftp.auto_deploy')==='1'?'checked':''} style="accent-color:var(--accent);width:14px;height:14px;" />
538
+ </div>
539
+ <div class="save-row" style="justify-content:flex-start;gap:14px">
540
+ <button type="button" class="btn btn-ghost" id="ftp-test-btn">Test connection</button>
541
+ <span id="ftp-test-result" style="font-size:11px;color:var(--muted)"></span>
542
+ </div>
543
+ </div>
544
+
505
545
  </div><!-- /settings-grid -->
506
546
  </form>
507
547
 
@@ -759,6 +799,14 @@
759
799
  ['email.notify_to', fd.get('email.notify_to')],
760
800
  ['email.notify_publish', fd.get('email.notify_publish') ? '1' : '0'],
761
801
  ['email.notify_comment', fd.get('email.notify_comment') ? '1' : '0'],
802
+ ['ftp.host', fd.get('ftp.host')],
803
+ ['ftp.port', fd.get('ftp.port')],
804
+ ['ftp.user', fd.get('ftp.user')],
805
+ ['ftp.password', fd.get('ftp.password')],
806
+ ['ftp.secure', fd.get('ftp.secure') ? '1' : '0'],
807
+ ['ftp.remote_path', fd.get('ftp.remote_path')],
808
+ ['ftp.local_path', fd.get('ftp.local_path')],
809
+ ['ftp.auto_deploy', fd.get('ftp.auto_deploy') ? '1' : '0'],
762
810
  ]);
763
811
  showBanner('site-banner','banner-ok','Settings saved');
764
812
  });
@@ -831,6 +879,18 @@
831
879
  });
832
880
  });
833
881
 
882
+ // FTP test connection
883
+ document.getElementById('ftp-test-btn')?.addEventListener('click', async () => {
884
+ const btn = document.getElementById('ftp-test-btn');
885
+ const res = document.getElementById('ftp-test-result');
886
+ btn.disabled = true; btn.textContent = 'Testing…'; res.textContent = '';
887
+ const r = await fetch('/api/deploy/ftp/test', { method: 'POST', credentials: 'include' });
888
+ const j = await r.json();
889
+ btn.disabled = false; btn.textContent = 'Test connection';
890
+ if (r.ok) { res.style.color = 'var(--jade)'; res.textContent = `✓ Connected — ${j.files} files in ${j.path}`; }
891
+ else { res.style.color = 'var(--red)'; res.textContent = `✕ ${j.error}`; }
892
+ });
893
+
834
894
  // Password form (with confirm validation)
835
895
  document.getElementById('pw-form').addEventListener('submit', async e => {
836
896
  e.preventDefault();
package/src/cli.js CHANGED
@@ -32,3 +32,17 @@ if (process.env.ORBITER_POD) {
32
32
  }
33
33
 
34
34
  await import('./server.js');
35
+
36
+ // Startup ping — counts active installs. Opt out: ORBITER_NO_TELEMETRY=1
37
+ if (!process.env.ORBITER_NO_TELEMETRY) {
38
+ const { version } = JSON.parse(
39
+ (await import('node:fs')).readFileSync(
40
+ new URL('../package.json', import.meta.url), 'utf8'
41
+ )
42
+ );
43
+ fetch('https://ping.orbiter.sh/ping', {
44
+ method: 'POST',
45
+ headers: { 'Content-Type': 'application/json' },
46
+ body: JSON.stringify({ version, node: process.version, platform: process.platform }),
47
+ }).catch(() => {});
48
+ }
@@ -1,5 +1,6 @@
1
1
  import { Hono } from 'hono';
2
2
  import { openPod } from '@a83/orbiter-core';
3
+ import { runFtpDeploy } from './deploy.js';
3
4
 
4
5
  export const buildRoutes = new Hono();
5
6
 
@@ -12,6 +13,12 @@ buildRoutes.post('/trigger', async (c) => {
12
13
 
13
14
  const res = await fetch(url, { method: 'POST' }).catch(e => ({ ok: false, status: 0, err: e.message }));
14
15
  if (!res.ok) return c.json({ error: `Webhook returned ${res.status}` }, 502);
16
+
17
+ const db2 = openPod(c.get('podPath'));
18
+ const autoDeploy = db2.getMeta('ftp.auto_deploy') === '1';
19
+ db2.close();
20
+ if (autoDeploy) runFtpDeploy(c.get('podPath')).catch(e => console.warn('[ftp-auto-deploy]', e.message));
21
+
15
22
  return c.json({ ok: true });
16
23
  });
17
24
 
@@ -0,0 +1,94 @@
1
+ import { Hono } from 'hono';
2
+ import { Client } from 'basic-ftp';
3
+ import { existsSync } from 'node:fs';
4
+ import { openPod } from '@a83/orbiter-core';
5
+
6
+ export const deployRoutes = new Hono();
7
+
8
+ async function getFtpCfg(podPath) {
9
+ const db = openPod(podPath);
10
+ const cfg = {
11
+ host: db.getMeta('ftp.host') ?? '',
12
+ port: parseInt(db.getMeta('ftp.port') ?? '21', 10),
13
+ user: db.getMeta('ftp.user') ?? '',
14
+ password: db.getMeta('ftp.password') ?? '',
15
+ remotePath: db.getMeta('ftp.remote_path') ?? '/',
16
+ localPath: db.getMeta('ftp.local_path') ?? '',
17
+ secure: db.getMeta('ftp.secure') === '1',
18
+ };
19
+ db.close();
20
+ return cfg;
21
+ }
22
+
23
+ export async function runFtpDeploy(podPath) {
24
+ const cfg = await getFtpCfg(podPath);
25
+ if (!cfg.host || !cfg.user || !cfg.password || !cfg.localPath) {
26
+ throw new Error('FTP not fully configured');
27
+ }
28
+ if (!existsSync(cfg.localPath)) {
29
+ throw new Error(`Local path not found: ${cfg.localPath}`);
30
+ }
31
+
32
+ const client = new Client();
33
+ client.ftp.verbose = false;
34
+ try {
35
+ await client.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: cfg.secure });
36
+ await client.uploadFromDir(cfg.localPath, cfg.remotePath || '/');
37
+
38
+ const db = openPod(podPath);
39
+ db.setMeta('ftp.last_deploy', new Date().toISOString());
40
+ db.setMeta('ftp.last_status', 'ok');
41
+ db.setMeta('ftp.last_error', '');
42
+ db.close();
43
+ } finally {
44
+ client.close();
45
+ }
46
+ }
47
+
48
+ // GET /api/deploy/ftp/status
49
+ deployRoutes.get('/ftp/status', (c) => {
50
+ const db = openPod(c.get('podPath'));
51
+ const out = {
52
+ configured: !!(db.getMeta('ftp.host') && db.getMeta('ftp.user') && db.getMeta('ftp.password') && db.getMeta('ftp.local_path')),
53
+ lastDeploy: db.getMeta('ftp.last_deploy') ?? null,
54
+ lastStatus: db.getMeta('ftp.last_status') ?? null,
55
+ lastError: db.getMeta('ftp.last_error') ?? null,
56
+ autoDeploy: db.getMeta('ftp.auto_deploy') === '1',
57
+ };
58
+ db.close();
59
+ return c.json(out);
60
+ });
61
+
62
+ // POST /api/deploy/ftp — run full deploy
63
+ deployRoutes.post('/ftp', async (c) => {
64
+ try {
65
+ await runFtpDeploy(c.get('podPath'));
66
+ return c.json({ ok: true });
67
+ } catch (err) {
68
+ const db = openPod(c.get('podPath'));
69
+ db.setMeta('ftp.last_status', 'error');
70
+ db.setMeta('ftp.last_error', err.message);
71
+ db.close();
72
+ return c.json({ error: err.message }, 502);
73
+ }
74
+ });
75
+
76
+ // POST /api/deploy/ftp/test — test connection only, no upload
77
+ deployRoutes.post('/ftp/test', async (c) => {
78
+ const cfg = await getFtpCfg(c.get('podPath'));
79
+ if (!cfg.host || !cfg.user || !cfg.password) {
80
+ return c.json({ error: 'FTP credentials not configured — save settings first' }, 400);
81
+ }
82
+
83
+ const client = new Client();
84
+ client.ftp.verbose = false;
85
+ try {
86
+ await client.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: cfg.secure });
87
+ const list = await client.list(cfg.remotePath || '/');
88
+ return c.json({ ok: true, files: list.length, path: cfg.remotePath || '/' });
89
+ } catch (err) {
90
+ return c.json({ error: err.message }, 502);
91
+ } finally {
92
+ client.close();
93
+ }
94
+ });
@@ -17,6 +17,8 @@ const ALLOWED_KEYS = [
17
17
  'format_version',
18
18
  'email.smtp_host', 'email.smtp_port', 'email.smtp_user', 'email.smtp_pass',
19
19
  'email.smtp_from', 'email.notify_publish', 'email.notify_comment', 'email.notify_to',
20
+ 'ftp.host', 'ftp.port', 'ftp.user', 'ftp.password',
21
+ 'ftp.remote_path', 'ftp.local_path', 'ftp.secure', 'ftp.auto_deploy',
20
22
  ];
21
23
 
22
24
  const PREVIEW_URL_RE = /^preview_url\.[a-z0-9_-]+$/;
package/src/server.js CHANGED
@@ -25,6 +25,7 @@ import { importRoutes } from './routes/import.js';
25
25
  import { commentRoutes } from './routes/comments.js';
26
26
  import { lockRoutes } from './routes/locks.js';
27
27
  import { terminalRoutes } from './routes/terminal.js';
28
+ import { deployRoutes } from './routes/deploy.js';
28
29
  import { requireAuth } from './middleware/auth.js';
29
30
  import { csrfMiddleware } from './middleware/csrf.js';
30
31
 
@@ -80,6 +81,7 @@ export function createApp(podPath) {
80
81
  api.route('/', commentRoutes);
81
82
  api.route('/locks', lockRoutes);
82
83
  api.route('/terminal', terminalRoutes);
84
+ api.route('/deploy', deployRoutes);
83
85
 
84
86
  app.route('/api', api);
85
87