skopix 2.0.12 → 2.0.14
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 +26 -3
- package/cli/commands/dashboard.js +21 -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,19 @@ 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 || 1;
|
|
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
|
+
const applyZoom = async () => {
|
|
259
|
+
if (browserZoom !== 1) {
|
|
260
|
+
await page.evaluate((z) => { document.documentElement.style.zoom = z; }, String(browserZoom)).catch(() => {});
|
|
261
|
+
}
|
|
262
|
+
};
|
|
252
263
|
|
|
253
264
|
const allSteps = [...(setupTest ? (setupTest.steps || []) : []), ...(test.steps || [])];
|
|
254
265
|
let stepNum = 0, passed = true, failReason = '';
|
|
@@ -258,12 +269,20 @@ export async function agentCommand(options) {
|
|
|
258
269
|
await page.waitForTimeout(800);
|
|
259
270
|
}
|
|
260
271
|
|
|
272
|
+
// Apply zoom now if no setup test (otherwise applied at setup→main transition)
|
|
273
|
+
if (!setupTest) await applyZoom();
|
|
274
|
+
|
|
261
275
|
send({ type: 'stdout', text: '◆ Replaying ' + allSteps.length + ' steps on ' + os.hostname() });
|
|
262
276
|
|
|
263
277
|
for (const step of allSteps) {
|
|
264
278
|
stepNum++;
|
|
265
279
|
const sel = sanitiseSelector(step.stableSelector || step.selector);
|
|
266
280
|
const isSetup = setupTest && stepNum <= (setupTest.steps || []).length;
|
|
281
|
+
const isFirstMainStep = setupTest && stepNum === (setupTest.steps || []).length + 1;
|
|
282
|
+
|
|
283
|
+
// Apply zoom at the transition from setup to main test steps
|
|
284
|
+
if (isFirstMainStep) await applyZoom();
|
|
285
|
+
|
|
267
286
|
const desc = step.description || (step.action + ' ' + (sel || ''));
|
|
268
287
|
send({ type: 'stdout', text: ' [' + stepNum + '/' + allSteps.length + '] ' + step.action.toUpperCase() + (isSetup ? ' [SETUP]' : '') + ' — ' + desc });
|
|
269
288
|
|
|
@@ -354,12 +373,16 @@ export async function agentCommand(options) {
|
|
|
354
373
|
if (count > 1) {
|
|
355
374
|
let bi = 0, bd = Infinity;
|
|
356
375
|
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 {} }
|
|
376
|
+
await page.locator(s).nth(bi).scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
|
|
357
377
|
await page.locator(s).nth(bi).click({ timeout: 5000 }); clicked = true;
|
|
358
|
-
} else if (count === 1) {
|
|
378
|
+
} else if (count === 1) {
|
|
379
|
+
await page.locator(s).first().scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
|
|
380
|
+
await page.locator(s).first().click({ timeout: 5000 }); clicked = true;
|
|
381
|
+
}
|
|
359
382
|
} catch {}
|
|
360
383
|
}
|
|
361
384
|
}
|
|
362
|
-
if (!clicked) { for (const s of selectors) { if (clicked) break; try { await page.locator(s).first().click({ timeout: 5000 }); clicked = true; } catch {} } }
|
|
385
|
+
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
386
|
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
387
|
if (!clicked) throw new Error('Could not click: ' + selectors.join(', '));
|
|
365
388
|
await page.waitForTimeout(400);
|
|
@@ -2503,6 +2503,8 @@ function cleanTest(t) {
|
|
|
2503
2503
|
if (t.jira) out.jira = true;
|
|
2504
2504
|
if (t.linear) out.linear = true;
|
|
2505
2505
|
if (t.headless) out.headless = true;
|
|
2506
|
+
if (t.generateReport === false) out.generateReport = false;
|
|
2507
|
+
if (t.browserZoom && t.browserZoom !== 1) out.browserZoom = t.browserZoom;
|
|
2506
2508
|
return out;
|
|
2507
2509
|
}
|
|
2508
2510
|
|
|
@@ -2992,14 +2994,18 @@ function startReplay(test, setupTest, activeRuns, reportsDir, currentUser, env)
|
|
|
2992
2994
|
if (count > 1) {
|
|
2993
2995
|
let bestIdx = 0, bestDist = Infinity;
|
|
2994
2996
|
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 {} }
|
|
2997
|
+
await page.locator(s).nth(bestIdx).scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
|
|
2995
2998
|
await page.locator(s).nth(bestIdx).click({ timeout: 5000 });
|
|
2996
2999
|
if (count > 1) broadcast({ type: 'stdout', text: ' (matched element ' + (bestIdx+1) + ' of ' + count + ' by position)' });
|
|
2997
3000
|
clicked = true;
|
|
2998
|
-
} else if (count === 1) {
|
|
3001
|
+
} else if (count === 1) {
|
|
3002
|
+
await page.locator(s).first().scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
|
|
3003
|
+
await page.locator(s).first().waitFor({ state: 'visible', timeout: 3000 }); await page.locator(s).first().click({ timeout: 5000 }); clicked = true;
|
|
3004
|
+
}
|
|
2999
3005
|
} catch {}
|
|
3000
3006
|
}
|
|
3001
3007
|
}
|
|
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 {} } }
|
|
3008
|
+
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
3009
|
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
3010
|
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
3011
|
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 +3079,20 @@ function startReplay(test, setupTest, activeRuns, reportsDir, currentUser, env)
|
|
|
3073
3079
|
});
|
|
3074
3080
|
|
|
3075
3081
|
const wantReport = test.generateReport !== false;
|
|
3082
|
+
const browserZoom = test.browserZoom || 1;
|
|
3076
3083
|
|
|
3077
3084
|
const ctx = await chromiumBrowser.newContext({
|
|
3078
|
-
viewport: { width:
|
|
3079
|
-
|
|
3085
|
+
viewport: { width: 1920, height: 1080 },
|
|
3086
|
+
deviceScaleFactor: 1,
|
|
3087
|
+
...(wantReport ? { recordVideo: { dir: sessionDir, size: { width: 1920, height: 1080 } } } : {}),
|
|
3080
3088
|
});
|
|
3081
3089
|
const page = await ctx.newPage();
|
|
3090
|
+
// Apply zoom after page loads, not via addInitScript (which affects setup steps too)
|
|
3091
|
+
const applyZoom = async () => {
|
|
3092
|
+
if (browserZoom !== 1) {
|
|
3093
|
+
await page.evaluate((z) => { document.documentElement.style.zoom = z; }, String(browserZoom)).catch(() => {});
|
|
3094
|
+
}
|
|
3095
|
+
};
|
|
3082
3096
|
|
|
3083
3097
|
// Navigate to start URL
|
|
3084
3098
|
if (test.url) {
|
|
@@ -3100,6 +3114,9 @@ function startReplay(test, setupTest, activeRuns, reportsDir, currentUser, env)
|
|
|
3100
3114
|
broadcast({ type: 'stdout', text: ' \u2714 Setup complete — continuing with test steps' });
|
|
3101
3115
|
}
|
|
3102
3116
|
|
|
3117
|
+
// Apply zoom now (after setup, before main test steps)
|
|
3118
|
+
await applyZoom();
|
|
3119
|
+
|
|
3103
3120
|
// Run main test steps
|
|
3104
3121
|
if (setupTest) { broadcast({ type: 'stdout', text: '' }); broadcast({ type: 'stdout', text: ' ─── TEST: ' + test.name + ' ───' }); }
|
|
3105
3122
|
await executeSteps(test.steps, page, null);
|
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
|
-
}
|