dorky 4.1.6 → 4.2.0
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/bin/index.js +444 -176
- package/package.json +7 -1
package/bin/index.js
CHANGED
|
@@ -11,6 +11,11 @@ const EOL = require("os").type() == "Darwin" ? "\r\n" : "\n";
|
|
|
11
11
|
const { GetObjectCommand, PutObjectCommand, ListObjectsV2Command, DeleteObjectCommand, DeleteObjectsCommand, S3Client } = require("@aws-sdk/client-s3");
|
|
12
12
|
const { authenticate } = require('@google-cloud/local-auth');
|
|
13
13
|
const { google } = require('googleapis');
|
|
14
|
+
const ora = require("ora");
|
|
15
|
+
const boxen = require("boxen");
|
|
16
|
+
const prompts = require("prompts");
|
|
17
|
+
const Table = require("cli-table3");
|
|
18
|
+
const gradient = require("gradient-string");
|
|
14
19
|
|
|
15
20
|
// Constants & Config
|
|
16
21
|
const DORKY_DIR = ".dorky";
|
|
@@ -20,6 +25,8 @@ const HISTORY_PATH = path.join(DORKY_DIR, "history.json");
|
|
|
20
25
|
const GD_CREDENTIALS_PATH = path.join(__dirname, "../google-drive-credentials.json");
|
|
21
26
|
const SCOPES = ['https://www.googleapis.com/auth/drive'];
|
|
22
27
|
|
|
28
|
+
const isTTY = Boolean(process.stdout.isTTY && process.stdin.isTTY) && process.env.NO_COLOR !== "1";
|
|
29
|
+
|
|
23
30
|
// Helpers
|
|
24
31
|
const readJson = (p) => existsSync(p) ? JSON.parse(readFileSync(p)) : {};
|
|
25
32
|
const writeJson = (p, d) => writeFileSync(p, JSON.stringify(d, null, 2));
|
|
@@ -41,6 +48,24 @@ const readHistory = () => {
|
|
|
41
48
|
return history.map(e => ({ ...e, files: normalizeKeys(e.files) }));
|
|
42
49
|
};
|
|
43
50
|
|
|
51
|
+
// UX helpers
|
|
52
|
+
const makeSpinner = (text) => {
|
|
53
|
+
if (!isTTY) {
|
|
54
|
+
return {
|
|
55
|
+
start() { return this; },
|
|
56
|
+
succeed(t) { if (t) console.log(chalk.green(`✔ ${t}`)); return this; },
|
|
57
|
+
fail(t) { if (t) console.log(chalk.red(`✖ ${t}`)); return this; },
|
|
58
|
+
warn(t) { if (t) console.log(chalk.yellow(`⚠ ${t}`)); return this; },
|
|
59
|
+
info(t) { if (t) console.log(chalk.cyan(`ℹ ${t}`)); return this; },
|
|
60
|
+
stop() { return this; },
|
|
61
|
+
set text(v) { },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return ora({ text, spinner: "dots", color: "cyan" });
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const divider = () => isTTY ? chalk.gray("─".repeat(Math.min(60, (process.stdout.columns || 60) - 2))) : "";
|
|
68
|
+
|
|
44
69
|
const checkDorkyProject = () => {
|
|
45
70
|
if (!existsSync(DORKY_DIR) && !existsSync(".dorkyignore")) {
|
|
46
71
|
console.log(chalk.red("✖ Not a dorky project. Please run ") + chalk.cyan("dorky --init [aws|google-drive]"));
|
|
@@ -55,9 +80,22 @@ const figlet = `
|
|
|
55
80
|
|_____|_____|__| |__|__|___ |\t
|
|
56
81
|
|_____|\t
|
|
57
82
|
`;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
83
|
+
|
|
84
|
+
function renderBanner() {
|
|
85
|
+
if (!isTTY) {
|
|
86
|
+
console.log(figlet);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const colored = gradient(["#00f5d4", "#00bbf9", "#9b5de5"]).multiline(figlet);
|
|
90
|
+
const tagline = chalk.gray("DevOps Records Keeper") + chalk.gray(" · ") + chalk.cyan("dorky --help");
|
|
91
|
+
console.log(boxen(`${colored}\n ${tagline}`, {
|
|
92
|
+
padding: { top: 0, bottom: 0, left: 2, right: 2 },
|
|
93
|
+
margin: { top: 1, bottom: 1, left: 0, right: 0 },
|
|
94
|
+
borderStyle: "round",
|
|
95
|
+
borderColor: "cyan",
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
renderBanner();
|
|
61
99
|
|
|
62
100
|
const args = yargs
|
|
63
101
|
.option("init", { alias: "i", describe: "Initialize dorky", type: "string" })
|
|
@@ -70,9 +108,10 @@ const args = yargs
|
|
|
70
108
|
.option("destroy", { alias: "d", describe: "Destroy project", type: "boolean" })
|
|
71
109
|
.option("log", { alias: "lg", describe: "Show push history", type: "boolean" })
|
|
72
110
|
.option("checkout", { alias: "co", describe: "Restore files from a history commit", type: "string" })
|
|
111
|
+
.option("interactive", { describe: "Open the interactive menu", type: "boolean" })
|
|
73
112
|
.help('help').strict().argv;
|
|
74
113
|
|
|
75
|
-
|
|
114
|
+
const noArgs = Object.keys(args).length === 2 && args._.length === 0;
|
|
76
115
|
|
|
77
116
|
function updateGitIgnore() {
|
|
78
117
|
let content = existsSync(".gitignore") ? readFileSync(".gitignore").toString() : "";
|
|
@@ -125,8 +164,15 @@ async function init(storage) {
|
|
|
125
164
|
credentials = { storage: "aws", accessKey: process.env.AWS_ACCESS_KEY, secretKey: process.env.AWS_SECRET_KEY, awsRegion: process.env.AWS_REGION, bucket: process.env.BUCKET_NAME };
|
|
126
165
|
} else {
|
|
127
166
|
if (!existsSync(DORKY_DIR)) mkdirSync(DORKY_DIR);
|
|
128
|
-
const
|
|
129
|
-
|
|
167
|
+
const spinner = makeSpinner("Waiting for Google Drive authorization...").start();
|
|
168
|
+
try {
|
|
169
|
+
const client = await authorizeGoogleDriveClient(true);
|
|
170
|
+
credentials = { storage: "google-drive", ...client.credentials };
|
|
171
|
+
spinner.succeed("Google Drive authorized");
|
|
172
|
+
} catch (err) {
|
|
173
|
+
spinner.fail("Google Drive authorization failed");
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
130
176
|
}
|
|
131
177
|
|
|
132
178
|
if (!existsSync(DORKY_DIR)) mkdirSync(DORKY_DIR);
|
|
@@ -134,7 +180,7 @@ async function init(storage) {
|
|
|
134
180
|
if (!existsSync(HISTORY_PATH)) writeJson(HISTORY_PATH, []);
|
|
135
181
|
if (!existsSync(".dorkyignore")) writeFileSync(".dorkyignore", "");
|
|
136
182
|
writeJson(CREDENTIALS_PATH, credentials);
|
|
137
|
-
console.log(chalk.green("✔ Dorky project initialized successfully."));
|
|
183
|
+
console.log(chalk.green("✔ Dorky project initialized successfully.") + chalk.gray(` [${storage}]`));
|
|
138
184
|
updateGitIgnore();
|
|
139
185
|
}
|
|
140
186
|
|
|
@@ -145,29 +191,39 @@ async function list(type) {
|
|
|
145
191
|
if (!await checkCredentials()) return;
|
|
146
192
|
const creds = readJson(CREDENTIALS_PATH);
|
|
147
193
|
const root = path.basename(process.cwd());
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
194
|
+
const spinner = makeSpinner("Fetching remote file listing...").start();
|
|
195
|
+
const remoteFiles = [];
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
if (creds.storage === "aws") {
|
|
199
|
+
await runS3(creds, async (s3, bucket) => {
|
|
200
|
+
const data = await s3.send(new ListObjectsV2Command({ Bucket: bucket, Prefix: root + "/" }));
|
|
201
|
+
(data.Contents || []).forEach(o => remoteFiles.push(o.Key.replace(root + "/", "")));
|
|
202
|
+
});
|
|
203
|
+
} else {
|
|
204
|
+
await runDrive(async (drive) => {
|
|
205
|
+
const q = `name='${root}' and mimeType='application/vnd.google-apps.folder' and 'root' in parents and trashed=false`;
|
|
206
|
+
const { data: { files: [folder] } } = await drive.files.list({ q, fields: 'files(id)' });
|
|
207
|
+
if (!folder) return;
|
|
208
|
+
const walk = async (pid, p = '') => {
|
|
209
|
+
const { data: { files } } = await drive.files.list({ q: `'${pid}' in parents and trashed=false`, fields: 'files(id, name, mimeType)' });
|
|
210
|
+
for (const f of files) {
|
|
211
|
+
if (f.mimeType === 'application/vnd.google-apps.folder') await walk(f.id, path.join(p, f.name));
|
|
212
|
+
else remoteFiles.push(path.join(p, f.name));
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
await walk(folder.id);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
spinner.stop();
|
|
219
|
+
} catch (err) {
|
|
220
|
+
spinner.fail("Failed to fetch remote files");
|
|
221
|
+
throw err;
|
|
170
222
|
}
|
|
223
|
+
|
|
224
|
+
console.log(chalk.blue.bold("\n☁ Remote Files:"));
|
|
225
|
+
if (!remoteFiles.length) return console.log(chalk.yellow("ℹ No remote files found."));
|
|
226
|
+
remoteFiles.forEach(f => console.log(chalk.cyan(` ${f}`)));
|
|
171
227
|
} else {
|
|
172
228
|
console.log(chalk.blue.bold("\n📂 Untracked Files:"));
|
|
173
229
|
const exclusions = existsSync(".dorkyignore") ? readFileSync(".dorkyignore").toString().split(EOL).filter(Boolean) : [];
|
|
@@ -179,7 +235,9 @@ async function list(type) {
|
|
|
179
235
|
else console.log(chalk.gray(` ${rel}`));
|
|
180
236
|
});
|
|
181
237
|
console.log(chalk.blue.bold("\n📦 Staged Files:"));
|
|
182
|
-
Object.keys(meta["stage-1-files"])
|
|
238
|
+
const staged = Object.keys(meta["stage-1-files"]);
|
|
239
|
+
if (!staged.length) console.log(chalk.gray(" (nothing staged)"));
|
|
240
|
+
else staged.forEach(f => console.log(chalk.green(` ✔ ${f}`)));
|
|
183
241
|
}
|
|
184
242
|
}
|
|
185
243
|
|
|
@@ -303,56 +361,75 @@ async function push() {
|
|
|
303
361
|
const history = readHistory();
|
|
304
362
|
if (history.length > 0 && history[history.length - 1].id === commitId) return console.log(chalk.yellow("ℹ Already on the latest commit. Nothing to push."));
|
|
305
363
|
|
|
364
|
+
console.log(chalk.blue.bold(`\n🚀 Pushing ${chalk.cyan(filesToUpload.length)} upload(s), ${chalk.cyan(filesToDelete.length)} deletion(s)`));
|
|
365
|
+
|
|
306
366
|
const creds = readJson(CREDENTIALS_PATH);
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
await
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
} else if (creds.storage === "google-drive") {
|
|
325
|
-
await runDrive(async (drive) => {
|
|
326
|
-
if (filesToUpload.length > 0) {
|
|
327
|
-
for (const f of filesToUpload) {
|
|
328
|
-
const root = path.basename(process.cwd());
|
|
329
|
-
const parentId = await getFolderId(path.posix.dirname(path.posix.join(root, f.name)), drive);
|
|
330
|
-
await drive.files.create({
|
|
331
|
-
requestBody: { name: path.posix.basename(f.name), parents: [parentId] },
|
|
332
|
-
media: { mimeType: f["mime-type"], body: createReadStream(f.name) }
|
|
333
|
-
});
|
|
334
|
-
console.log(chalk.green(`✔ Uploaded: ${f.name}`));
|
|
367
|
+
const total = filesToUpload.length + filesToDelete.length;
|
|
368
|
+
let done = 0;
|
|
369
|
+
const spinner = makeSpinner(`Syncing files... 0/${total}`).start();
|
|
370
|
+
const tick = (label) => {
|
|
371
|
+
done += 1;
|
|
372
|
+
spinner.text = `${label} ${done}/${total}`;
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
if (creds.storage === "aws") {
|
|
377
|
+
await runS3(creds, async (s3, bucket) => {
|
|
378
|
+
if (filesToUpload.length > 0) {
|
|
379
|
+
await Promise.all(filesToUpload.map(async f => {
|
|
380
|
+
const key = path.posix.join(path.basename(process.cwd()), f.name);
|
|
381
|
+
await s3.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: readFileSync(f.name) }));
|
|
382
|
+
tick(`Uploaded ${f.name}`);
|
|
383
|
+
}));
|
|
335
384
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
385
|
+
if (filesToDelete.length > 0) {
|
|
386
|
+
await Promise.all(filesToDelete.map(async f => {
|
|
387
|
+
const key = path.posix.join(path.basename(process.cwd()), f);
|
|
388
|
+
await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
|
|
389
|
+
tick(`Deleted remote ${f}`);
|
|
390
|
+
}));
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
} else if (creds.storage === "google-drive") {
|
|
394
|
+
await runDrive(async (drive) => {
|
|
395
|
+
if (filesToUpload.length > 0) {
|
|
396
|
+
for (const f of filesToUpload) {
|
|
397
|
+
const root = path.basename(process.cwd());
|
|
398
|
+
const parentId = await getFolderId(path.posix.dirname(path.posix.join(root, f.name)), drive);
|
|
399
|
+
await drive.files.create({
|
|
400
|
+
requestBody: { name: path.posix.basename(f.name), parents: [parentId] },
|
|
401
|
+
media: { mimeType: f["mime-type"], body: createReadStream(f.name) }
|
|
345
402
|
});
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
403
|
+
tick(`Uploaded ${f.name}`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (filesToDelete.length > 0) {
|
|
407
|
+
const root = path.basename(process.cwd());
|
|
408
|
+
for (const f of filesToDelete) {
|
|
409
|
+
const parentId = await getFolderId(path.posix.dirname(path.posix.join(root, f)), drive, false);
|
|
410
|
+
if (parentId) {
|
|
411
|
+
const res = await drive.files.list({
|
|
412
|
+
q: `name='${path.posix.basename(f)}' and '${parentId}' in parents and trashed=false`,
|
|
413
|
+
fields: 'files(id)'
|
|
414
|
+
});
|
|
415
|
+
if (res.data.files[0]) {
|
|
416
|
+
await drive.files.delete({ fileId: res.data.files[0].id });
|
|
417
|
+
tick(`Deleted remote ${f}`);
|
|
418
|
+
}
|
|
349
419
|
}
|
|
350
420
|
}
|
|
351
421
|
}
|
|
352
|
-
}
|
|
353
|
-
}
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
spinner.succeed(`Synced ${done}/${total}`);
|
|
425
|
+
} catch (err) {
|
|
426
|
+
spinner.fail("Push failed");
|
|
427
|
+
throw err;
|
|
354
428
|
}
|
|
355
429
|
|
|
430
|
+
filesToUpload.forEach(f => console.log(chalk.green(`✔ Uploaded: ${f.name}`)));
|
|
431
|
+
filesToDelete.forEach(f => console.log(chalk.yellow(`✔ Deleted remote: ${f}`)));
|
|
432
|
+
|
|
356
433
|
meta["uploaded-files"] = { ...meta["stage-1-files"] };
|
|
357
434
|
writeJson(METADATA_PATH, meta);
|
|
358
435
|
|
|
@@ -361,23 +438,30 @@ async function push() {
|
|
|
361
438
|
|
|
362
439
|
const root = path.basename(process.cwd());
|
|
363
440
|
const historyPrefix = path.posix.join(root, ".dorky-history", commitId);
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
await
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
441
|
+
const historySpinner = makeSpinner(`Archiving commit ${commitId}...`).start();
|
|
442
|
+
try {
|
|
443
|
+
if (creds.storage === "aws") {
|
|
444
|
+
await runS3(creds, async (s3, bucket) => {
|
|
445
|
+
await Promise.all(Object.keys(commitFiles).map(async f => {
|
|
446
|
+
const key = path.posix.join(historyPrefix, f);
|
|
447
|
+
await s3.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: readFileSync(f) }));
|
|
448
|
+
}));
|
|
449
|
+
});
|
|
450
|
+
} else if (creds.storage === "google-drive") {
|
|
451
|
+
await runDrive(async (drive) => {
|
|
452
|
+
for (const f of Object.keys(commitFiles)) {
|
|
453
|
+
const parentId = await getFolderId(path.posix.join(root, ".dorky-history", commitId, path.posix.dirname(f)), drive);
|
|
454
|
+
await drive.files.create({
|
|
455
|
+
requestBody: { name: path.posix.basename(f), parents: [parentId] },
|
|
456
|
+
media: { mimeType: commitFiles[f]["mime-type"], body: createReadStream(f) }
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
historySpinner.succeed(`Archived commit ${commitId}`);
|
|
462
|
+
} catch (err) {
|
|
463
|
+
historySpinner.fail(`Failed to archive commit ${commitId}`);
|
|
464
|
+
throw err;
|
|
381
465
|
}
|
|
382
466
|
console.log(chalk.cyan(`ℹ History commit saved: ${commitId}`));
|
|
383
467
|
}
|
|
@@ -388,31 +472,54 @@ async function pull() {
|
|
|
388
472
|
const meta = readMetadata();
|
|
389
473
|
const files = meta["uploaded-files"];
|
|
390
474
|
const creds = readJson(CREDENTIALS_PATH);
|
|
475
|
+
const total = Object.keys(files).length;
|
|
391
476
|
|
|
392
|
-
if (
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
477
|
+
if (total === 0) return console.log(chalk.yellow("ℹ No remote files recorded. Nothing to pull."));
|
|
478
|
+
|
|
479
|
+
console.log(chalk.blue.bold(`\n⬇ Pulling ${chalk.cyan(total)} file(s)`));
|
|
480
|
+
let done = 0;
|
|
481
|
+
const spinner = makeSpinner(`Downloading... 0/${total}`).start();
|
|
482
|
+
const pulled = [];
|
|
483
|
+
|
|
484
|
+
try {
|
|
485
|
+
if (creds.storage === "aws") {
|
|
486
|
+
await runS3(creds, async (s3, bucket) => {
|
|
487
|
+
await Promise.all(Object.keys(files).map(async f => {
|
|
488
|
+
const key = path.posix.join(path.basename(process.cwd()), f);
|
|
489
|
+
const { Body } = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
|
490
|
+
const dir = path.dirname(f);
|
|
491
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
492
|
+
writeFileSync(f, await Body.transformToString());
|
|
493
|
+
pulled.push(f);
|
|
494
|
+
done += 1;
|
|
495
|
+
spinner.text = `Downloading... ${done}/${total}`;
|
|
496
|
+
}));
|
|
497
|
+
});
|
|
498
|
+
} else if (creds.storage === "google-drive") {
|
|
499
|
+
await runDrive(async (drive) => {
|
|
500
|
+
const fileList = Object.keys(files).map(k => ({ name: k, ...files[k] }));
|
|
501
|
+
await Promise.all(fileList.map(async f => {
|
|
502
|
+
const res = await drive.files.list({ q: `name='${path.posix.basename(f.name)}' and mimeType!='application/vnd.google-apps.folder'`, fields: 'files(id)' });
|
|
503
|
+
if (!res.data.files[0]) {
|
|
504
|
+
spinner.warn(`Missing remote file: ${f.name}`);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const data = await drive.files.get({ fileId: res.data.files[0].id, alt: 'media' });
|
|
508
|
+
if (!existsSync(path.dirname(f.name))) mkdirSync(path.dirname(f.name), { recursive: true });
|
|
509
|
+
writeFileSync(f.name, await data.data.text());
|
|
510
|
+
pulled.push(f.name);
|
|
511
|
+
done += 1;
|
|
512
|
+
spinner.text = `Downloading... ${done}/${total}`;
|
|
513
|
+
}));
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
spinner.succeed(`Downloaded ${done}/${total}`);
|
|
517
|
+
} catch (err) {
|
|
518
|
+
spinner.fail("Pull failed");
|
|
519
|
+
throw err;
|
|
415
520
|
}
|
|
521
|
+
|
|
522
|
+
pulled.forEach(f => console.log(chalk.green(`✔ Downloaded: ${f}`)));
|
|
416
523
|
}
|
|
417
524
|
|
|
418
525
|
function log() {
|
|
@@ -420,15 +527,41 @@ function log() {
|
|
|
420
527
|
const history = readHistory();
|
|
421
528
|
if (!history.length) return console.log(chalk.yellow("ℹ No history found. Push some files first."));
|
|
422
529
|
console.log(chalk.blue.bold("\n📜 Push History:\n"));
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
530
|
+
|
|
531
|
+
if (isTTY) {
|
|
532
|
+
const table = new Table({
|
|
533
|
+
head: [chalk.cyan("Commit"), chalk.cyan("When"), chalk.cyan("Files"), chalk.cyan("")].map(h => h),
|
|
534
|
+
style: { head: [], border: ["gray"] },
|
|
535
|
+
colWidths: [12, 26, 7, 12],
|
|
536
|
+
});
|
|
537
|
+
[...history].reverse().forEach((entry, i) => {
|
|
538
|
+
const date = new Date(entry.timestamp).toLocaleString();
|
|
539
|
+
const fileCount = Object.keys(entry.files).length;
|
|
540
|
+
table.push([
|
|
541
|
+
chalk.yellow(entry.id),
|
|
542
|
+
chalk.gray(date),
|
|
543
|
+
String(fileCount),
|
|
544
|
+
i === 0 ? chalk.green("(latest)") : "",
|
|
545
|
+
]);
|
|
546
|
+
});
|
|
547
|
+
console.log(table.toString());
|
|
430
548
|
console.log();
|
|
431
|
-
|
|
549
|
+
[...history].reverse().forEach((entry, i) => {
|
|
550
|
+
console.log(chalk.yellow(`commit ${entry.id}`) + (i === 0 ? chalk.green(" (latest)") : ""));
|
|
551
|
+
Object.keys(entry.files).forEach(f => console.log(chalk.gray(` • ${f}`)));
|
|
552
|
+
console.log();
|
|
553
|
+
});
|
|
554
|
+
} else {
|
|
555
|
+
[...history].reverse().forEach((entry, i) => {
|
|
556
|
+
const date = new Date(entry.timestamp).toLocaleString();
|
|
557
|
+
const fileCount = Object.keys(entry.files).length;
|
|
558
|
+
console.log(chalk.yellow(` commit ${entry.id}`) + (i === 0 ? chalk.green(" (latest)") : ""));
|
|
559
|
+
console.log(chalk.gray(` Date: ${date}`));
|
|
560
|
+
console.log(chalk.gray(` Files: ${fileCount}`));
|
|
561
|
+
Object.keys(entry.files).forEach(f => console.log(chalk.cyan(` • ${f}`)));
|
|
562
|
+
console.log();
|
|
563
|
+
});
|
|
564
|
+
}
|
|
432
565
|
}
|
|
433
566
|
|
|
434
567
|
async function checkout(commitId) {
|
|
@@ -444,35 +577,51 @@ async function checkout(commitId) {
|
|
|
444
577
|
const creds = readJson(CREDENTIALS_PATH);
|
|
445
578
|
const root = path.basename(process.cwd());
|
|
446
579
|
const historyPrefix = path.posix.join(root, ".dorky-history", entry.id);
|
|
580
|
+
const total = Object.keys(entry.files).length;
|
|
581
|
+
let done = 0;
|
|
582
|
+
const spinner = makeSpinner(`Restoring... 0/${total}`).start();
|
|
583
|
+
const restored = [];
|
|
447
584
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
await
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
585
|
+
try {
|
|
586
|
+
if (creds.storage === "aws") {
|
|
587
|
+
await runS3(creds, async (s3, bucket) => {
|
|
588
|
+
await Promise.all(Object.keys(entry.files).map(async f => {
|
|
589
|
+
const key = path.posix.join(historyPrefix, f);
|
|
590
|
+
const { Body } = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
|
591
|
+
if (!existsSync(path.dirname(f))) mkdirSync(path.dirname(f), { recursive: true });
|
|
592
|
+
writeFileSync(f, await Body.transformToString());
|
|
593
|
+
restored.push(f);
|
|
594
|
+
done += 1;
|
|
595
|
+
spinner.text = `Restoring... ${done}/${total}`;
|
|
596
|
+
}));
|
|
597
|
+
});
|
|
598
|
+
} else if (creds.storage === "google-drive") {
|
|
599
|
+
await runDrive(async (drive) => {
|
|
600
|
+
for (const f of Object.keys(entry.files)) {
|
|
601
|
+
const parentId = await getFolderId(path.posix.join(root, ".dorky-history", entry.id, path.posix.dirname(f)), drive, false);
|
|
602
|
+
if (!parentId) { spinner.warn(`Remote history folder missing for: ${f}`); continue; }
|
|
603
|
+
const res = await drive.files.list({
|
|
604
|
+
q: `name='${path.posix.basename(f)}' and '${parentId}' in parents and trashed=false`,
|
|
605
|
+
fields: 'files(id)'
|
|
606
|
+
});
|
|
607
|
+
if (!res.data.files[0]) { spinner.warn(`Missing remote history file: ${f}`); continue; }
|
|
608
|
+
const data = await drive.files.get({ fileId: res.data.files[0].id, alt: 'media' });
|
|
609
|
+
if (!existsSync(path.dirname(f))) mkdirSync(path.dirname(f), { recursive: true });
|
|
610
|
+
writeFileSync(f, await data.data.text());
|
|
611
|
+
restored.push(f);
|
|
612
|
+
done += 1;
|
|
613
|
+
spinner.text = `Restoring... ${done}/${total}`;
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
spinner.succeed(`Restored ${done}/${total}`);
|
|
618
|
+
} catch (err) {
|
|
619
|
+
spinner.fail("Checkout failed");
|
|
620
|
+
throw err;
|
|
474
621
|
}
|
|
475
622
|
|
|
623
|
+
restored.forEach(f => console.log(chalk.green(`✔ Restored: ${f}`)));
|
|
624
|
+
|
|
476
625
|
const meta = readMetadata();
|
|
477
626
|
meta["stage-1-files"] = { ...entry.files };
|
|
478
627
|
writeJson(METADATA_PATH, meta);
|
|
@@ -483,44 +632,163 @@ async function destroy() {
|
|
|
483
632
|
checkDorkyProject();
|
|
484
633
|
if (!await checkCredentials()) return;
|
|
485
634
|
|
|
635
|
+
if (isTTY) {
|
|
636
|
+
const root = path.basename(process.cwd());
|
|
637
|
+
const { confirmed } = await prompts({
|
|
638
|
+
type: "confirm",
|
|
639
|
+
name: "confirmed",
|
|
640
|
+
message: `This will delete remote files for "${root}" and remove .dorky locally. Proceed?`,
|
|
641
|
+
initial: false,
|
|
642
|
+
});
|
|
643
|
+
if (!confirmed) return console.log(chalk.yellow("ℹ Destroy cancelled."));
|
|
644
|
+
}
|
|
645
|
+
|
|
486
646
|
const creds = readJson(CREDENTIALS_PATH);
|
|
487
647
|
const root = path.basename(process.cwd());
|
|
648
|
+
const spinner = makeSpinner("Deleting remote project...").start();
|
|
488
649
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
650
|
+
try {
|
|
651
|
+
if (creds.storage === "aws") {
|
|
652
|
+
await runS3(creds, async (s3, bucket) => {
|
|
653
|
+
const data = await s3.send(new ListObjectsV2Command({ Bucket: bucket, Prefix: root + "/" }));
|
|
654
|
+
if (data.Contents && data.Contents.length > 0) {
|
|
655
|
+
const deleteParams = {
|
|
656
|
+
Bucket: bucket,
|
|
657
|
+
Delete: { Objects: data.Contents.map(o => ({ Key: o.Key })) }
|
|
658
|
+
};
|
|
659
|
+
await s3.send(new DeleteObjectsCommand(deleteParams));
|
|
660
|
+
spinner.text = "Remote files deleted";
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
} else if (creds.storage === "google-drive") {
|
|
664
|
+
await runDrive(async (drive) => {
|
|
665
|
+
const q = `name='${root}' and mimeType='application/vnd.google-apps.folder' and 'root' in parents and trashed=false`;
|
|
666
|
+
const { data: { files: [folder] } } = await drive.files.list({ q, fields: 'files(id)' });
|
|
667
|
+
if (folder) {
|
|
668
|
+
await drive.files.delete({ fileId: folder.id });
|
|
669
|
+
spinner.text = "Remote folder deleted";
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
spinner.succeed("Remote cleaned");
|
|
674
|
+
} catch (err) {
|
|
675
|
+
spinner.fail("Failed to clean remote");
|
|
676
|
+
throw err;
|
|
510
677
|
}
|
|
511
678
|
|
|
679
|
+
if (creds.storage === "aws") console.log(chalk.red("✖ Remote files deleted."));
|
|
680
|
+
else console.log(chalk.red("✖ Remote folder deleted."));
|
|
681
|
+
|
|
512
682
|
if (existsSync(DORKY_DIR)) rmSync(DORKY_DIR, { recursive: true, force: true });
|
|
513
683
|
if (existsSync(".dorkyignore")) unlinkSync(".dorkyignore");
|
|
514
684
|
|
|
515
685
|
console.log(chalk.red("✖ Project destroyed locally."));
|
|
516
686
|
}
|
|
517
687
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
688
|
+
async function interactiveMenu() {
|
|
689
|
+
const initialized = existsSync(CREDENTIALS_PATH) || existsSync(DORKY_DIR) || existsSync(".dorkyignore");
|
|
690
|
+
|
|
691
|
+
const baseChoices = initialized
|
|
692
|
+
? [
|
|
693
|
+
{ title: "📂 List untracked + staged files", value: "list" },
|
|
694
|
+
{ title: "☁ List remote files", value: "list-remote" },
|
|
695
|
+
{ title: "➕ Stage files (add)", value: "add" },
|
|
696
|
+
{ title: "➖ Unstage files (rm)", value: "rm" },
|
|
697
|
+
{ title: "🚀 Push staged files", value: "push" },
|
|
698
|
+
{ title: "⬇ Pull remote files", value: "pull" },
|
|
699
|
+
{ title: "📜 Show push history (log)", value: "log" },
|
|
700
|
+
{ title: "⏪ Checkout a commit", value: "checkout" },
|
|
701
|
+
{ title: "💥 Destroy project", value: "destroy" },
|
|
702
|
+
{ title: "Quit", value: "quit" },
|
|
703
|
+
]
|
|
704
|
+
: [
|
|
705
|
+
{ title: "🆕 Initialize with AWS S3", value: "init-aws" },
|
|
706
|
+
{ title: "🆕 Initialize with Google Drive", value: "init-gd" },
|
|
707
|
+
{ title: "Quit", value: "quit" },
|
|
708
|
+
];
|
|
709
|
+
|
|
710
|
+
console.log(divider());
|
|
711
|
+
console.log(chalk.bold(initialized ? " Dorky · interactive menu" : " Dorky · welcome (no project yet)"));
|
|
712
|
+
console.log(divider());
|
|
713
|
+
|
|
714
|
+
const { action } = await prompts({
|
|
715
|
+
type: "select",
|
|
716
|
+
name: "action",
|
|
717
|
+
message: "What would you like to do?",
|
|
718
|
+
choices: baseChoices,
|
|
719
|
+
hint: "Use arrow keys, Enter to select",
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
if (!action || action === "quit") return;
|
|
723
|
+
|
|
724
|
+
switch (action) {
|
|
725
|
+
case "init-aws": return init("aws");
|
|
726
|
+
case "init-gd": return init("google-drive");
|
|
727
|
+
case "list": return list();
|
|
728
|
+
case "list-remote": return list("remote");
|
|
729
|
+
case "log": return log();
|
|
730
|
+
case "push": return push();
|
|
731
|
+
case "pull": return pull();
|
|
732
|
+
case "destroy": return destroy();
|
|
733
|
+
case "add": {
|
|
734
|
+
const { files } = await prompts({
|
|
735
|
+
type: "text",
|
|
736
|
+
name: "files",
|
|
737
|
+
message: "Files to stage (space-separated globs):",
|
|
738
|
+
});
|
|
739
|
+
if (!files) return;
|
|
740
|
+
const expanded = (await Promise.all(files.split(/\s+/).filter(Boolean).map(g => glob(g, { dot: true })))).flat();
|
|
741
|
+
return add(expanded.length ? expanded : files.split(/\s+/).filter(Boolean));
|
|
742
|
+
}
|
|
743
|
+
case "rm": {
|
|
744
|
+
const meta = readMetadata();
|
|
745
|
+
const staged = Object.keys(meta["stage-1-files"]);
|
|
746
|
+
if (!staged.length) return console.log(chalk.yellow("ℹ Nothing staged to remove."));
|
|
747
|
+
const { picks } = await prompts({
|
|
748
|
+
type: "multiselect",
|
|
749
|
+
name: "picks",
|
|
750
|
+
message: "Select files to unstage",
|
|
751
|
+
choices: staged.map(s => ({ title: s, value: s })),
|
|
752
|
+
hint: "Space to toggle, Enter to confirm",
|
|
753
|
+
instructions: false,
|
|
754
|
+
});
|
|
755
|
+
if (!picks || !picks.length) return;
|
|
756
|
+
return rm(picks);
|
|
757
|
+
}
|
|
758
|
+
case "checkout": {
|
|
759
|
+
const history = readHistory();
|
|
760
|
+
if (!history.length) return console.log(chalk.yellow("ℹ No history yet."));
|
|
761
|
+
const { id } = await prompts({
|
|
762
|
+
type: "select",
|
|
763
|
+
name: "id",
|
|
764
|
+
message: "Pick a commit to restore",
|
|
765
|
+
choices: [...history].reverse().map((e, i) => ({
|
|
766
|
+
title: `${e.id} ${new Date(e.timestamp).toLocaleString()} (${Object.keys(e.files).length} files)${i === 0 ? " ← latest" : ""}`,
|
|
767
|
+
value: e.id,
|
|
768
|
+
})),
|
|
769
|
+
});
|
|
770
|
+
if (!id) return;
|
|
771
|
+
return checkout(id);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
async function main() {
|
|
777
|
+
if ((noArgs && isTTY) || args.interactive) return interactiveMenu();
|
|
778
|
+
if (noArgs) { yargs.showHelp(); return; }
|
|
779
|
+
|
|
780
|
+
if (args.init !== undefined) await init(args.init);
|
|
781
|
+
if (args.list !== undefined) await list(args.list);
|
|
782
|
+
if (args.add !== undefined) add(args.add);
|
|
783
|
+
if (args.rm !== undefined) rm(args.rm);
|
|
784
|
+
if (args.push !== undefined) await push();
|
|
785
|
+
if (args.pull !== undefined) await pull();
|
|
786
|
+
if (args.log !== undefined) log();
|
|
787
|
+
if (args.checkout !== undefined) await checkout(args.checkout);
|
|
788
|
+
if (args.destroy !== undefined) await destroy();
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
main().catch(err => {
|
|
792
|
+
console.log(chalk.red(`✖ ${err.message || err}`));
|
|
793
|
+
process.exit(1);
|
|
794
|
+
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dorky",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"description": "DevOps Records Keeper.",
|
|
5
|
+
"main": "bin/index.js",
|
|
5
6
|
"bin": {
|
|
6
7
|
"dorky": "bin/index.js",
|
|
7
8
|
"dorky-mcp": "bin/mcp.js"
|
|
@@ -42,12 +43,17 @@
|
|
|
42
43
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
43
44
|
"@aws-sdk/client-s3": "^3.679.0",
|
|
44
45
|
"@google-cloud/local-auth": "^3.0.1",
|
|
46
|
+
"boxen": "^5.1.2",
|
|
45
47
|
"chalk": "^4.1.2",
|
|
48
|
+
"cli-table3": "^0.6.5",
|
|
46
49
|
"glob": "^11.1.0",
|
|
47
50
|
"googleapis": "^144.0.0",
|
|
51
|
+
"gradient-string": "^2.0.2",
|
|
48
52
|
"md5": "^2.3.0",
|
|
49
53
|
"mime-type": "^4.0.0",
|
|
50
54
|
"mime-types": "^2.1.35",
|
|
55
|
+
"ora": "^5.4.1",
|
|
56
|
+
"prompts": "^2.4.2",
|
|
51
57
|
"yargs": "^17.7.2"
|
|
52
58
|
}
|
|
53
59
|
}
|