dorky 4.1.7 → 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.
Files changed (2) hide show
  1. package/bin/index.js +444 -176
  2. package/package.json +6 -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
- let randomColor = `#${Math.floor(Math.random() * 16777215).toString(16)}`;
59
- while (randomColor[2] === "f" || randomColor[3] === "f") randomColor = `#${Math.floor(Math.random() * 16777215).toString(16)}`;
60
- console.log(chalk.bgHex(randomColor)(figlet));
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
- if (Object.keys(args).length === 2 && args._.length === 0) yargs.showHelp();
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 client = await authorizeGoogleDriveClient(true);
129
- credentials = { storage: "google-drive", ...client.credentials };
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
- console.log(chalk.blue.bold("\n☁ Remote Files:"));
149
-
150
- if (creds.storage === "aws") {
151
- await runS3(creds, async (s3, bucket) => {
152
- const data = await s3.send(new ListObjectsV2Command({ Bucket: bucket, Prefix: root + "/" }));
153
- if (!data.Contents?.length) return console.log(chalk.yellow("ℹ No remote files found."));
154
- data.Contents.forEach(o => console.log(chalk.cyan(` ${o.Key.replace(root + "/", "")}`)));
155
- });
156
- } else {
157
- await runDrive(async (drive) => {
158
- const q = `name='${root}' and mimeType='application/vnd.google-apps.folder' and 'root' in parents and trashed=false`;
159
- const { data: { files: [folder] } } = await drive.files.list({ q, fields: 'files(id)' });
160
- if (!folder) return console.log(chalk.yellow("ℹ Remote folder not found."));
161
- const walk = async (pid, p = '') => {
162
- const { data: { files } } = await drive.files.list({ q: `'${pid}' in parents and trashed=false`, fields: 'files(id, name, mimeType)' });
163
- for (const f of files) {
164
- if (f.mimeType === 'application/vnd.google-apps.folder') await walk(f.id, path.join(p, f.name));
165
- else console.log(chalk.cyan(` ${path.join(p, f.name)}`));
166
- }
167
- };
168
- await walk(folder.id);
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"]).forEach(f => console.log(chalk.green(` ✔ ${f}`)));
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
- if (creds.storage === "aws") {
308
- await runS3(creds, async (s3, bucket) => {
309
- if (filesToUpload.length > 0) {
310
- await Promise.all(filesToUpload.map(async f => {
311
- const key = path.posix.join(path.basename(process.cwd()), f.name);
312
- await s3.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: readFileSync(f.name) }));
313
- console.log(chalk.green(`✔ Uploaded: ${f.name}`));
314
- }));
315
- }
316
- if (filesToDelete.length > 0) {
317
- await Promise.all(filesToDelete.map(async f => {
318
- const key = path.posix.join(path.basename(process.cwd()), f);
319
- await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
320
- console.log(chalk.yellow(`✔ Deleted remote: ${f}`));
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
- if (filesToDelete.length > 0) {
338
- const root = path.basename(process.cwd());
339
- for (const f of filesToDelete) {
340
- const parentId = await getFolderId(path.posix.dirname(path.posix.join(root, f)), drive, false);
341
- if (parentId) {
342
- const res = await drive.files.list({
343
- q: `name='${path.posix.basename(f)}' and '${parentId}' in parents and trashed=false`,
344
- fields: 'files(id)'
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
- if (res.data.files[0]) {
347
- await drive.files.delete({ fileId: res.data.files[0].id });
348
- console.log(chalk.yellow(`✔ Deleted remote: ${f}`));
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
- if (creds.storage === "aws") {
365
- await runS3(creds, async (s3, bucket) => {
366
- await Promise.all(Object.keys(commitFiles).map(async f => {
367
- const key = path.posix.join(historyPrefix, f);
368
- await s3.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: readFileSync(f) }));
369
- }));
370
- });
371
- } else if (creds.storage === "google-drive") {
372
- await runDrive(async (drive) => {
373
- for (const f of Object.keys(commitFiles)) {
374
- const parentId = await getFolderId(path.posix.join(root, ".dorky-history", commitId, path.posix.dirname(f)), drive);
375
- await drive.files.create({
376
- requestBody: { name: path.posix.basename(f), parents: [parentId] },
377
- media: { mimeType: commitFiles[f]["mime-type"], body: createReadStream(f) }
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 (creds.storage === "aws") {
393
- await runS3(creds, async (s3, bucket) => {
394
- await Promise.all(Object.keys(files).map(async f => {
395
- const key = path.posix.join(path.basename(process.cwd()), f);
396
- const { Body } = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
397
- const dir = path.dirname(f);
398
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
399
- writeFileSync(f, await Body.transformToString());
400
- console.log(chalk.green(`✔ Downloaded: ${f}`));
401
- }));
402
- });
403
- } else if (creds.storage === "google-drive") {
404
- await runDrive(async (drive) => {
405
- const fileList = Object.keys(files).map(k => ({ name: k, ...files[k] }));
406
- await Promise.all(fileList.map(async f => {
407
- const res = await drive.files.list({ q: `name='${path.posix.basename(f.name)}' and mimeType!='application/vnd.google-apps.folder'`, fields: 'files(id)' });
408
- if (!res.data.files[0]) return console.log(chalk.red(`✖ Missing remote file: ${f.name}`));
409
- const data = await drive.files.get({ fileId: res.data.files[0].id, alt: 'media' });
410
- if (!existsSync(path.dirname(f.name))) mkdirSync(path.dirname(f.name), { recursive: true });
411
- writeFileSync(f.name, await data.data.text());
412
- console.log(chalk.green(`✔ Downloaded: ${f.name}`));
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
- [...history].reverse().forEach((entry, i) => {
424
- const date = new Date(entry.timestamp).toLocaleString();
425
- const fileCount = Object.keys(entry.files).length;
426
- console.log(chalk.yellow(` commit ${entry.id}`) + (i === 0 ? chalk.green(" (latest)") : ""));
427
- console.log(chalk.gray(` Date: ${date}`));
428
- console.log(chalk.gray(` Files: ${fileCount}`));
429
- Object.keys(entry.files).forEach(f => console.log(chalk.cyan(` • ${f}`)));
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
- if (creds.storage === "aws") {
449
- await runS3(creds, async (s3, bucket) => {
450
- await Promise.all(Object.keys(entry.files).map(async f => {
451
- const key = path.posix.join(historyPrefix, f);
452
- const { Body } = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
453
- if (!existsSync(path.dirname(f))) mkdirSync(path.dirname(f), { recursive: true });
454
- writeFileSync(f, await Body.transformToString());
455
- console.log(chalk.green(`✔ Restored: ${f}`));
456
- }));
457
- });
458
- } else if (creds.storage === "google-drive") {
459
- await runDrive(async (drive) => {
460
- for (const f of Object.keys(entry.files)) {
461
- const parentId = await getFolderId(path.posix.join(root, ".dorky-history", entry.id, path.posix.dirname(f)), drive, false);
462
- if (!parentId) { console.log(chalk.red(`✖ Remote history folder missing for: ${f}`)); continue; }
463
- const res = await drive.files.list({
464
- q: `name='${path.posix.basename(f)}' and '${parentId}' in parents and trashed=false`,
465
- fields: 'files(id)'
466
- });
467
- if (!res.data.files[0]) { console.log(chalk.red(`✖ Missing remote history file: ${f}`)); continue; }
468
- const data = await drive.files.get({ fileId: res.data.files[0].id, alt: 'media' });
469
- if (!existsSync(path.dirname(f))) mkdirSync(path.dirname(f), { recursive: true });
470
- writeFileSync(f, await data.data.text());
471
- console.log(chalk.green(`✔ Restored: ${f}`));
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
- if (creds.storage === "aws") {
490
- await runS3(creds, async (s3, bucket) => {
491
- const data = await s3.send(new ListObjectsV2Command({ Bucket: bucket, Prefix: root + "/" }));
492
- if (data.Contents && data.Contents.length > 0) {
493
- const deleteParams = {
494
- Bucket: bucket,
495
- Delete: { Objects: data.Contents.map(o => ({ Key: o.Key })) }
496
- };
497
- await s3.send(new DeleteObjectsCommand(deleteParams));
498
- console.log(chalk.red("✖ Remote files deleted."));
499
- }
500
- });
501
- } else if (creds.storage === "google-drive") {
502
- await runDrive(async (drive) => {
503
- const q = `name='${root}' and mimeType='application/vnd.google-apps.folder' and 'root' in parents and trashed=false`;
504
- const { data: { files: [folder] } } = await drive.files.list({ q, fields: 'files(id)' });
505
- if (folder) {
506
- await drive.files.delete({ fileId: folder.id });
507
- console.log(chalk.red("✖ Remote folder deleted."));
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
- if (args.init !== undefined) init(args.init);
519
- if (args.list !== undefined) list(args.list);
520
- if (args.add !== undefined) add(args.add);
521
- if (args.rm !== undefined) rm(args.rm);
522
- if (args.push !== undefined) push();
523
- if (args.pull !== undefined) pull();
524
- if (args.log !== undefined) log();
525
- if (args.checkout !== undefined) checkout(args.checkout);
526
- if (args.destroy !== undefined) destroy();
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,6 +1,6 @@
1
1
  {
2
2
  "name": "dorky",
3
- "version": "4.1.7",
3
+ "version": "4.2.0",
4
4
  "description": "DevOps Records Keeper.",
5
5
  "main": "bin/index.js",
6
6
  "bin": {
@@ -43,12 +43,17 @@
43
43
  "@modelcontextprotocol/sdk": "^1.29.0",
44
44
  "@aws-sdk/client-s3": "^3.679.0",
45
45
  "@google-cloud/local-auth": "^3.0.1",
46
+ "boxen": "^5.1.2",
46
47
  "chalk": "^4.1.2",
48
+ "cli-table3": "^0.6.5",
47
49
  "glob": "^11.1.0",
48
50
  "googleapis": "^144.0.0",
51
+ "gradient-string": "^2.0.2",
49
52
  "md5": "^2.3.0",
50
53
  "mime-type": "^4.0.0",
51
54
  "mime-types": "^2.1.35",
55
+ "ora": "^5.4.1",
56
+ "prompts": "^2.4.2",
52
57
  "yargs": "^17.7.2"
53
58
  }
54
59
  }