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.
@@ -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 ctx = await browser.newContext({ viewport: { width: 1280, height: 800 }, recordVideo: { dir: sessionDir, size: { width: 1280, height: 800 } } });
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) { await page.locator(s).first().click({ timeout: 5000 }); clicked = true; }
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) { await page.locator(s).first().waitFor({ state: 'visible', timeout: 3000 }); await page.locator(s).first().click({ timeout: 5000 }); clicked = true; }
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: 1280, height: 800 },
3079
- ...(wantReport ? { recordVideo: { dir: sessionDir, size: { width: 1280, height: 800 } } } : {}),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skopix",
3
- "version": "2.0.12",
3
+ "version": "2.0.13",
4
4
  "description": "Browser-based QA tool — record tests by using your app, replay them deterministically, generate Playwright code automatically",
5
5
  "main": "cli/index.js",
6
6
  "bin": {
@@ -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,5 +0,0 @@
1
- #!/bin/bash
2
- node ~/Downloads/skopix_backup/skopix-backup.js
3
- echo ""
4
- echo "Press any key to close..."
5
- read -n 1
@@ -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,5 +0,0 @@
1
- #!/bin/bash
2
- node ~/Downloads/skopix_backup/skopix-restore.js
3
- echo ""
4
- echo "Press any key to close..."
5
- read -n 1
@@ -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
- }