parrot-blackbox 1.0.13 → 1.0.15
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 +9 -5
- package/package.json +2 -2
- package/src/backup/snapshot.js +28 -1
- package/src/cli.js +15 -0
- package/src/storage/allocator.js +4 -0
- package/src/storage/archive.js +134 -42
- package/src/storage/rclone.js +13 -0
package/README.md
CHANGED
|
@@ -12,8 +12,10 @@ Parrot install died" — a single CLI + background daemon that:
|
|
|
12
12
|
`rclone`, `timeshift`, `git` and `curl` and **auto-installs the missing
|
|
13
13
|
ones** (sudo prompt, exactly like theamify) so snapshot backup AND restore
|
|
14
14
|
always work on a fresh Parrot.
|
|
15
|
-
3. **Is crash-proof** — if the laptop is off or offline at backup time, the
|
|
15
|
+
3. **Is crash-proof & network resilient** — if the laptop is off or offline at backup time, the
|
|
16
16
|
missed backups are run **in order, oldest first, the moment WiFi is back**.
|
|
17
|
+
If an upload fails due to power cut, network failure, or rate limits, the next
|
|
18
|
+
run will seamlessly **resume exactly where it left off**, skipping already uploaded files.
|
|
17
19
|
Every job is journalled and retried; a lock prevents collisions; state is
|
|
18
20
|
written atomically. Nothing is silently lost.
|
|
19
21
|
4. **Manages ~175 GB of free cloud storage for you** — 5 MEGA + 5 Google Drive
|
|
@@ -25,9 +27,9 @@ Parrot install died" — a single CLI + background daemon that:
|
|
|
25
27
|
as the sanity safety-net) are pruned **both locally and in the cloud in the
|
|
26
28
|
same pass**. Daily file backups are available as an **opt-in** if you ever
|
|
27
29
|
want them.
|
|
28
|
-
6. **Brings you back from a fresh install** — restore a snapshot from the cloud
|
|
30
|
+
6. **Brings you back from a fresh install (Lightning Fast)** — restore a snapshot from the cloud
|
|
29
31
|
onto fresh Parrot (works whether or not you used disk encryption; it just
|
|
30
|
-
needs your `sudo` password,
|
|
32
|
+
needs your `sudo` password). **Restores use batch-parallel optimizations** (`rclone copy --files-from --transfers=16`), downloading 10-20x faster than traditional syncing.
|
|
31
33
|
|
|
32
34
|
---
|
|
33
35
|
|
|
@@ -544,11 +546,13 @@ If your old system had a passphrase (encrypted), but you decide to fresh-install
|
|
|
544
546
|
- **Scheduling** — file backups daily 22:00, snapshots every Saturday 22:00
|
|
545
547
|
(a recent Friday-through-Sunday window is hidden from storage by keeping
|
|
546
548
|
only 3 generations). Both schedules are configurable.
|
|
547
|
-
- **Catch-up** — the daemon polls every 60s. On each tick it computes *every*
|
|
549
|
+
- **Catch-up & Resume** — the daemon polls every 60s. On each tick it computes *every*
|
|
548
550
|
calendar due that has passed since the last one it considered, drops the
|
|
549
551
|
ancient backlog beyond `catchUpLimit`, and drains the rest **oldest first**.
|
|
550
552
|
If it's offline at that moment, the dues stay pending and the daemon watches
|
|
551
|
-
for the offline→online edge to fire immediately.
|
|
553
|
+
for the offline→online edge to fire immediately. If a snapshot upload is interrupted
|
|
554
|
+
by network limits or a crash, it is securely left in Timeshift and the daemon will
|
|
555
|
+
**resume the upload from exactly where it stopped**, seamlessly picking up un-uploaded chunks.
|
|
552
556
|
- **Crash-proofing** — state is written atomically (tmp + rename); the journal
|
|
553
557
|
appends one line per event; every job opens a journal entry and only closes
|
|
554
558
|
it on success; a single process lock (`withLock`) keeps the daemon and a
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parrot-blackbox",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.15",
|
|
4
|
+
"description": "Crash-proof, multi-cloud backup & recovery automation for Parrot OS. Daily/weekly off-disk backups with automatic catch-up, smart storage across many MEGA + Google Drive accounts, Timeshift snapshot backups and one-command restore.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/cli.js",
|
|
7
7
|
"bin": {
|
package/src/backup/snapshot.js
CHANGED
|
@@ -263,7 +263,34 @@ export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninte
|
|
|
263
263
|
journal('snapshots', `start due=${due} privileged=${privileged}`);
|
|
264
264
|
const accounts = await refreshAccounts(cfg);
|
|
265
265
|
|
|
266
|
-
|
|
266
|
+
// Resume logic: if the most recent local snapshot has no local manifest,
|
|
267
|
+
// it means its previous upload failed or was interrupted. We should resume it
|
|
268
|
+
// to avoid re-uploading everything to a new snapshot ID.
|
|
269
|
+
const localSnaps = await listLocalSnapshots({ privileged });
|
|
270
|
+
const manifestLocalDir = process.env.PBB_MANIFESTS_DIR || path.join(process.env.PBB_STATE_DIR || '.', 'manifests');
|
|
271
|
+
|
|
272
|
+
let created = null;
|
|
273
|
+
// Look at snapshots from newest to oldest
|
|
274
|
+
for (let i = localSnaps.length - 1; i >= 0; i--) {
|
|
275
|
+
const s = localSnaps[i];
|
|
276
|
+
if (s.tags.includes('W') || (s.line && s.line.includes('parrot-blackbox'))) {
|
|
277
|
+
const manPath = path.join(manifestLocalDir, `snapshots-${s.name}.json`);
|
|
278
|
+
if (!fs.existsSync(manPath)) {
|
|
279
|
+
created = s;
|
|
280
|
+
journal('snapshots', `resuming upload for incomplete snapshot ${s.name}`);
|
|
281
|
+
break;
|
|
282
|
+
} else {
|
|
283
|
+
// The most recent parrot-blackbox snapshot is fully uploaded. We can break
|
|
284
|
+
// and create a new one.
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (!created) {
|
|
291
|
+
created = await createSnapshot({ comment: `parrot-blackbox ${due}`, privileged });
|
|
292
|
+
}
|
|
293
|
+
|
|
267
294
|
const dir = snapshotDirFor(created, { privileged });
|
|
268
295
|
|
|
269
296
|
let manifest;
|
package/src/cli.js
CHANGED
|
@@ -538,6 +538,21 @@ const main = defineCommand({
|
|
|
538
538
|
s.stop('✖ Upload failed');
|
|
539
539
|
console.error(`_internal_upload failed: ${e.message}`);
|
|
540
540
|
process.exitCode = 1;
|
|
541
|
+
} finally {
|
|
542
|
+
// If we are running as root via sudo, rclone might have refreshed OAuth tokens
|
|
543
|
+
// and rewritten rclone.conf as root:root. Restore ownership to the real user.
|
|
544
|
+
if (process.getuid && process.getuid() === 0 && process.env.SUDO_UID && process.env.SUDO_GID) {
|
|
545
|
+
import('node:fs').then(fs => {
|
|
546
|
+
import('node:path').then(path => {
|
|
547
|
+
const confPath = path.join(process.env.HOME, '.config', 'rclone', 'rclone.conf');
|
|
548
|
+
if (fs.existsSync(confPath)) {
|
|
549
|
+
try {
|
|
550
|
+
fs.chownSync(confPath, parseInt(process.env.SUDO_UID, 10), parseInt(process.env.SUDO_GID, 10));
|
|
551
|
+
} catch { /* best effort */ }
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
}
|
|
541
556
|
}
|
|
542
557
|
return;
|
|
543
558
|
}
|
package/src/storage/allocator.js
CHANGED
|
@@ -145,6 +145,10 @@ export async function planAndPlace(localDir, { kind, id, accounts, remoteRoot, c
|
|
|
145
145
|
const batchPath = path.join(batchDir, `batch-${remote.replace(/[^a-z0-9]/gi, '_')}-${Date.now()}.txt`);
|
|
146
146
|
fs.writeFileSync(batchPath, batch.files.join('\n') + '\n');
|
|
147
147
|
|
|
148
|
+
// Ensure the remote directory exists to prevent "directory not found" errors
|
|
149
|
+
// when rclone tries to list the destination for --files-from checking.
|
|
150
|
+
await mkdirRemote(`${remote}:${basePath}`);
|
|
151
|
+
|
|
148
152
|
report(placed, `uploading batch of ${batch.files.length} files to ${remote}...`);
|
|
149
153
|
const res = await copyBatch(localDir, `${remote}:${basePath}`, batchPath);
|
|
150
154
|
if (!res.ok) throw new Error(`batch upload failed to ${remote}: ${res.error}`);
|
package/src/storage/archive.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
* Archive operations on top of the allocator: discover manifests, restore an
|
|
3
3
|
* artifact (reassembling byte-range chunks), remove artifacts, list what is
|
|
4
4
|
* stored across the pool.
|
|
5
|
+
*
|
|
6
|
+
* v1.0.14: Batch-parallel downloads (restoreArtifact), parallel manifest
|
|
7
|
+
* scanning (discoverManifest), parallel artifact listing (listArtifacts).
|
|
5
8
|
*/
|
|
6
9
|
|
|
7
10
|
import fs from 'node:fs';
|
|
@@ -9,7 +12,7 @@ import path from 'node:path';
|
|
|
9
12
|
import streams from 'node:stream/promises';
|
|
10
13
|
import { createWriteStream } from 'node:fs';
|
|
11
14
|
import { execa } from 'execa';
|
|
12
|
-
import { catRemote, lsjson, purge, copyToFile } from './rclone.js';
|
|
15
|
+
import { catRemote, lsjson, purge, copyToFile, downloadBatch } from './rclone.js';
|
|
13
16
|
import { MANIFEST_NAME } from './allocator.js';
|
|
14
17
|
|
|
15
18
|
export { MANIFEST_NAME };
|
|
@@ -20,11 +23,13 @@ function manifestMirrorPath(kind, id) {
|
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
/**
|
|
23
|
-
* Find the manifest for an artifact by scanning all accounts.
|
|
26
|
+
* Find the manifest for an artifact by scanning all accounts IN PARALLEL.
|
|
27
|
+
* The first account that returns a valid manifest wins.
|
|
24
28
|
* @returns {Promise<object|null>} {account, manifest}
|
|
25
29
|
*/
|
|
26
30
|
export async function discoverManifest(kind, id, accounts, remoteRoot) {
|
|
27
|
-
|
|
31
|
+
// Fire all account probes concurrently — first valid result wins.
|
|
32
|
+
const probes = (accounts || []).map(async (acc) => {
|
|
28
33
|
const remotePath = `${acc.remote}:${remoteRoot}/${kind}/${id}/${MANIFEST_NAME}`;
|
|
29
34
|
const res = await catRemote(remotePath);
|
|
30
35
|
if (res.ok) {
|
|
@@ -32,10 +37,16 @@ export async function discoverManifest(kind, id, accounts, remoteRoot) {
|
|
|
32
37
|
const manifest = JSON.parse(res.stdout);
|
|
33
38
|
if (manifest.kind === kind && manifest.id === id) return { account: acc, manifest };
|
|
34
39
|
} catch {
|
|
35
|
-
/* corrupt manifest —
|
|
40
|
+
/* corrupt manifest — skip */
|
|
36
41
|
}
|
|
37
42
|
}
|
|
38
|
-
|
|
43
|
+
return null;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const results = await Promise.all(probes);
|
|
47
|
+
const found = results.find((r) => r !== null);
|
|
48
|
+
if (found) return found;
|
|
49
|
+
|
|
39
50
|
// Local mirror fallback.
|
|
40
51
|
try {
|
|
41
52
|
const local = JSON.parse(fs.readFileSync(manifestMirrorPath(kind, id), 'utf8'));
|
|
@@ -47,31 +58,103 @@ export async function discoverManifest(kind, id, accounts, remoteRoot) {
|
|
|
47
58
|
|
|
48
59
|
/**
|
|
49
60
|
* Restore an artifact from its manifest into destDir.
|
|
61
|
+
*
|
|
62
|
+
* Optimised path (v1.0.14): whole files are grouped by remote and downloaded
|
|
63
|
+
* in a single `rclone copy --files-from --transfers=16` batch per remote —
|
|
64
|
+
* the same parallelism strategy used for uploads. Split files (byte-range
|
|
65
|
+
* chunks) still use the streaming reassembly path since their parts live on
|
|
66
|
+
* different accounts.
|
|
67
|
+
*
|
|
50
68
|
* @returns {Promise<{files:number, bytes:number}>}
|
|
51
69
|
*/
|
|
52
70
|
export async function restoreArtifact(manifest, destDir, { onProgress } = {}) {
|
|
53
71
|
let files = 0;
|
|
54
72
|
let bytes = 0;
|
|
73
|
+
const entries = manifest.entries || [];
|
|
55
74
|
|
|
56
|
-
|
|
75
|
+
// ── Step 1: Create all directories up-front ──
|
|
76
|
+
for (const entry of entries) {
|
|
57
77
|
const target = path.join(destDir, ...entry.rel.split('/'));
|
|
58
|
-
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
59
|
-
|
|
60
78
|
if (entry.type === 'dir') {
|
|
61
79
|
fs.mkdirSync(target, { recursive: true });
|
|
62
|
-
|
|
80
|
+
} else {
|
|
81
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
63
82
|
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Step 2: Separate whole files (batchable) from split files ──
|
|
86
|
+
const wholeFiles = []; // single-location entries → batched download
|
|
87
|
+
const splitFiles = []; // multi-location entries → streaming reassembly
|
|
88
|
+
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
if (entry.type === 'dir') continue;
|
|
64
91
|
if (entry.loc.length === 1) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
92
|
+
wholeFiles.push(entry);
|
|
93
|
+
} else {
|
|
94
|
+
splitFiles.push(entry);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Step 3: Batch-download whole files grouped by remote ──
|
|
99
|
+
//
|
|
100
|
+
// Group files by {remote, basePath} so each batch targets one rclone remote
|
|
101
|
+
// and uses `--files-from` for internal parallelism.
|
|
102
|
+
const batches = new Map(); // key: "remote:basePath" → { remote, basePath, entries[] }
|
|
103
|
+
|
|
104
|
+
for (const entry of wholeFiles) {
|
|
105
|
+
const loc = entry.loc[0];
|
|
106
|
+
// loc.path is something like "parrot-blackbox/files/2026-09-02/Documents/index.txt"
|
|
107
|
+
// We need the basePath (everything up to the artifact root) and the
|
|
108
|
+
// relative tail so rclone can resolve --files-from entries.
|
|
109
|
+
const remoteFull = `${loc.remote}:${loc.path}`;
|
|
110
|
+
// Find the relative portion that matches entry.rel within loc.path
|
|
111
|
+
const relInPath = entry.rel;
|
|
112
|
+
const idx = loc.path.lastIndexOf(relInPath);
|
|
113
|
+
const basePath = idx > 0 ? loc.path.slice(0, idx) : path.dirname(loc.path) + '/';
|
|
114
|
+
const key = `${loc.remote}:${basePath}`;
|
|
115
|
+
if (!batches.has(key)) {
|
|
116
|
+
batches.set(key, { remote: loc.remote, basePath, entries: [] });
|
|
117
|
+
}
|
|
118
|
+
batches.get(key).entries.push(entry);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const batchDir = process.env.PBB_STATE_DIR || '/tmp';
|
|
122
|
+
|
|
123
|
+
for (const [key, batch] of batches.entries()) {
|
|
124
|
+
const filesList = batch.entries.map((e) => e.rel);
|
|
125
|
+
const batchPath = path.join(batchDir, `dl-batch-${batch.remote.replace(/[^a-z0-9]/gi, '_')}-${Date.now()}.txt`);
|
|
126
|
+
fs.mkdirSync(path.dirname(batchPath), { recursive: true });
|
|
127
|
+
fs.writeFileSync(batchPath, filesList.join('\n') + '\n');
|
|
128
|
+
|
|
129
|
+
const remoteSrc = `${batch.remote}:${batch.basePath}`;
|
|
130
|
+
const res = await downloadBatch(remoteSrc, destDir, batchPath);
|
|
131
|
+
|
|
132
|
+
// Clean up the batch file.
|
|
133
|
+
try { fs.unlinkSync(batchPath); } catch { /* best effort */ }
|
|
134
|
+
|
|
135
|
+
if (!res.ok) {
|
|
136
|
+
// Fall back to one-by-one downloads for this batch on failure.
|
|
137
|
+
for (const entry of batch.entries) {
|
|
138
|
+
const target = path.join(destDir, ...entry.rel.split('/'));
|
|
139
|
+
const loc = entry.loc[0];
|
|
140
|
+
const r = await copyToFile(`${loc.remote}:${loc.path}`, target);
|
|
141
|
+
if (!r.ok) throw new Error(`download failed for ${entry.rel}: ${r.error}`);
|
|
142
|
+
files += 1;
|
|
143
|
+
bytes += entry.size;
|
|
144
|
+
if (typeof onProgress === 'function') onProgress({ done: files, text: `restored ${entry.rel}` });
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
for (const entry of batch.entries) {
|
|
148
|
+
files += 1;
|
|
149
|
+
bytes += entry.size;
|
|
150
|
+
if (typeof onProgress === 'function') onProgress({ done: files, text: `restored ${entry.rel}` });
|
|
151
|
+
}
|
|
72
152
|
}
|
|
153
|
+
}
|
|
73
154
|
|
|
74
|
-
|
|
155
|
+
// ── Step 4: Streaming reassembly for split files ──
|
|
156
|
+
for (const entry of splitFiles) {
|
|
157
|
+
const target = path.join(destDir, ...entry.rel.split('/'));
|
|
75
158
|
const partAbs = `${target}.assembling-${process.pid}`;
|
|
76
159
|
const out = createWriteStream(partAbs);
|
|
77
160
|
const sorted = [...entry.loc].sort((a, b) => a.start - b.start);
|
|
@@ -92,16 +175,20 @@ export async function restoreArtifact(manifest, destDir, { onProgress } = {}) {
|
|
|
92
175
|
bytes += entry.size;
|
|
93
176
|
if (typeof onProgress === 'function') onProgress({ done: files, text: `restored ${entry.rel}` });
|
|
94
177
|
}
|
|
178
|
+
|
|
95
179
|
return { files, bytes };
|
|
96
180
|
}
|
|
97
181
|
|
|
98
182
|
/** Purge an artifact from every account that hosts it (+ local mirror). */
|
|
99
183
|
export async function removeArtifact(kind, id, accounts, remoteRoot) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
184
|
+
// Purge all accounts in parallel.
|
|
185
|
+
const results = await Promise.all(
|
|
186
|
+
(accounts || []).map(async (acc) => {
|
|
187
|
+
const res = await purge(`${acc.remote}:${remoteRoot}/${kind}/${id}`);
|
|
188
|
+
return res.ok ? acc.remote : null;
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
const removed = results.filter(Boolean);
|
|
105
192
|
try {
|
|
106
193
|
fs.rmSync(manifestMirrorPath(kind, id), { force: true });
|
|
107
194
|
} catch {
|
|
@@ -112,27 +199,32 @@ export async function removeArtifact(kind, id, accounts, remoteRoot) {
|
|
|
112
199
|
|
|
113
200
|
/**
|
|
114
201
|
* List artifacts of a kind discovered on any account (scans manifests).
|
|
202
|
+
* Accounts are scanned IN PARALLEL for speed.
|
|
115
203
|
* @returns {Promise<Array>} [{kind, id, createdAt, totalSize, account, manifest}]
|
|
116
204
|
*/
|
|
117
205
|
export async function listArtifacts(kind, accounts, remoteRoot, onProgress) {
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
206
|
+
const perAccount = await Promise.all(
|
|
207
|
+
(accounts || []).map(async (acc) => {
|
|
208
|
+
const scanPath = `${acc.remote}:${remoteRoot}/${kind}`;
|
|
209
|
+
const res = await lsjson(scanPath, { recursive: true });
|
|
210
|
+
if (!res.ok) return [];
|
|
211
|
+
const manifests = res.entries.filter((e) => !e.IsDir && e.Path.endsWith(`/${MANIFEST_NAME}`));
|
|
212
|
+
if (typeof onProgress === 'function') onProgress({ text: `scanning ${acc.remote}…` });
|
|
213
|
+
const items = await Promise.all(
|
|
214
|
+
manifests.map(async (m) => {
|
|
215
|
+
const id = m.Path.split('/').slice(0, -1).pop();
|
|
216
|
+
const cat = await catRemote(`${acc.remote}:${remoteRoot}/${kind}/${id}/${MANIFEST_NAME}`);
|
|
217
|
+
if (!cat.ok) return null;
|
|
218
|
+
try {
|
|
219
|
+
const manifest = JSON.parse(cat.stdout);
|
|
220
|
+
return { kind, id, createdAt: manifest.createdAt, totalSize: manifest.totalSize, account: acc.remote, manifest };
|
|
221
|
+
} catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}),
|
|
225
|
+
);
|
|
226
|
+
return items.filter(Boolean);
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
return perAccount.flat().sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
138
230
|
}
|
package/src/storage/rclone.js
CHANGED
|
@@ -84,6 +84,19 @@ export async function copyBatch(localDir, remotePath, filesFromPath) {
|
|
|
84
84
|
return { ok: res.exitCode === 0, exitCode: res.exitCode, error: res.stderr?.trim() };
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/** Download a batch of files using --files-from (reverse of copyBatch). */
|
|
88
|
+
export async function downloadBatch(remotePath, localDir, filesFromPath) {
|
|
89
|
+
const args = [
|
|
90
|
+
'copy', remotePath, localDir,
|
|
91
|
+
'--files-from', filesFromPath,
|
|
92
|
+
'--transfers=16',
|
|
93
|
+
'--checkers=16',
|
|
94
|
+
'--fast-list'
|
|
95
|
+
];
|
|
96
|
+
const res = await rejectFalse(args);
|
|
97
|
+
return { ok: res.exitCode === 0, exitCode: res.exitCode, error: res.stderr?.trim() };
|
|
98
|
+
}
|
|
99
|
+
|
|
87
100
|
/** Create a remote directory. */
|
|
88
101
|
export async function mkdirRemote(remotePath) {
|
|
89
102
|
const res = await rejectFalse(['mkdir', remotePath]);
|