argus-media-cli 0.3.0 → 0.4.1
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 +39 -0
- package/dist/index.js +218 -1
- package/dist/sync.d.ts +76 -0
- package/dist/sync.js +189 -0
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -35,6 +35,45 @@ argus-media-cli upload photo.jpg
|
|
|
35
35
|
argus-media-cli upload hero.png --project campaign-q2 --tags hero,banner
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
+
### Sync
|
|
39
|
+
|
|
40
|
+
Onboard an entire folder of assets in one shot — ideal for migrating a legacy
|
|
41
|
+
brand folder. `sync` walks the folder recursively, filters out junk and
|
|
42
|
+
unsupported files, dedupes, uploads what's left sequentially, and prints a
|
|
43
|
+
manifest of what was synced, skipped, and failed.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
argus-media-cli sync ./legacy-brand-folder --dry-run # preview the plan
|
|
47
|
+
argus-media-cli sync ./legacy-brand-folder # upload for real
|
|
48
|
+
argus-media-cli sync ./assets --project brand-2019 --tags legacy,imported
|
|
49
|
+
argus-media-cli sync ./assets --json # structured manifest
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**What it uploads:** JPEG, PNG, GIF, WebP, and SVG files up to 20 MB.
|
|
53
|
+
|
|
54
|
+
**What it skips (with a stated reason):**
|
|
55
|
+
|
|
56
|
+
- Junk files (`.DS_Store`, `Thumbs.db`, `desktop.ini`) and hidden dotfiles
|
|
57
|
+
- Unsupported file types
|
|
58
|
+
- Files larger than the 20 MB server cap
|
|
59
|
+
- Duplicates — identical file contents (hashed), and any asset already in the
|
|
60
|
+
target project with the same filename + size
|
|
61
|
+
|
|
62
|
+
**Project:** defaults to the kebab-cased folder name (e.g. `Legacy Brand
|
|
63
|
+
Folder` → `legacy-brand-folder`). Override with `--project`.
|
|
64
|
+
|
|
65
|
+
**Credits:** each analyzable upload costs 1 credit. If the plan exceeds your
|
|
66
|
+
remaining credits, `sync` warns and asks for confirmation; pass `--yes` to skip
|
|
67
|
+
the prompt (uploads past the limit are stored without AI analysis).
|
|
68
|
+
|
|
69
|
+
| Flag | Description |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `--project NAME` | Target project (default: kebab-cased folder name) |
|
|
72
|
+
| `--tags a,b,c` | Tags applied to every synced asset |
|
|
73
|
+
| `--dry-run` | Print the plan without uploading |
|
|
74
|
+
| `--json` | Emit the manifest as structured JSON |
|
|
75
|
+
| `--yes` | Skip the credit-shortfall confirmation prompt |
|
|
76
|
+
|
|
38
77
|
### Search
|
|
39
78
|
|
|
40
79
|
```bash
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
3
3
|
import { basename, extname, join } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
5
6
|
import * as readline from "node:readline";
|
|
7
|
+
import { basicSkipReason, buildPlan, deriveProject, formatManifestText, toJsonManifest, walk, } from "./sync.js";
|
|
6
8
|
// ─── Config ────────────────────────────────────────────────────────────────
|
|
7
9
|
const CONFIG_DIR = join(homedir(), ".argus");
|
|
8
10
|
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
@@ -307,6 +309,211 @@ async function cmdUsage(_args, flags) {
|
|
|
307
309
|
console.log(` Storage: ${data.storage?.used != null ? formatBytes(data.storage.used) : "0 B"} / ${data.storage?.limit != null ? formatBytes(data.storage.limit) : "unlimited"}`);
|
|
308
310
|
}
|
|
309
311
|
}
|
|
312
|
+
/** Upload a single file. Returns the created asset, or a duplicate marker on 409. */
|
|
313
|
+
async function uploadFile(config, filePath, fileName, project, tags) {
|
|
314
|
+
const baseUrl = getBaseUrl(config);
|
|
315
|
+
const fileData = await readFile(filePath);
|
|
316
|
+
const ext = extname(fileName).toLowerCase();
|
|
317
|
+
const mimeType = MIME_TYPES[ext] || "application/octet-stream";
|
|
318
|
+
const blob = new Blob([fileData], { type: mimeType });
|
|
319
|
+
const form = new FormData();
|
|
320
|
+
form.append("file", blob, fileName);
|
|
321
|
+
if (project)
|
|
322
|
+
form.append("project", project);
|
|
323
|
+
if (tags)
|
|
324
|
+
form.append("tags", tags);
|
|
325
|
+
const res = await fetch(`${baseUrl}/assets/upload`, {
|
|
326
|
+
method: "POST",
|
|
327
|
+
headers: { ...getAuthHeaders(config) },
|
|
328
|
+
body: form,
|
|
329
|
+
});
|
|
330
|
+
// The server enforces workspace-wide exact-content dedup and returns 409 with
|
|
331
|
+
// the pre-existing asset. Surface that as a duplicate skip, not a failure.
|
|
332
|
+
if (res.status === 409) {
|
|
333
|
+
const body = (await res.json().catch(() => ({})));
|
|
334
|
+
const existing = body?.duplicate?.asset;
|
|
335
|
+
return {
|
|
336
|
+
duplicateOf: existing?.filename
|
|
337
|
+
? `${existing.filename} (${existing.id})`
|
|
338
|
+
: "an existing asset",
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
if (!res.ok) {
|
|
342
|
+
const text = await res.text();
|
|
343
|
+
throw new Error(`upload failed (${res.status}): ${text}`);
|
|
344
|
+
}
|
|
345
|
+
const data = (await res.json());
|
|
346
|
+
return { asset: data.asset };
|
|
347
|
+
}
|
|
348
|
+
/** Poll an asset until it reaches a terminal status (ready/error) or times out. */
|
|
349
|
+
async function pollUntilReady(config, id, timeoutMs = 60_000) {
|
|
350
|
+
const deadline = Date.now() + timeoutMs;
|
|
351
|
+
while (Date.now() < deadline) {
|
|
352
|
+
const data = (await apiRequest(config, "GET", `/assets/${id}`));
|
|
353
|
+
const status = data.asset?.status;
|
|
354
|
+
if (status === "ready" || status === "error")
|
|
355
|
+
return status;
|
|
356
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
357
|
+
}
|
|
358
|
+
return "pending"; // timed out — still queued, not a hard failure
|
|
359
|
+
}
|
|
360
|
+
/** Fetch every existing asset in a project (paginated) for filename+size dedup. */
|
|
361
|
+
async function fetchExistingAssets(config, project) {
|
|
362
|
+
const existing = [];
|
|
363
|
+
const pageSize = 200;
|
|
364
|
+
let offset = 0;
|
|
365
|
+
for (;;) {
|
|
366
|
+
const params = new URLSearchParams({
|
|
367
|
+
project,
|
|
368
|
+
limit: String(pageSize),
|
|
369
|
+
offset: String(offset),
|
|
370
|
+
});
|
|
371
|
+
const data = (await apiRequest(config, "GET", `/assets?${params}`));
|
|
372
|
+
const assets = data.assets ?? [];
|
|
373
|
+
for (const a of assets) {
|
|
374
|
+
if (typeof a.size === "number")
|
|
375
|
+
existing.push({ filename: a.filename, size: a.size });
|
|
376
|
+
}
|
|
377
|
+
if (assets.length < pageSize)
|
|
378
|
+
break;
|
|
379
|
+
offset += pageSize;
|
|
380
|
+
}
|
|
381
|
+
return existing;
|
|
382
|
+
}
|
|
383
|
+
async function confirm(question) {
|
|
384
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
385
|
+
const answer = await new Promise((resolve) => rl.question(question, resolve));
|
|
386
|
+
rl.close();
|
|
387
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
388
|
+
}
|
|
389
|
+
async function cmdSync(args, flags) {
|
|
390
|
+
const folder = args[0];
|
|
391
|
+
if (!folder) {
|
|
392
|
+
console.error("Usage: argus-media-cli sync <folder> [--project NAME] [--tags a,b,c] [--dry-run] [--json] [--yes]");
|
|
393
|
+
process.exit(1);
|
|
394
|
+
}
|
|
395
|
+
const config = await loadConfig();
|
|
396
|
+
const project = flags.project || deriveProject(folder);
|
|
397
|
+
const tags = flags.tags;
|
|
398
|
+
const dryRun = !!flags["dry-run"];
|
|
399
|
+
const asJson = !!flags.json;
|
|
400
|
+
const assumeYes = !!flags.yes;
|
|
401
|
+
// 1. Walk the folder.
|
|
402
|
+
let files;
|
|
403
|
+
try {
|
|
404
|
+
files = await walk(folder);
|
|
405
|
+
}
|
|
406
|
+
catch (err) {
|
|
407
|
+
throw new Error(`could not read folder '${folder}': ${err.message}`);
|
|
408
|
+
}
|
|
409
|
+
// 2. Hash the files that pass the cheap filters, so content-dedup can run.
|
|
410
|
+
const hashed = [];
|
|
411
|
+
for (const f of files) {
|
|
412
|
+
if (basicSkipReason(f)) {
|
|
413
|
+
hashed.push(f); // no hash needed — will be skipped in buildPlan
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
const buf = await readFile(f.path);
|
|
417
|
+
hashed.push({ ...f, hash: createHash("sha256").update(buf).digest("hex") });
|
|
418
|
+
}
|
|
419
|
+
// 3. Dedup against what's already in the target project.
|
|
420
|
+
const existing = await fetchExistingAssets(config, project);
|
|
421
|
+
const plan = buildPlan(hashed, existing);
|
|
422
|
+
const result = {
|
|
423
|
+
synced: [],
|
|
424
|
+
skipped: plan.skip.map((s) => ({ filename: s.filename, reason: s.reason })),
|
|
425
|
+
failed: [],
|
|
426
|
+
};
|
|
427
|
+
// 4. Dry run: show the plan and stop.
|
|
428
|
+
if (dryRun) {
|
|
429
|
+
result.synced = plan.include.map((f) => ({ id: "", filename: f.name }));
|
|
430
|
+
if (asJson) {
|
|
431
|
+
console.log(JSON.stringify(toJsonManifest(result, { project, dryRun: true }), null, 2));
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
console.log(formatManifestText(result, { project, dryRun: true }));
|
|
435
|
+
}
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (plan.include.length === 0) {
|
|
439
|
+
if (asJson) {
|
|
440
|
+
console.log(JSON.stringify(toJsonManifest(result, { project, dryRun: false }), null, 2));
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
console.log(formatManifestText(result, { project, dryRun: false }));
|
|
444
|
+
}
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
// 5. Credit check — each analyzable upload costs 1 credit. The server draws
|
|
448
|
+
// from monthly credits first, then one-time bonus credits, so the real
|
|
449
|
+
// balance is remaining + bonus.
|
|
450
|
+
try {
|
|
451
|
+
const usage = (await apiRequest(config, "GET", "/usage"));
|
|
452
|
+
const available = (usage.credits?.remaining ?? 0) + (usage.credits?.bonus ?? 0);
|
|
453
|
+
if (plan.include.length > available) {
|
|
454
|
+
const msg = `Plan uploads ${plan.include.length} file(s) but only ${available} credit(s) remain; uploads past the limit will store without AI analysis.`;
|
|
455
|
+
if (assumeYes) {
|
|
456
|
+
console.error(`Warning: ${msg}`);
|
|
457
|
+
}
|
|
458
|
+
else if (!asJson) {
|
|
459
|
+
const ok = await confirm(`${msg}\nContinue? [y/N] `);
|
|
460
|
+
if (!ok) {
|
|
461
|
+
console.log("Aborted.");
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
throw new Error(`${msg} Re-run with --yes to proceed.`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
catch (err) {
|
|
471
|
+
// Only abort if this was our own credit-guard error; usage lookups are best-effort.
|
|
472
|
+
if (/Re-run with --yes/.test(err.message))
|
|
473
|
+
throw err;
|
|
474
|
+
}
|
|
475
|
+
// 6. Upload sequentially, then poll each to a terminal state.
|
|
476
|
+
for (const f of plan.include) {
|
|
477
|
+
if (!asJson)
|
|
478
|
+
process.stderr.write(`Uploading ${f.name}… `);
|
|
479
|
+
try {
|
|
480
|
+
const outcome = await uploadFile(config, f.path, f.name, project, tags);
|
|
481
|
+
if (outcome.duplicateOf) {
|
|
482
|
+
result.skipped.push({
|
|
483
|
+
filename: f.name,
|
|
484
|
+
reason: `duplicate of ${outcome.duplicateOf}`,
|
|
485
|
+
});
|
|
486
|
+
if (!asJson)
|
|
487
|
+
process.stderr.write("duplicate\n");
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
const asset = outcome.asset;
|
|
491
|
+
const status = await pollUntilReady(config, asset.id);
|
|
492
|
+
if (status === "error") {
|
|
493
|
+
result.failed.push({ filename: f.name, error: `analysis failed (${asset.id})` });
|
|
494
|
+
if (!asJson)
|
|
495
|
+
process.stderr.write("error\n");
|
|
496
|
+
}
|
|
497
|
+
else {
|
|
498
|
+
result.synced.push({ id: asset.id, filename: f.name });
|
|
499
|
+
if (!asJson)
|
|
500
|
+
process.stderr.write(status === "ready" ? "ready\n" : "queued\n");
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
catch (err) {
|
|
504
|
+
result.failed.push({ filename: f.name, error: err.message });
|
|
505
|
+
if (!asJson)
|
|
506
|
+
process.stderr.write("failed\n");
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
// 7. Manifest.
|
|
510
|
+
if (asJson) {
|
|
511
|
+
console.log(JSON.stringify(toJsonManifest(result, { project, dryRun: false }), null, 2));
|
|
512
|
+
}
|
|
513
|
+
else {
|
|
514
|
+
console.log("\n" + formatManifestText(result, { project, dryRun: false }));
|
|
515
|
+
}
|
|
516
|
+
}
|
|
310
517
|
// ─── Phase 2: Generative Commands ─────────────────────────────────────────
|
|
311
518
|
async function cmdCreativeDirection(args, flags) {
|
|
312
519
|
const id = args[0];
|
|
@@ -570,6 +777,7 @@ function printHelp() {
|
|
|
570
777
|
\x1b[1mCommands:\x1b[0m
|
|
571
778
|
login Authenticate with Argus
|
|
572
779
|
upload <file> Upload a media file
|
|
780
|
+
sync <folder> Onboard a folder of assets in one shot
|
|
573
781
|
search <query> Search assets by description, tags, mood
|
|
574
782
|
list List all assets
|
|
575
783
|
get <id> Get asset details and analysis
|
|
@@ -590,6 +798,12 @@ function printHelp() {
|
|
|
590
798
|
--project NAME Assign to a project
|
|
591
799
|
--tags a,b,c Comma-separated tags
|
|
592
800
|
|
|
801
|
+
\x1b[1mSync Options:\x1b[0m
|
|
802
|
+
--project NAME Target project (default: kebab-cased folder name)
|
|
803
|
+
--tags a,b,c Tags applied to every synced asset
|
|
804
|
+
--dry-run Print the plan without uploading
|
|
805
|
+
--yes Skip the credit-shortfall confirmation prompt
|
|
806
|
+
|
|
593
807
|
\x1b[1mSearch/List Options:\x1b[0m
|
|
594
808
|
--project NAME Filter by project
|
|
595
809
|
--status STATUS Filter by status (pending, ready, error)
|
|
@@ -628,6 +842,8 @@ function printHelp() {
|
|
|
628
842
|
\x1b[1mExamples:\x1b[0m
|
|
629
843
|
argus-media-cli login
|
|
630
844
|
argus-media-cli upload photo.jpg --project campaign-q2 --tags hero,banner
|
|
845
|
+
argus-media-cli sync ./legacy-brand-folder --dry-run
|
|
846
|
+
argus-media-cli sync ./legacy-brand-folder --project brand-2019 --tags legacy
|
|
631
847
|
argus-media-cli search "sunset landscape" --json
|
|
632
848
|
argus-media-cli list --project website --limit 20
|
|
633
849
|
argus-media-cli get abc12345-def6-7890-abcd-ef1234567890
|
|
@@ -653,6 +869,7 @@ const COMMANDS = {
|
|
|
653
869
|
dashboard: cmdDashboard,
|
|
654
870
|
login: cmdLogin,
|
|
655
871
|
upload: cmdUpload,
|
|
872
|
+
sync: cmdSync,
|
|
656
873
|
search: cmdSearch,
|
|
657
874
|
list: cmdList,
|
|
658
875
|
get: cmdGet,
|
|
@@ -694,7 +911,7 @@ async function main() {
|
|
|
694
911
|
return;
|
|
695
912
|
}
|
|
696
913
|
if (command === "version" || command === "--version" || command === "-v") {
|
|
697
|
-
console.log("argus-media-cli 0.
|
|
914
|
+
console.log("argus-media-cli 0.4.0");
|
|
698
915
|
return;
|
|
699
916
|
}
|
|
700
917
|
const handler = COMMANDS[command];
|
package/dist/sync.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** Server-enforced upload cap. Files larger than this are skipped. */
|
|
2
|
+
export declare const MAX_SYNC_SIZE: number;
|
|
3
|
+
/**
|
|
4
|
+
* File types Argus can ingest and analyze. Mirrors the server's ANALYZABLE_TYPES
|
|
5
|
+
* (JPEG/PNG/GIF/WebP/SVG). Anything else is skipped with an "unsupported type" reason.
|
|
6
|
+
*/
|
|
7
|
+
export declare const SYNCABLE_EXTENSIONS: Record<string, string>;
|
|
8
|
+
export interface FileEntry {
|
|
9
|
+
path: string;
|
|
10
|
+
name: string;
|
|
11
|
+
size: number;
|
|
12
|
+
}
|
|
13
|
+
export interface HashedFile extends FileEntry {
|
|
14
|
+
/** sha256 of file contents; undefined for files that never reach the dedup stage. */
|
|
15
|
+
hash?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface ExistingAsset {
|
|
18
|
+
filename: string;
|
|
19
|
+
size: number;
|
|
20
|
+
}
|
|
21
|
+
export interface SyncPlan {
|
|
22
|
+
include: HashedFile[];
|
|
23
|
+
skip: {
|
|
24
|
+
filename: string;
|
|
25
|
+
path: string;
|
|
26
|
+
reason: string;
|
|
27
|
+
}[];
|
|
28
|
+
}
|
|
29
|
+
export interface SyncResult {
|
|
30
|
+
synced: {
|
|
31
|
+
id: string;
|
|
32
|
+
filename: string;
|
|
33
|
+
}[];
|
|
34
|
+
skipped: {
|
|
35
|
+
filename: string;
|
|
36
|
+
reason: string;
|
|
37
|
+
}[];
|
|
38
|
+
failed: {
|
|
39
|
+
filename: string;
|
|
40
|
+
error: string;
|
|
41
|
+
}[];
|
|
42
|
+
}
|
|
43
|
+
export declare function formatBytes(bytes: number): string;
|
|
44
|
+
export declare function kebabCase(input: string): string;
|
|
45
|
+
/** Default project name = kebab-cased basename of the folder. */
|
|
46
|
+
export declare function deriveProject(folder: string): string;
|
|
47
|
+
export declare function mimeForFile(name: string): string | null;
|
|
48
|
+
/**
|
|
49
|
+
* Cheap, hash-free skip checks: junk, hidden, unsupported type, oversize.
|
|
50
|
+
* Returns a human-readable reason, or null if the file passes and should be hashed.
|
|
51
|
+
*/
|
|
52
|
+
export declare function basicSkipReason(f: FileEntry): string | null;
|
|
53
|
+
/**
|
|
54
|
+
* Recursively collect files under `dir`. Hidden directories (`.git`, etc.) are
|
|
55
|
+
* skipped entirely; hidden *files* are still returned so they can be reported as
|
|
56
|
+
* skipped-with-reason. Output is sorted by path for deterministic manifests.
|
|
57
|
+
*/
|
|
58
|
+
export declare function walk(dir: string): Promise<FileEntry[]>;
|
|
59
|
+
/**
|
|
60
|
+
* Decide which files to upload vs skip.
|
|
61
|
+
*
|
|
62
|
+
* Skip reasons, in priority order:
|
|
63
|
+
* 1. junk / hidden / unsupported / oversize (see basicSkipReason)
|
|
64
|
+
* 2. an existing asset in the target project with the same filename + size
|
|
65
|
+
* 3. an earlier file in this run with identical contents (hash)
|
|
66
|
+
*
|
|
67
|
+
* `files` should carry a `hash` for every entry that passes basicSkipReason;
|
|
68
|
+
* entries without a hash simply never match the content-dedup check.
|
|
69
|
+
*/
|
|
70
|
+
export declare function buildPlan(files: HashedFile[], existing: ExistingAsset[]): SyncPlan;
|
|
71
|
+
export interface ManifestMeta {
|
|
72
|
+
project: string;
|
|
73
|
+
dryRun: boolean;
|
|
74
|
+
}
|
|
75
|
+
export declare function toJsonManifest(result: SyncResult, meta: ManifestMeta): object;
|
|
76
|
+
export declare function formatManifestText(result: SyncResult, meta: ManifestMeta): string;
|
package/dist/sync.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Pure, testable logic for the `sync <folder>` command.
|
|
2
|
+
// Kept separate from index.ts so the walk/filter/dedup/manifest behaviour can be
|
|
3
|
+
// unit-tested without spinning up the CLI or hitting the network.
|
|
4
|
+
import { readdir, stat } from "node:fs/promises";
|
|
5
|
+
import { basename, extname, join } from "node:path";
|
|
6
|
+
/** Server-enforced upload cap. Files larger than this are skipped. */
|
|
7
|
+
export const MAX_SYNC_SIZE = 20 * 1024 * 1024; // 20 MB
|
|
8
|
+
/**
|
|
9
|
+
* File types Argus can ingest and analyze. Mirrors the server's ANALYZABLE_TYPES
|
|
10
|
+
* (JPEG/PNG/GIF/WebP/SVG). Anything else is skipped with an "unsupported type" reason.
|
|
11
|
+
*/
|
|
12
|
+
export const SYNCABLE_EXTENSIONS = {
|
|
13
|
+
".jpg": "image/jpeg",
|
|
14
|
+
".jpeg": "image/jpeg",
|
|
15
|
+
".png": "image/png",
|
|
16
|
+
".gif": "image/gif",
|
|
17
|
+
".webp": "image/webp",
|
|
18
|
+
".svg": "image/svg+xml",
|
|
19
|
+
};
|
|
20
|
+
/** OS/editor cruft that should never be uploaded. */
|
|
21
|
+
const JUNK_FILENAMES = new Set([".DS_Store", "Thumbs.db", "desktop.ini"]);
|
|
22
|
+
// ─── Formatting ──────────────────────────────────────────────────────────────
|
|
23
|
+
export function formatBytes(bytes) {
|
|
24
|
+
if (bytes < 1024)
|
|
25
|
+
return `${bytes} B`;
|
|
26
|
+
if (bytes < 1024 * 1024)
|
|
27
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
28
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
29
|
+
}
|
|
30
|
+
// ─── Project derivation ────────────────────────────────────────────────────────
|
|
31
|
+
export function kebabCase(input) {
|
|
32
|
+
return input
|
|
33
|
+
.trim()
|
|
34
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2") // camelCase → camel-Case
|
|
35
|
+
.replace(/[\s_]+/g, "-")
|
|
36
|
+
.replace(/[^a-zA-Z0-9-]/g, "-")
|
|
37
|
+
.replace(/-+/g, "-")
|
|
38
|
+
.replace(/^-+|-+$/g, "")
|
|
39
|
+
.toLowerCase();
|
|
40
|
+
}
|
|
41
|
+
/** Default project name = kebab-cased basename of the folder. */
|
|
42
|
+
export function deriveProject(folder) {
|
|
43
|
+
const trimmed = folder.replace(/[/\\]+$/, "");
|
|
44
|
+
const base = basename(trimmed);
|
|
45
|
+
return kebabCase(base) || "imports";
|
|
46
|
+
}
|
|
47
|
+
// ─── Classification ────────────────────────────────────────────────────────────
|
|
48
|
+
export function mimeForFile(name) {
|
|
49
|
+
const ext = extname(name).toLowerCase();
|
|
50
|
+
return SYNCABLE_EXTENSIONS[ext] ?? null;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Cheap, hash-free skip checks: junk, hidden, unsupported type, oversize.
|
|
54
|
+
* Returns a human-readable reason, or null if the file passes and should be hashed.
|
|
55
|
+
*/
|
|
56
|
+
export function basicSkipReason(f) {
|
|
57
|
+
if (JUNK_FILENAMES.has(f.name))
|
|
58
|
+
return "junk file";
|
|
59
|
+
if (f.name.startsWith("."))
|
|
60
|
+
return "hidden file";
|
|
61
|
+
const mime = mimeForFile(f.name);
|
|
62
|
+
if (!mime) {
|
|
63
|
+
const ext = extname(f.name).toLowerCase();
|
|
64
|
+
return `unsupported type (${ext || "no extension"})`;
|
|
65
|
+
}
|
|
66
|
+
if (f.size > MAX_SYNC_SIZE) {
|
|
67
|
+
return `exceeds 20 MB limit (${formatBytes(f.size)})`;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
// ─── Walk ──────────────────────────────────────────────────────────────────────
|
|
72
|
+
/**
|
|
73
|
+
* Recursively collect files under `dir`. Hidden directories (`.git`, etc.) are
|
|
74
|
+
* skipped entirely; hidden *files* are still returned so they can be reported as
|
|
75
|
+
* skipped-with-reason. Output is sorted by path for deterministic manifests.
|
|
76
|
+
*/
|
|
77
|
+
export async function walk(dir) {
|
|
78
|
+
const out = [];
|
|
79
|
+
async function recurse(current) {
|
|
80
|
+
const entries = await readdir(current, { withFileTypes: true });
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
const full = join(current, entry.name);
|
|
83
|
+
if (entry.isDirectory()) {
|
|
84
|
+
if (entry.name.startsWith("."))
|
|
85
|
+
continue; // skip .git, .cache, etc.
|
|
86
|
+
await recurse(full);
|
|
87
|
+
}
|
|
88
|
+
else if (entry.isFile()) {
|
|
89
|
+
const s = await stat(full);
|
|
90
|
+
out.push({ path: full, name: entry.name, size: s.size });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
await recurse(dir);
|
|
95
|
+
out.sort((a, b) => a.path.localeCompare(b.path));
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
// ─── Plan ────────────────────────────────────────────────────────────────────
|
|
99
|
+
/**
|
|
100
|
+
* Decide which files to upload vs skip.
|
|
101
|
+
*
|
|
102
|
+
* Skip reasons, in priority order:
|
|
103
|
+
* 1. junk / hidden / unsupported / oversize (see basicSkipReason)
|
|
104
|
+
* 2. an existing asset in the target project with the same filename + size
|
|
105
|
+
* 3. an earlier file in this run with identical contents (hash)
|
|
106
|
+
*
|
|
107
|
+
* `files` should carry a `hash` for every entry that passes basicSkipReason;
|
|
108
|
+
* entries without a hash simply never match the content-dedup check.
|
|
109
|
+
*/
|
|
110
|
+
export function buildPlan(files, existing) {
|
|
111
|
+
const include = [];
|
|
112
|
+
const skip = [];
|
|
113
|
+
const existingKeys = new Set(existing.map((a) => `${a.filename}:${a.size}`));
|
|
114
|
+
const seenHashes = new Map(); // hash → first filename seen
|
|
115
|
+
for (const f of files) {
|
|
116
|
+
const basic = basicSkipReason(f);
|
|
117
|
+
if (basic) {
|
|
118
|
+
skip.push({ filename: f.name, path: f.path, reason: basic });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
// Content dedup within the run. The first file with a given hash claims it,
|
|
122
|
+
// even if that first file is then skipped for another reason (e.g. already
|
|
123
|
+
// in the project) — a later identical-content file is still a duplicate.
|
|
124
|
+
if (f.hash && seenHashes.has(f.hash)) {
|
|
125
|
+
skip.push({
|
|
126
|
+
filename: f.name,
|
|
127
|
+
path: f.path,
|
|
128
|
+
reason: `duplicate of ${seenHashes.get(f.hash)}`,
|
|
129
|
+
});
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (f.hash)
|
|
133
|
+
seenHashes.set(f.hash, f.name);
|
|
134
|
+
if (existingKeys.has(`${f.name}:${f.size}`)) {
|
|
135
|
+
skip.push({
|
|
136
|
+
filename: f.name,
|
|
137
|
+
path: f.path,
|
|
138
|
+
reason: "already in project (same name + size)",
|
|
139
|
+
});
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
include.push(f);
|
|
143
|
+
}
|
|
144
|
+
return { include, skip };
|
|
145
|
+
}
|
|
146
|
+
export function toJsonManifest(result, meta) {
|
|
147
|
+
return {
|
|
148
|
+
project: meta.project,
|
|
149
|
+
dryRun: meta.dryRun,
|
|
150
|
+
summary: {
|
|
151
|
+
synced: result.synced.length,
|
|
152
|
+
skipped: result.skipped.length,
|
|
153
|
+
failed: result.failed.length,
|
|
154
|
+
},
|
|
155
|
+
synced: result.synced,
|
|
156
|
+
skipped: result.skipped,
|
|
157
|
+
failed: result.failed,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
const BOLD = "\x1b[1m";
|
|
161
|
+
const DIM = "\x1b[2m";
|
|
162
|
+
const GREEN = "\x1b[32m";
|
|
163
|
+
const YELLOW = "\x1b[33m";
|
|
164
|
+
const RED = "\x1b[31m";
|
|
165
|
+
const RESET = "\x1b[0m";
|
|
166
|
+
export function formatManifestText(result, meta) {
|
|
167
|
+
const lines = [];
|
|
168
|
+
const header = meta.dryRun ? "Sync plan (dry run)" : "Sync complete";
|
|
169
|
+
lines.push(`${BOLD}${header}${RESET} — project ${BOLD}${meta.project}${RESET}\n`);
|
|
170
|
+
const syncedLabel = meta.dryRun ? "Would sync" : "Synced";
|
|
171
|
+
lines.push(`${GREEN}${syncedLabel}: ${result.synced.length}${RESET}`);
|
|
172
|
+
for (const s of result.synced) {
|
|
173
|
+
const id = s.id ? `${DIM}${s.id}${RESET} ` : "";
|
|
174
|
+
lines.push(` ${id}${s.filename}`);
|
|
175
|
+
}
|
|
176
|
+
if (result.skipped.length) {
|
|
177
|
+
lines.push(`\n${YELLOW}Skipped: ${result.skipped.length}${RESET}`);
|
|
178
|
+
for (const s of result.skipped) {
|
|
179
|
+
lines.push(` ${s.filename} ${DIM}— ${s.reason}${RESET}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (result.failed.length) {
|
|
183
|
+
lines.push(`\n${RED}Failed: ${result.failed.length}${RESET}`);
|
|
184
|
+
for (const f of result.failed) {
|
|
185
|
+
lines.push(` ${f.filename} ${DIM}— ${f.error}${RESET}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return lines.join("\n");
|
|
189
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "argus-media-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "CLI for Argus — AI-native media asset management. Upload, search, analyze, and manage media assets from the terminal.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"argus",
|
|
@@ -29,12 +29,14 @@
|
|
|
29
29
|
"README.md"
|
|
30
30
|
],
|
|
31
31
|
"scripts": {
|
|
32
|
-
"build": "tsc",
|
|
32
|
+
"build": "tsc -p tsconfig.build.json",
|
|
33
|
+
"test": "vitest run",
|
|
33
34
|
"prepublishOnly": "npm run build"
|
|
34
35
|
},
|
|
35
36
|
"devDependencies": {
|
|
36
37
|
"@types/node": "^20.0.0",
|
|
37
|
-
"typescript": "^5.4.0"
|
|
38
|
+
"typescript": "^5.4.0",
|
|
39
|
+
"vitest": "^4.1.2"
|
|
38
40
|
},
|
|
39
41
|
"engines": {
|
|
40
42
|
"node": ">=18"
|