skopix 2.0.12 → 2.0.13
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/cli/commands/agent.js +16 -3
- package/cli/commands/dashboard.js +13 -4
- package/package.json +1 -1
- package/web/app/index.html +15 -3
- package/README.md +0 -126
- package/skopix_backup/skopix-backup.command +0 -5
- package/skopix_backup/skopix-backup.js +0 -85
- package/skopix_backup/skopix-restore.command +0 -5
- package/skopix_backup/skopix-restore.js +0 -152
- package/skopix_backup.sql +0 -3532
package/cli/commands/agent.js
CHANGED
|
@@ -247,8 +247,17 @@ export async function agentCommand(options) {
|
|
|
247
247
|
send({ type: 'sessionId', sessionId: runId });
|
|
248
248
|
|
|
249
249
|
const browser = await chromium.launch({ headless: test.headless || false, args: ['--no-sandbox'] });
|
|
250
|
-
const
|
|
250
|
+
const wantReport = test.generateReport !== false;
|
|
251
|
+
const browserZoom = test.browserZoom || 0.8;
|
|
252
|
+
const ctx = await browser.newContext({
|
|
253
|
+
viewport: { width: 1920, height: 1080 },
|
|
254
|
+
deviceScaleFactor: 1,
|
|
255
|
+
...(wantReport ? { recordVideo: { dir: sessionDir, size: { width: 1920, height: 1080 } } } : {}),
|
|
256
|
+
});
|
|
251
257
|
const page = await ctx.newPage();
|
|
258
|
+
if (browserZoom !== 1) {
|
|
259
|
+
await page.addInitScript((zoom) => { document.documentElement.style.zoom = zoom; }, browserZoom);
|
|
260
|
+
}
|
|
252
261
|
|
|
253
262
|
const allSteps = [...(setupTest ? (setupTest.steps || []) : []), ...(test.steps || [])];
|
|
254
263
|
let stepNum = 0, passed = true, failReason = '';
|
|
@@ -354,12 +363,16 @@ export async function agentCommand(options) {
|
|
|
354
363
|
if (count > 1) {
|
|
355
364
|
let bi = 0, bd = Infinity;
|
|
356
365
|
for (let i = 0; i < count; i++) { try { const box = await page.locator(s).nth(i).boundingBox({ timeout: 2000 }); if (!box) continue; const d = Math.sqrt(Math.pow(box.x + box.width / 2 - tx, 2) + Math.pow(box.y + box.height / 2 - ty, 2)); if (d < bd) { bd = d; bi = i; } } catch {} }
|
|
366
|
+
await page.locator(s).nth(bi).scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
|
|
357
367
|
await page.locator(s).nth(bi).click({ timeout: 5000 }); clicked = true;
|
|
358
|
-
} else if (count === 1) {
|
|
368
|
+
} else if (count === 1) {
|
|
369
|
+
await page.locator(s).first().scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
|
|
370
|
+
await page.locator(s).first().click({ timeout: 5000 }); clicked = true;
|
|
371
|
+
}
|
|
359
372
|
} catch {}
|
|
360
373
|
}
|
|
361
374
|
}
|
|
362
|
-
if (!clicked) { for (const s of selectors) { if (clicked) break; try { await page.locator(s).first().click({ timeout: 5000 }); clicked = true; } catch {} } }
|
|
375
|
+
if (!clicked) { for (const s of selectors) { if (clicked) break; try { await page.locator(s).first().scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {}); await page.locator(s).first().click({ timeout: 5000 }); clicked = true; } catch {} } }
|
|
363
376
|
if (!clicked) { for (const s of selectors) { if (clicked) break; try { await page.locator(s).first().click({ force: true, timeout: 5000 }); clicked = true; } catch {} } }
|
|
364
377
|
if (!clicked) throw new Error('Could not click: ' + selectors.join(', '));
|
|
365
378
|
await page.waitForTimeout(400);
|
|
@@ -2992,14 +2992,18 @@ function startReplay(test, setupTest, activeRuns, reportsDir, currentUser, env)
|
|
|
2992
2992
|
if (count > 1) {
|
|
2993
2993
|
let bestIdx = 0, bestDist = Infinity;
|
|
2994
2994
|
for (let i = 0; i < count; i++) { try { const box = await page.locator(s).nth(i).boundingBox({ timeout: 2000 }); if (!box) continue; const d = Math.sqrt(Math.pow(box.x+box.width/2-targetX,2)+Math.pow(box.y+box.height/2-targetY,2)); if (d < bestDist) { bestDist = d; bestIdx = i; } } catch {} }
|
|
2995
|
+
await page.locator(s).nth(bestIdx).scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
|
|
2995
2996
|
await page.locator(s).nth(bestIdx).click({ timeout: 5000 });
|
|
2996
2997
|
if (count > 1) broadcast({ type: 'stdout', text: ' (matched element ' + (bestIdx+1) + ' of ' + count + ' by position)' });
|
|
2997
2998
|
clicked = true;
|
|
2998
|
-
} else if (count === 1) {
|
|
2999
|
+
} else if (count === 1) {
|
|
3000
|
+
await page.locator(s).first().scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
|
|
3001
|
+
await page.locator(s).first().waitFor({ state: 'visible', timeout: 3000 }); await page.locator(s).first().click({ timeout: 5000 }); clicked = true;
|
|
3002
|
+
}
|
|
2999
3003
|
} catch {}
|
|
3000
3004
|
}
|
|
3001
3005
|
}
|
|
3002
|
-
if (!clicked) { for (const s of selectors) { if (clicked) break; try { await page.locator(s).first().waitFor({ state: 'visible', timeout: 3000 }); await page.locator(s).first().click({ timeout: 5000 }); clicked = true; } catch {} } }
|
|
3006
|
+
if (!clicked) { for (const s of selectors) { if (clicked) break; try { await page.locator(s).first().scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {}); await page.locator(s).first().waitFor({ state: 'visible', timeout: 3000 }); await page.locator(s).first().click({ timeout: 5000 }); clicked = true; } catch {} } }
|
|
3003
3007
|
if (!clicked) { for (const s of selectors) { if (clicked) break; try { await page.locator(s).first().click({ force: true, timeout: 5000 }); clicked = true; } catch {} } }
|
|
3004
3008
|
if (!clicked && step.element) { const tag = (step.element.tag||'').toLowerCase(); if (['i','svg','path','span','img'].includes(tag)) { for (const s of selectors) { if (clicked) break; try { await page.locator(s).first().locator('xpath=ancestor-or-self::*[self::a or self::button or @role="button"][1]').first().click({ timeout: 5000 }); clicked = true; } catch {} } } }
|
|
3005
3009
|
if (!clicked && step.element && step.element.text && step.element.text.length > 1) { try { await page.locator('*:has-text("' + step.element.text.replace(/"/g, '\\"') + '")').first().click({ timeout: 5000 }); clicked = true; } catch {} }
|
|
@@ -3073,12 +3077,17 @@ function startReplay(test, setupTest, activeRuns, reportsDir, currentUser, env)
|
|
|
3073
3077
|
});
|
|
3074
3078
|
|
|
3075
3079
|
const wantReport = test.generateReport !== false;
|
|
3080
|
+
const browserZoom = test.browserZoom || 0.8;
|
|
3076
3081
|
|
|
3077
3082
|
const ctx = await chromiumBrowser.newContext({
|
|
3078
|
-
viewport: { width:
|
|
3079
|
-
|
|
3083
|
+
viewport: { width: 1920, height: 1080 },
|
|
3084
|
+
deviceScaleFactor: 1,
|
|
3085
|
+
...(wantReport ? { recordVideo: { dir: sessionDir, size: { width: 1920, height: 1080 } } } : {}),
|
|
3080
3086
|
});
|
|
3081
3087
|
const page = await ctx.newPage();
|
|
3088
|
+
if (browserZoom !== 1) {
|
|
3089
|
+
await page.addInitScript((zoom) => { document.documentElement.style.zoom = zoom; }, browserZoom);
|
|
3090
|
+
}
|
|
3082
3091
|
|
|
3083
3092
|
// Navigate to start URL
|
|
3084
3093
|
if (test.url) {
|
package/package.json
CHANGED
package/web/app/index.html
CHANGED
|
@@ -2237,6 +2237,16 @@ body.viewer-mode .saved-test-row { cursor: default !important; }
|
|
|
2237
2237
|
<input type="checkbox" id="re-generate-report" checked style="width:14px;height:14px;accent-color:var(--cyan)">
|
|
2238
2238
|
<span style="color:var(--muted)">Generate <span style="color:var(--cyan)">report</span> (video + screenshots)</span>
|
|
2239
2239
|
</label>
|
|
2240
|
+
<div style="display:flex;align-items:center;gap:8px">
|
|
2241
|
+
<label style="font-family:var(--mono);font-size:12px;color:var(--muted);white-space:nowrap">Browser zoom:</label>
|
|
2242
|
+
<select class="form-select" id="re-browser-zoom" style="padding:5px 8px;font-size:12px;width:90px">
|
|
2243
|
+
<option value="1">100%</option>
|
|
2244
|
+
<option value="0.9">90%</option>
|
|
2245
|
+
<option value="0.8" selected>80%</option>
|
|
2246
|
+
<option value="0.75">75%</option>
|
|
2247
|
+
<option value="0.67">67%</option>
|
|
2248
|
+
</select>
|
|
2249
|
+
</div>
|
|
2240
2250
|
<div style="display:flex;align-items:center;gap:8px;flex:1">
|
|
2241
2251
|
<label style="font-family:var(--mono);font-size:12px;color:var(--muted);white-space:nowrap">Run setup:</label>
|
|
2242
2252
|
<select class="form-select" id="re-setup" style="flex:1;padding:5px 8px;font-size:12px">
|
|
@@ -5457,7 +5467,7 @@ async function confirmAddToSuite() {
|
|
|
5457
5467
|
}
|
|
5458
5468
|
|
|
5459
5469
|
// ── RECORDED TEST EDITOR ────────────────────────────────────────────────────
|
|
5460
|
-
let reEditorState = { scope: null, testId: null, steps: [], testName: '', url: '', reusable: false, setup: '', credentials: '', tags: [], generateReport: true };
|
|
5470
|
+
let reEditorState = { scope: null, testId: null, steps: [], testName: '', url: '', reusable: false, setup: '', credentials: '', tags: [], generateReport: true, browserZoom: 0.8 };
|
|
5461
5471
|
let reAssertInsertAfter = -1;
|
|
5462
5472
|
|
|
5463
5473
|
async function openRecordedTestEditor(scope, testId) {
|
|
@@ -5465,11 +5475,12 @@ async function openRecordedTestEditor(scope, testId) {
|
|
|
5465
5475
|
const res = await fetch(API_BASE + '/api/test/' + encodeURIComponent(scope) + '/' + encodeURIComponent(testId));
|
|
5466
5476
|
const test = await res.json();
|
|
5467
5477
|
if (!res.ok) { showToast(test.error || 'Failed to load test'); return; }
|
|
5468
|
-
reEditorState = { scope, testId, steps: JSON.parse(JSON.stringify(test.steps || [])), testName: test.name || '', url: test.url || '', reusable: !!test.reusable, setup: test.setup || '', credentials: test.credentials || '', tags: test.tags || [], generateReport: test.generateReport !== false };
|
|
5478
|
+
reEditorState = { scope, testId, steps: JSON.parse(JSON.stringify(test.steps || [])), testName: test.name || '', url: test.url || '', reusable: !!test.reusable, setup: test.setup || '', credentials: test.credentials || '', tags: test.tags || [], generateReport: test.generateReport !== false, browserZoom: test.browserZoom || 0.8 };
|
|
5469
5479
|
document.getElementById('re-name').value = reEditorState.testName;
|
|
5470
5480
|
document.getElementById('re-url').value = reEditorState.url;
|
|
5471
5481
|
document.getElementById('re-reusable').checked = reEditorState.reusable;
|
|
5472
5482
|
document.getElementById('re-generate-report').checked = reEditorState.generateReport;
|
|
5483
|
+
document.getElementById('re-browser-zoom').value = String(reEditorState.browserZoom);
|
|
5473
5484
|
document.getElementById('re-tags').value = (reEditorState.tags || []).join(', ');
|
|
5474
5485
|
// Populate credentials dropdown
|
|
5475
5486
|
const credSel = document.getElementById('re-credentials');
|
|
@@ -5726,6 +5737,7 @@ async function saveRecordedEdits() {
|
|
|
5726
5737
|
const url = document.getElementById('re-url').value.trim();
|
|
5727
5738
|
const reusable = document.getElementById('re-reusable').checked;
|
|
5728
5739
|
const generateReport = document.getElementById('re-generate-report')?.checked !== false;
|
|
5740
|
+
const browserZoom = parseFloat(document.getElementById('re-browser-zoom')?.value || '0.8');
|
|
5729
5741
|
const setup = document.getElementById('re-setup').value || null;
|
|
5730
5742
|
const credentials = document.getElementById('re-credentials')?.value || '';
|
|
5731
5743
|
const tags = (document.getElementById('re-tags')?.value || '').split(',').map(t => t.trim()).filter(Boolean);
|
|
@@ -5733,7 +5745,7 @@ async function saveRecordedEdits() {
|
|
|
5733
5745
|
try {
|
|
5734
5746
|
const res = await fetch(API_BASE + '/api/test/' + encodeURIComponent(reEditorState.scope) + '/' + encodeURIComponent(reEditorState.testId), {
|
|
5735
5747
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
|
5736
|
-
body: JSON.stringify({ name, url, type: 'recorded', steps: reEditorState.steps, reusable, setup, credentials, tags, generateReport }),
|
|
5748
|
+
body: JSON.stringify({ name, url, type: 'recorded', steps: reEditorState.steps, reusable, setup, credentials, tags, generateReport, browserZoom }),
|
|
5737
5749
|
});
|
|
5738
5750
|
const data = await res.json();
|
|
5739
5751
|
if (!res.ok) { showToast(data.error || 'Failed to save'); return; }
|
package/README.md
DELETED
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
# Skopix
|
|
2
|
-
|
|
3
|
-
Record tests by using your app. Replay them anywhere.
|
|
4
|
-
|
|
5
|
-
**[skopix.ayteelabs.com](https://skopix.ayteelabs.com)**
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
## Install
|
|
10
|
-
|
|
11
|
-
```bash
|
|
12
|
-
npm install -g skopix
|
|
13
|
-
npx playwright install chromium
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
---
|
|
17
|
-
|
|
18
|
-
## Quick Start
|
|
19
|
-
|
|
20
|
-
### Solo mode
|
|
21
|
-
```bash
|
|
22
|
-
skopix dashboard
|
|
23
|
-
```
|
|
24
|
-
Open `http://localhost:9000` and start recording.
|
|
25
|
-
|
|
26
|
-
### Team mode (one command)
|
|
27
|
-
Add to `~/.skopix.env` once:
|
|
28
|
-
```
|
|
29
|
-
SKOPIX_SECRET_KEY=your-secret
|
|
30
|
-
SKOPIX_AGENT_EMAIL=your@email.com
|
|
31
|
-
SKOPIX_AGENT_PASSWORD=yourpassword
|
|
32
|
-
```
|
|
33
|
-
Then:
|
|
34
|
-
```bash
|
|
35
|
-
skopix start
|
|
36
|
-
```
|
|
37
|
-
Starts the dashboard + agent in one command. Teammates connect via `http://YOUR-IP:9000`.
|
|
38
|
-
|
|
39
|
-
### Teammates — connect as agent
|
|
40
|
-
```bash
|
|
41
|
-
skopix agent --server http://HOST-IP:9000 --key "your-secret"
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
---
|
|
45
|
-
|
|
46
|
-
## Configure AI
|
|
47
|
-
|
|
48
|
-
```bash
|
|
49
|
-
skopix init
|
|
50
|
-
```
|
|
51
|
-
Choose Gemini, OpenAI, or Ollama (local — no API key needed).
|
|
52
|
-
|
|
53
|
-
Or set manually in `~/.skopix.env`:
|
|
54
|
-
```
|
|
55
|
-
SKOPIX_PROVIDER=gemini
|
|
56
|
-
GEMINI_API_KEY=your-key
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
---
|
|
60
|
-
|
|
61
|
-
## All Commands
|
|
62
|
-
|
|
63
|
-
| Command | Description |
|
|
64
|
-
|---|---|
|
|
65
|
-
| `skopix start` | Start dashboard + agent (team mode) |
|
|
66
|
-
| `skopix dashboard` | Start dashboard (solo mode) |
|
|
67
|
-
| `skopix dashboard --team --host 0.0.0.0` | Start dashboard in team mode |
|
|
68
|
-
| `skopix agent --server URL --key SECRET` | Connect as agent to a shared server |
|
|
69
|
-
| `skopix init` | Configure AI provider and API keys |
|
|
70
|
-
| `skopix config --set KEY=value` | Set a config value |
|
|
71
|
-
| `skopix config --list` | List all config values |
|
|
72
|
-
|
|
73
|
-
---
|
|
74
|
-
|
|
75
|
-
## Data & Backup
|
|
76
|
-
|
|
77
|
-
All data lives in `~/.skopix/` — tests, suites, sessions, credentials.
|
|
78
|
-
|
|
79
|
-
Backup:
|
|
80
|
-
```bash
|
|
81
|
-
node skopix-backup.js
|
|
82
|
-
```
|
|
83
|
-
Restore:
|
|
84
|
-
```bash
|
|
85
|
-
node skopix-restore.js
|
|
86
|
-
```
|
|
87
|
-
Download backup scripts from this repo.
|
|
88
|
-
|
|
89
|
-
---
|
|
90
|
-
|
|
91
|
-
## Remote Access
|
|
92
|
-
|
|
93
|
-
Expose your dashboard to remote teammates using [Portix](https://portix.dev):
|
|
94
|
-
```bash
|
|
95
|
-
portix 9000 --name skopix
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
---
|
|
99
|
-
|
|
100
|
-
## Export to Playwright
|
|
101
|
-
|
|
102
|
-
Every recorded test generates a `.spec.js` / `.spec.ts` file. Download it from the test editor and run it anywhere with:
|
|
103
|
-
```bash
|
|
104
|
-
npx playwright test
|
|
105
|
-
```
|
|
106
|
-
No Skopix needed to run exported tests.
|
|
107
|
-
|
|
108
|
-
---
|
|
109
|
-
|
|
110
|
-
## Environment Variables
|
|
111
|
-
|
|
112
|
-
| Variable | Description |
|
|
113
|
-
|---|---|
|
|
114
|
-
| `SKOPIX_SECRET_KEY` | Required for team mode |
|
|
115
|
-
| `SKOPIX_PROVIDER` | AI provider: `gemini`, `openai`, `ollama` |
|
|
116
|
-
| `GEMINI_API_KEY` | Google Gemini API key |
|
|
117
|
-
| `OPENAI_API_KEY` | OpenAI API key |
|
|
118
|
-
| `OLLAMA_MODEL` | Ollama model name (e.g. `llama3.1`) |
|
|
119
|
-
| `SKOPIX_AGENT_EMAIL` | Auto-agent login email |
|
|
120
|
-
| `SKOPIX_AGENT_PASSWORD` | Auto-agent login password |
|
|
121
|
-
| `BASE_URL` | Override base URL for exported Playwright tests |
|
|
122
|
-
| `TEST_PASSWORD` | Password used in exported Playwright tests |
|
|
123
|
-
|
|
124
|
-
---
|
|
125
|
-
|
|
126
|
-
Built by [Aytee Labs](https://ayteelabs.com)
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// ─────────────────────────────────────────────────────────────────
|
|
3
|
-
// SKOPIX BACKUP
|
|
4
|
-
// Works on Mac, Windows, and Linux — no dependencies needed.
|
|
5
|
-
// Usage: node skopix-backup.js
|
|
6
|
-
// ─────────────────────────────────────────────────────────────────
|
|
7
|
-
import fs from 'fs';
|
|
8
|
-
import path from 'path';
|
|
9
|
-
import os from 'os';
|
|
10
|
-
import { execSync } from 'child_process';
|
|
11
|
-
|
|
12
|
-
const SKOPIX_DIR = path.join(os.homedir(), '.skopix');
|
|
13
|
-
const TODAY = new Date().toISOString().slice(0, 10);
|
|
14
|
-
const BACKUP_NAME = `skopix-backup-${TODAY}.zip`;
|
|
15
|
-
const DESKTOP = path.join(os.homedir(), 'Desktop');
|
|
16
|
-
const DEST = path.join(DESKTOP, BACKUP_NAME);
|
|
17
|
-
|
|
18
|
-
const c = {
|
|
19
|
-
cyan: (s) => `\x1b[36m${s}\x1b[0m`,
|
|
20
|
-
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
21
|
-
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
22
|
-
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
23
|
-
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
function countFiles(dir) {
|
|
27
|
-
let count = 0;
|
|
28
|
-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
29
|
-
if (entry.isDirectory()) count += countFiles(path.join(dir, entry.name));
|
|
30
|
-
else count++;
|
|
31
|
-
}
|
|
32
|
-
return count;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function formatBytes(bytes) {
|
|
36
|
-
if (bytes < 1024) return bytes + ' B';
|
|
37
|
-
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
38
|
-
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function dirSize(dir) {
|
|
42
|
-
let total = 0;
|
|
43
|
-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
44
|
-
const p = path.join(dir, entry.name);
|
|
45
|
-
if (entry.isDirectory()) total += dirSize(p);
|
|
46
|
-
else total += fs.statSync(p).size;
|
|
47
|
-
}
|
|
48
|
-
return total;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
console.log('');
|
|
52
|
-
console.log(c.cyan(c.bold(' SKOPIX BACKUP')));
|
|
53
|
-
console.log(' ' + c.dim('─'.repeat(50)));
|
|
54
|
-
|
|
55
|
-
if (!fs.existsSync(SKOPIX_DIR)) {
|
|
56
|
-
console.log(' ' + c.red('✖') + ' ~/.skopix not found — nothing to back up');
|
|
57
|
-
process.exit(1);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const fileCount = countFiles(SKOPIX_DIR);
|
|
61
|
-
const size = formatBytes(dirSize(SKOPIX_DIR));
|
|
62
|
-
console.log(' Source : ' + c.cyan(SKOPIX_DIR));
|
|
63
|
-
console.log(' Files : ' + fileCount + ' files (' + size + ')');
|
|
64
|
-
console.log(' Saving : ' + c.cyan(DEST));
|
|
65
|
-
console.log('');
|
|
66
|
-
|
|
67
|
-
if (fs.existsSync(DEST)) fs.unlinkSync(DEST);
|
|
68
|
-
|
|
69
|
-
try {
|
|
70
|
-
const isWindows = process.platform === 'win32';
|
|
71
|
-
if (isWindows) {
|
|
72
|
-
const ps = `Compress-Archive -Path "${SKOPIX_DIR}\\*" -DestinationPath "${DEST}" -Force`;
|
|
73
|
-
execSync(`powershell -Command "${ps}"`, { stdio: 'pipe' });
|
|
74
|
-
} else {
|
|
75
|
-
execSync(`cd "${os.homedir()}" && zip -r "${DEST}" .skopix`, { stdio: 'pipe' });
|
|
76
|
-
}
|
|
77
|
-
console.log(' ' + c.green('✔') + ' Backup saved to:');
|
|
78
|
-
console.log(' ' + c.cyan(DEST));
|
|
79
|
-
console.log('');
|
|
80
|
-
console.log(' ' + c.dim('Restore with: node skopix-restore.js'));
|
|
81
|
-
console.log('');
|
|
82
|
-
} catch (e) {
|
|
83
|
-
console.log(' ' + c.red('✖') + ' Backup failed: ' + e.message);
|
|
84
|
-
process.exit(1);
|
|
85
|
-
}
|
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// ─────────────────────────────────────────────────────────────────
|
|
3
|
-
// SKOPIX RESTORE
|
|
4
|
-
// Works on Mac, Windows, and Linux — no dependencies needed.
|
|
5
|
-
// Usage: node skopix-restore.js
|
|
6
|
-
// or: node skopix-restore.js /path/to/skopix-backup-2026-05-26.zip
|
|
7
|
-
// ─────────────────────────────────────────────────────────────────
|
|
8
|
-
import fs from 'fs';
|
|
9
|
-
import path from 'path';
|
|
10
|
-
import os from 'os';
|
|
11
|
-
import { execSync } from 'child_process';
|
|
12
|
-
import readline from 'readline';
|
|
13
|
-
|
|
14
|
-
const SKOPIX_DIR = path.join(os.homedir(), '.skopix');
|
|
15
|
-
|
|
16
|
-
const c = {
|
|
17
|
-
cyan: (s) => `\x1b[36m${s}\x1b[0m`,
|
|
18
|
-
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
19
|
-
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
20
|
-
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
21
|
-
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
22
|
-
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
function ask(question) {
|
|
26
|
-
return new Promise(resolve => {
|
|
27
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
28
|
-
rl.question(' ' + question, ans => { rl.close(); resolve(ans.trim()); });
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function copyDir(src, dest) {
|
|
33
|
-
fs.mkdirSync(dest, { recursive: true });
|
|
34
|
-
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
35
|
-
const s = path.join(src, entry.name);
|
|
36
|
-
const d = path.join(dest, entry.name);
|
|
37
|
-
if (entry.isDirectory()) copyDir(s, d);
|
|
38
|
-
else fs.copyFileSync(s, d);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function countFiles(dir) {
|
|
43
|
-
let count = 0;
|
|
44
|
-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
45
|
-
if (entry.isDirectory()) count += countFiles(path.join(dir, entry.name));
|
|
46
|
-
else count++;
|
|
47
|
-
}
|
|
48
|
-
return count;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function findBackups() {
|
|
52
|
-
const searchDirs = [
|
|
53
|
-
path.join(os.homedir(), 'Desktop'),
|
|
54
|
-
path.join(os.homedir(), 'Downloads'),
|
|
55
|
-
os.homedir(),
|
|
56
|
-
process.cwd(),
|
|
57
|
-
];
|
|
58
|
-
const found = [];
|
|
59
|
-
for (const dir of searchDirs) {
|
|
60
|
-
if (!fs.existsSync(dir)) continue;
|
|
61
|
-
for (const f of fs.readdirSync(dir)) {
|
|
62
|
-
if (f.startsWith('skopix-backup') && f.endsWith('.zip')) {
|
|
63
|
-
found.push(path.join(dir, f));
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return found;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
console.log('');
|
|
71
|
-
console.log(c.cyan(c.bold(' SKOPIX RESTORE')));
|
|
72
|
-
console.log(' ' + c.dim('─'.repeat(50)));
|
|
73
|
-
|
|
74
|
-
// Find backup file
|
|
75
|
-
let src = process.argv[2];
|
|
76
|
-
|
|
77
|
-
if (!src) {
|
|
78
|
-
const found = findBackups();
|
|
79
|
-
if (found.length === 0) {
|
|
80
|
-
console.log(' ' + c.red('✖') + ' No backup files found on Desktop or Downloads.');
|
|
81
|
-
console.log(' Run: node skopix-restore.js /path/to/backup.zip');
|
|
82
|
-
process.exit(1);
|
|
83
|
-
} else if (found.length === 1) {
|
|
84
|
-
src = found[0];
|
|
85
|
-
console.log(' Found: ' + c.cyan(src));
|
|
86
|
-
} else {
|
|
87
|
-
console.log(' Found multiple backups:');
|
|
88
|
-
found.forEach((f, i) => console.log(' ' + (i + 1) + '. ' + f));
|
|
89
|
-
const choice = await ask('Which one to restore? (1-' + found.length + '): ');
|
|
90
|
-
const idx = parseInt(choice) - 1;
|
|
91
|
-
if (isNaN(idx) || idx < 0 || idx >= found.length) {
|
|
92
|
-
console.log(' ' + c.red('✖') + ' Invalid choice'); process.exit(1);
|
|
93
|
-
}
|
|
94
|
-
src = found[idx];
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (!fs.existsSync(src)) {
|
|
99
|
-
console.log(' ' + c.red('✖') + ' File not found: ' + src);
|
|
100
|
-
process.exit(1);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
console.log(' Backup : ' + c.cyan(src));
|
|
104
|
-
console.log('');
|
|
105
|
-
|
|
106
|
-
// Warn about overwrite
|
|
107
|
-
if (fs.existsSync(SKOPIX_DIR)) {
|
|
108
|
-
const existing = countFiles(SKOPIX_DIR);
|
|
109
|
-
console.log(' ' + c.yellow('⚠') + ' This will overwrite ~/.skopix (' + existing + ' files)');
|
|
110
|
-
const confirm = await ask('Continue? (y/N): ');
|
|
111
|
-
if (confirm.toLowerCase() !== 'y') {
|
|
112
|
-
console.log(' ' + c.yellow('⚠') + ' Restore cancelled.');
|
|
113
|
-
process.exit(0);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Auto-backup existing data first
|
|
117
|
-
const autoBackup = path.join(os.tmpdir(), 'skopix-pre-restore-' + Date.now());
|
|
118
|
-
copyDir(SKOPIX_DIR, autoBackup);
|
|
119
|
-
console.log(' ' + c.dim('Auto-backed up existing data to: ' + autoBackup));
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
console.log(' Restoring...');
|
|
123
|
-
|
|
124
|
-
try {
|
|
125
|
-
const tmpDir = path.join(os.tmpdir(), 'skopix-restore-' + Date.now());
|
|
126
|
-
fs.mkdirSync(tmpDir, { recursive: true });
|
|
127
|
-
|
|
128
|
-
const isWindows = process.platform === 'win32';
|
|
129
|
-
if (isWindows) {
|
|
130
|
-
const ps = `Expand-Archive -Path "${src}" -DestinationPath "${tmpDir}" -Force`;
|
|
131
|
-
execSync(`powershell -Command "${ps}"`, { stdio: 'pipe' });
|
|
132
|
-
} else {
|
|
133
|
-
execSync(`unzip -o "${src}" -d "${tmpDir}"`, { stdio: 'pipe' });
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// Find .skopix inside the extracted folder
|
|
137
|
-
const inner = path.join(tmpDir, '.skopix');
|
|
138
|
-
const actualSrc = fs.existsSync(inner) ? inner : tmpDir;
|
|
139
|
-
|
|
140
|
-
if (fs.existsSync(SKOPIX_DIR)) fs.rmSync(SKOPIX_DIR, { recursive: true, force: true });
|
|
141
|
-
copyDir(actualSrc, SKOPIX_DIR);
|
|
142
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
143
|
-
|
|
144
|
-
const restored = countFiles(SKOPIX_DIR);
|
|
145
|
-
console.log('');
|
|
146
|
-
console.log(' ' + c.green('✔') + ' Restored ' + restored + ' files to ' + c.cyan(SKOPIX_DIR));
|
|
147
|
-
console.log(' ' + c.green('✔') + ' Restart Skopix to see your data');
|
|
148
|
-
console.log('');
|
|
149
|
-
} catch (e) {
|
|
150
|
-
console.log(' ' + c.red('✖') + ' Restore failed: ' + e.message);
|
|
151
|
-
process.exit(1);
|
|
152
|
-
}
|