argus-media-cli 0.1.2 → 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 +51 -0
- package/dist/index.js +588 -24
- 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
|
|
@@ -64,6 +103,18 @@ argus-media-cli login --key ak_xxx # Non-interactive
|
|
|
64
103
|
argus-media-cli login --url http://localhost:8787 # Custom API URL
|
|
65
104
|
```
|
|
66
105
|
|
|
106
|
+
### API Keys
|
|
107
|
+
|
|
108
|
+
Manage API keys for the current workspace. Works with either a session (from
|
|
109
|
+
`login`) or an existing API key. Use this to rotate a dead or expired key:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
argus-media-cli login # Magic-link session (no key needed)
|
|
113
|
+
argus-media-cli keys list # List active keys
|
|
114
|
+
argus-media-cli keys create --name "MCP" # Mint a new key (shown once)
|
|
115
|
+
argus-media-cli keys revoke <id> # Revoke a key (admin only)
|
|
116
|
+
```
|
|
117
|
+
|
|
67
118
|
## Options
|
|
68
119
|
|
|
69
120
|
All commands support `--json` for structured JSON output.
|
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");
|
|
@@ -23,22 +25,27 @@ async function saveConfig(config) {
|
|
|
23
25
|
function getBaseUrl(config) {
|
|
24
26
|
return process.env.ARGUS_BASE_URL || config.baseUrl || "https://argus.build";
|
|
25
27
|
}
|
|
26
|
-
function
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
function getAuthHeaders(config) {
|
|
29
|
+
// API key takes precedence (CI / backward compat)
|
|
30
|
+
const apiKey = process.env.ARGUS_API_KEY || config.apiKey;
|
|
31
|
+
if (apiKey) {
|
|
32
|
+
return { Authorization: `Bearer ${apiKey}` };
|
|
33
|
+
}
|
|
34
|
+
// Session-based auth (interactive login)
|
|
35
|
+
const sessionId = config.sessionId;
|
|
36
|
+
if (sessionId) {
|
|
37
|
+
return { Cookie: `session=${sessionId}` };
|
|
32
38
|
}
|
|
33
|
-
|
|
39
|
+
console.error("Error: Not authenticated.");
|
|
40
|
+
console.error("Run `argus-media-cli login` to sign in, or set ARGUS_API_KEY.");
|
|
41
|
+
process.exit(1);
|
|
34
42
|
}
|
|
35
43
|
async function apiRequest(config, method, path, body) {
|
|
36
44
|
const url = `${getBaseUrl(config)}${path}`;
|
|
37
|
-
const apiKey = getApiKey(config);
|
|
38
45
|
const opts = {
|
|
39
46
|
method,
|
|
40
47
|
headers: {
|
|
41
|
-
|
|
48
|
+
...getAuthHeaders(config),
|
|
42
49
|
...(body ? { "Content-Type": "application/json" } : {}),
|
|
43
50
|
},
|
|
44
51
|
...(body ? { body: JSON.stringify(body) } : {}),
|
|
@@ -92,6 +99,8 @@ function formatAssetDetail(a) {
|
|
|
92
99
|
lines.push(` Project: ${a.project}`);
|
|
93
100
|
if (a.url)
|
|
94
101
|
lines.push(` URL: ${a.url}`);
|
|
102
|
+
if (a.displayUrl)
|
|
103
|
+
lines.push(` Display: ${a.displayUrl}`);
|
|
95
104
|
if (a.tags?.length)
|
|
96
105
|
lines.push(` Tags: ${a.tags.join(", ")}`);
|
|
97
106
|
if (a.uploadedBy)
|
|
@@ -118,6 +127,7 @@ function formatAssetDetail(a) {
|
|
|
118
127
|
// ─── Commands ──────────────────────────────────────────────────────────────
|
|
119
128
|
async function cmdLogin(args, flags) {
|
|
120
129
|
const config = await loadConfig();
|
|
130
|
+
// Backward-compat: --key sets API key directly (useful for CI)
|
|
121
131
|
if (flags.key) {
|
|
122
132
|
config.apiKey = flags.key;
|
|
123
133
|
await saveConfig(config);
|
|
@@ -132,25 +142,62 @@ async function cmdLogin(args, flags) {
|
|
|
132
142
|
}
|
|
133
143
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
134
144
|
const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
135
|
-
|
|
136
|
-
console.log("
|
|
137
|
-
const
|
|
145
|
+
const baseUrl = getBaseUrl(config);
|
|
146
|
+
console.log("\x1b[1mArgus CLI Login\x1b[0m\n");
|
|
147
|
+
const email = (await ask("Email: ")).trim().toLowerCase();
|
|
148
|
+
if (!email) {
|
|
149
|
+
rl.close();
|
|
150
|
+
console.error("No email provided.");
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
// Request magic code
|
|
154
|
+
console.log("Sending login code...");
|
|
155
|
+
const magicRes = await fetch(`${baseUrl}/auth/magic`, {
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers: { "Content-Type": "application/json" },
|
|
158
|
+
body: JSON.stringify({ email }),
|
|
159
|
+
});
|
|
160
|
+
if (!magicRes.ok) {
|
|
161
|
+
rl.close();
|
|
162
|
+
const err = await magicRes.json().catch(() => ({ error: "Request failed" }));
|
|
163
|
+
console.error(`Error: ${err.error || "Could not send login code."}`);
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
console.log(`\nCheck your email for a 6-digit code.\n`);
|
|
167
|
+
const code = (await ask("Code: ")).trim();
|
|
138
168
|
rl.close();
|
|
139
|
-
if (!
|
|
140
|
-
console.error("No
|
|
169
|
+
if (!code) {
|
|
170
|
+
console.error("No code provided.");
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
// Verify code and get session
|
|
174
|
+
const verifyRes = await fetch(`${baseUrl}/auth/magic/verify`, {
|
|
175
|
+
method: "POST",
|
|
176
|
+
headers: { "Content-Type": "application/json" },
|
|
177
|
+
body: JSON.stringify({ email, code, source: "cli" }),
|
|
178
|
+
});
|
|
179
|
+
if (!verifyRes.ok) {
|
|
180
|
+
const err = await verifyRes.json().catch(() => ({ error: "Verification failed" }));
|
|
181
|
+
console.error(`Error: ${err.error || "Invalid or expired code."}`);
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
const data = await verifyRes.json();
|
|
185
|
+
if (!data.session) {
|
|
186
|
+
console.error("Error: No session returned from server.");
|
|
141
187
|
process.exit(1);
|
|
142
188
|
}
|
|
143
|
-
config.
|
|
189
|
+
config.sessionId = data.session;
|
|
190
|
+
delete config.apiKey; // prefer session auth
|
|
144
191
|
await saveConfig(config);
|
|
145
|
-
|
|
192
|
+
console.log("\nAuthenticated! Session saved to ~/.argus/config.json");
|
|
193
|
+
// Show usage summary
|
|
146
194
|
try {
|
|
147
195
|
const res = await apiRequest(config, "GET", "/usage");
|
|
148
|
-
console.log(
|
|
196
|
+
console.log(`Workspace tier: ${res.tier}`);
|
|
149
197
|
console.log(`Assets: ${res.assets?.used ?? 0}/${res.assets?.limit ?? "unlimited"}`);
|
|
150
198
|
}
|
|
151
|
-
catch
|
|
152
|
-
|
|
153
|
-
console.log("Key saved anyway. Check it with `argus-media-cli list`.");
|
|
199
|
+
catch {
|
|
200
|
+
// non-fatal
|
|
154
201
|
}
|
|
155
202
|
}
|
|
156
203
|
async function cmdUpload(args, flags) {
|
|
@@ -160,7 +207,6 @@ async function cmdUpload(args, flags) {
|
|
|
160
207
|
process.exit(1);
|
|
161
208
|
}
|
|
162
209
|
const config = await loadConfig();
|
|
163
|
-
const apiKey = getApiKey(config);
|
|
164
210
|
const baseUrl = getBaseUrl(config);
|
|
165
211
|
// Read file
|
|
166
212
|
const fileData = await readFile(filePath);
|
|
@@ -177,7 +223,7 @@ async function cmdUpload(args, flags) {
|
|
|
177
223
|
form.append("tags", flags.tags);
|
|
178
224
|
const res = await fetch(`${baseUrl}/assets/upload`, {
|
|
179
225
|
method: "POST",
|
|
180
|
-
headers: {
|
|
226
|
+
headers: { ...getAuthHeaders(config) },
|
|
181
227
|
body: form,
|
|
182
228
|
});
|
|
183
229
|
if (!res.ok) {
|
|
@@ -194,6 +240,8 @@ async function cmdUpload(args, flags) {
|
|
|
194
240
|
console.log(`Status: ${asset.status}`);
|
|
195
241
|
if (asset.url)
|
|
196
242
|
console.log(`URL: ${asset.url}`);
|
|
243
|
+
if (asset.displayUrl)
|
|
244
|
+
console.log(`Display: ${asset.displayUrl}`);
|
|
197
245
|
}
|
|
198
246
|
}
|
|
199
247
|
async function cmdSearch(args, flags) {
|
|
@@ -261,6 +309,439 @@ async function cmdUsage(_args, flags) {
|
|
|
261
309
|
console.log(` Storage: ${data.storage?.used != null ? formatBytes(data.storage.used) : "0 B"} / ${data.storage?.limit != null ? formatBytes(data.storage.limit) : "unlimited"}`);
|
|
262
310
|
}
|
|
263
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
|
+
}
|
|
517
|
+
// ─── Phase 2: Generative Commands ─────────────────────────────────────────
|
|
518
|
+
async function cmdCreativeDirection(args, flags) {
|
|
519
|
+
const id = args[0];
|
|
520
|
+
if (!id) {
|
|
521
|
+
console.error("Usage: argus-media-cli creative-direction <asset-id> [--purpose TEXT] [--audience TEXT] [--tone TEXT] [--style TEXT]");
|
|
522
|
+
process.exit(1);
|
|
523
|
+
}
|
|
524
|
+
const config = await loadConfig();
|
|
525
|
+
const body = {};
|
|
526
|
+
if (flags.purpose)
|
|
527
|
+
body.purpose = flags.purpose;
|
|
528
|
+
if (flags.audience)
|
|
529
|
+
body.audience = flags.audience;
|
|
530
|
+
if (flags.tone)
|
|
531
|
+
body.tone = flags.tone;
|
|
532
|
+
if (flags.style)
|
|
533
|
+
body.style = flags.style;
|
|
534
|
+
const data = (await apiRequest(config, "POST", `/assets/${id}/creative-direction`, body));
|
|
535
|
+
if (flags.json) {
|
|
536
|
+
console.log(JSON.stringify(data, null, 2));
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
const cd = data.creativeDirection;
|
|
540
|
+
console.log(`\x1b[1mCreative Direction\x1b[0m${data.cached ? " (cached)" : ""}\n`);
|
|
541
|
+
console.log(cd.rationale);
|
|
542
|
+
if (cd.googleFontsUrl)
|
|
543
|
+
console.log(`\nGoogle Fonts: ${cd.googleFontsUrl}`);
|
|
544
|
+
if (cd.typography?.length) {
|
|
545
|
+
console.log(`\n\x1b[1mTypography:\x1b[0m`);
|
|
546
|
+
for (const t of cd.typography) {
|
|
547
|
+
console.log(` ${t.role}: ${t.css.fontFamily} (${t.css.fontWeight}) — ${t.semantic}`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (cd.colors?.length) {
|
|
551
|
+
console.log(`\n\x1b[1mColors:\x1b[0m`);
|
|
552
|
+
for (const c of cd.colors) {
|
|
553
|
+
console.log(` ${c.role}: ${c.css.color} — ${c.semantic}`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (cd.layout?.length) {
|
|
557
|
+
console.log(`\n\x1b[1mLayout:\x1b[0m`);
|
|
558
|
+
for (const l of cd.layout) {
|
|
559
|
+
console.log(` ${l.pattern} — ${l.semantic}`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
async function cmdTypography(args, flags) {
|
|
564
|
+
const id = args[0];
|
|
565
|
+
if (!id) {
|
|
566
|
+
console.error("Usage: argus-media-cli typography <asset-id> [--purpose TEXT]");
|
|
567
|
+
process.exit(1);
|
|
568
|
+
}
|
|
569
|
+
const config = await loadConfig();
|
|
570
|
+
const body = {};
|
|
571
|
+
if (flags.purpose)
|
|
572
|
+
body.purpose = flags.purpose;
|
|
573
|
+
const data = (await apiRequest(config, "POST", `/assets/${id}/typography`, body));
|
|
574
|
+
if (flags.json) {
|
|
575
|
+
console.log(JSON.stringify(data, null, 2));
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
const typo = data.typography;
|
|
579
|
+
console.log(`\x1b[1mTypography Suggestions\x1b[0m${data.cached ? " (cached)" : ""}\n`);
|
|
580
|
+
if (typo.pairings?.length) {
|
|
581
|
+
for (const t of typo.pairings) {
|
|
582
|
+
console.log(` ${t.role}: ${t.css.fontFamily} (${t.css.fontWeight}) — ${t.semantic}`);
|
|
583
|
+
if (t.googleFontsUrl)
|
|
584
|
+
console.log(` Font: ${t.googleFontsUrl}`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
if (typo.googleFontsUrl)
|
|
588
|
+
console.log(`\nGoogle Fonts: ${typo.googleFontsUrl}`);
|
|
589
|
+
if (typo.rationale)
|
|
590
|
+
console.log(`\n${typo.rationale}`);
|
|
591
|
+
}
|
|
592
|
+
async function cmdPalette(args, flags) {
|
|
593
|
+
const id = args[0];
|
|
594
|
+
if (!id) {
|
|
595
|
+
console.error("Usage: argus-media-cli palette <asset-id>");
|
|
596
|
+
process.exit(1);
|
|
597
|
+
}
|
|
598
|
+
const config = await loadConfig();
|
|
599
|
+
const data = (await apiRequest(config, "POST", `/assets/${id}/palette`));
|
|
600
|
+
if (flags.json) {
|
|
601
|
+
console.log(JSON.stringify(data, null, 2));
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
const palette = data.palette;
|
|
605
|
+
console.log(`\x1b[1mColor Palette\x1b[0m${data.cached ? " (cached)" : ""}\n`);
|
|
606
|
+
if (palette.colors?.length) {
|
|
607
|
+
for (const c of palette.colors) {
|
|
608
|
+
console.log(` ${c.role}: ${c.css.color} — ${c.semantic}`);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
if (palette.rationale)
|
|
612
|
+
console.log(`\n${palette.rationale}`);
|
|
613
|
+
}
|
|
614
|
+
async function cmdCohesion(args, flags) {
|
|
615
|
+
if (args.length < 2) {
|
|
616
|
+
console.error("Usage: argus-media-cli cohesion <asset-id> <asset-id> [<asset-id>...] [--purpose TEXT] [--brand TEXT]");
|
|
617
|
+
process.exit(1);
|
|
618
|
+
}
|
|
619
|
+
const config = await loadConfig();
|
|
620
|
+
const body = { assetIds: args };
|
|
621
|
+
if (flags.purpose || flags.brand) {
|
|
622
|
+
const context = {};
|
|
623
|
+
if (flags.purpose)
|
|
624
|
+
context.purpose = flags.purpose;
|
|
625
|
+
if (flags.brand)
|
|
626
|
+
context.brandName = flags.brand;
|
|
627
|
+
body.context = context;
|
|
628
|
+
}
|
|
629
|
+
const data = (await apiRequest(config, "POST", "/assets/compare-cohesion", body));
|
|
630
|
+
if (flags.json) {
|
|
631
|
+
console.log(JSON.stringify(data, null, 2));
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
const c = data.cohesion;
|
|
635
|
+
console.log(`\x1b[1mCohesion Report\x1b[0m${data.cached ? " (cached)" : ""}\n`);
|
|
636
|
+
console.log(` Overall: ${c.overallScore}/100`);
|
|
637
|
+
console.log(` Typography: ${c.typography.score}/100 — ${c.typography.consistency}`);
|
|
638
|
+
console.log(` Color: ${c.color.score}/100 — ${c.color.harmony}`);
|
|
639
|
+
console.log(` Mood: ${c.mood.score}/100 — ${c.mood.alignment}`);
|
|
640
|
+
if (c.suggestions?.length) {
|
|
641
|
+
console.log(`\n\x1b[1mSuggestions:\x1b[0m`);
|
|
642
|
+
for (const s of c.suggestions) {
|
|
643
|
+
const sev = s.severity === "high" ? "\x1b[31m" : s.severity === "medium" ? "\x1b[33m" : "\x1b[32m";
|
|
644
|
+
console.log(` ${sev}${s.severity}\x1b[0m ${s.asset.slice(0, 8)}: ${s.suggestion}`);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
if (c.rationale)
|
|
648
|
+
console.log(`\n${c.rationale}`);
|
|
649
|
+
}
|
|
650
|
+
// ─── API Key Management ───────────────────────────────────────────────────
|
|
651
|
+
async function cmdKeys(args, flags) {
|
|
652
|
+
const sub = args[0];
|
|
653
|
+
const rest = args.slice(1);
|
|
654
|
+
if (!sub || sub === "list" || sub === "ls") {
|
|
655
|
+
return cmdKeysList(rest, flags);
|
|
656
|
+
}
|
|
657
|
+
if (sub === "create" || sub === "new") {
|
|
658
|
+
return cmdKeysCreate(rest, flags);
|
|
659
|
+
}
|
|
660
|
+
if (sub === "revoke" || sub === "delete" || sub === "rm") {
|
|
661
|
+
return cmdKeysRevoke(rest, flags);
|
|
662
|
+
}
|
|
663
|
+
console.error(`Unknown keys subcommand: ${sub}`);
|
|
664
|
+
console.error("Usage: argus-media-cli keys <list|create|revoke>");
|
|
665
|
+
process.exit(1);
|
|
666
|
+
}
|
|
667
|
+
async function cmdKeysList(_args, flags) {
|
|
668
|
+
const config = await loadConfig();
|
|
669
|
+
const data = (await apiRequest(config, "GET", "/keys"));
|
|
670
|
+
if (flags.json) {
|
|
671
|
+
console.log(JSON.stringify(data, null, 2));
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
const keys = data.keys || [];
|
|
675
|
+
if (!keys.length) {
|
|
676
|
+
console.log("No API keys found.");
|
|
677
|
+
console.log("Create one with: argus-media-cli keys create --name 'My Key'");
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
console.log(`\x1b[1m${keys.length} API key${keys.length === 1 ? "" : "s"}\x1b[0m\n`);
|
|
681
|
+
for (const k of keys) {
|
|
682
|
+
const status = k.revoked_at ? "\x1b[31mrevoked\x1b[0m" : "\x1b[32mactive\x1b[0m ";
|
|
683
|
+
const lastUsed = k.last_used_at ? `used ${k.last_used_at}` : "never used";
|
|
684
|
+
console.log(` ${status} ${k.key_prefix}… ${k.name}`);
|
|
685
|
+
console.log(` id: ${k.id}`);
|
|
686
|
+
console.log(` created ${k.created_at}, ${lastUsed}`);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
async function cmdKeysCreate(args, flags) {
|
|
690
|
+
const name = flags.name || args[0] || "API Key";
|
|
691
|
+
const config = await loadConfig();
|
|
692
|
+
const data = (await apiRequest(config, "POST", "/keys", { name }));
|
|
693
|
+
if (flags.json) {
|
|
694
|
+
console.log(JSON.stringify(data, null, 2));
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
console.log(`\x1b[1mAPI key created\x1b[0m\n`);
|
|
698
|
+
console.log(` Name: ${data.name}`);
|
|
699
|
+
console.log(` ID: ${data.id}`);
|
|
700
|
+
console.log(` Prefix: ${data.key_prefix}`);
|
|
701
|
+
console.log(`\n\x1b[1m\x1b[33mKey (shown once — store it now):\x1b[0m`);
|
|
702
|
+
console.log(` ${data.key}\n`);
|
|
703
|
+
console.log(`Use it by setting:`);
|
|
704
|
+
console.log(` export ARGUS_API_KEY=${data.key}`);
|
|
705
|
+
}
|
|
706
|
+
async function cmdKeysRevoke(args, flags) {
|
|
707
|
+
const id = args[0];
|
|
708
|
+
if (!id) {
|
|
709
|
+
console.error("Usage: argus-media-cli keys revoke <id>");
|
|
710
|
+
process.exit(1);
|
|
711
|
+
}
|
|
712
|
+
const config = await loadConfig();
|
|
713
|
+
const data = (await apiRequest(config, "DELETE", `/keys/${id}`));
|
|
714
|
+
if (flags.json) {
|
|
715
|
+
console.log(JSON.stringify(data, null, 2));
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
console.log(`Key ${id} revoked.`);
|
|
719
|
+
}
|
|
720
|
+
async function cmdStyleGuide(args, flags) {
|
|
721
|
+
if (args.length < 1) {
|
|
722
|
+
console.error("Usage: argus-media-cli style-guide <asset-id> [<asset-id>...] [--name TEXT] [--guide-id ID]");
|
|
723
|
+
process.exit(1);
|
|
724
|
+
}
|
|
725
|
+
const config = await loadConfig();
|
|
726
|
+
const body = { assetIds: args };
|
|
727
|
+
if (flags.name)
|
|
728
|
+
body.name = flags.name;
|
|
729
|
+
if (flags["guide-id"])
|
|
730
|
+
body.guideId = flags["guide-id"];
|
|
731
|
+
const data = (await apiRequest(config, "POST", "/style-guides", body));
|
|
732
|
+
if (flags.json) {
|
|
733
|
+
console.log(JSON.stringify(data, null, 2));
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
const sg = data.styleGuide;
|
|
737
|
+
console.log(`\x1b[1mStyle Guide: ${sg.name || sg.id}\x1b[0m\n`);
|
|
738
|
+
console.log(` ID: ${sg.id}`);
|
|
739
|
+
console.log(` Assets: ${sg.sourceAssetIds?.length ?? 0}`);
|
|
740
|
+
if (data.googleFontsUrl)
|
|
741
|
+
console.log(` Fonts: ${data.googleFontsUrl}`);
|
|
742
|
+
if (data.rationale)
|
|
743
|
+
console.log(`\n${data.rationale}`);
|
|
744
|
+
}
|
|
264
745
|
function parseArgs(argv) {
|
|
265
746
|
const command = argv[0] || "help";
|
|
266
747
|
const flags = {};
|
|
@@ -296,10 +777,19 @@ function printHelp() {
|
|
|
296
777
|
\x1b[1mCommands:\x1b[0m
|
|
297
778
|
login Authenticate with Argus
|
|
298
779
|
upload <file> Upload a media file
|
|
780
|
+
sync <folder> Onboard a folder of assets in one shot
|
|
299
781
|
search <query> Search assets by description, tags, mood
|
|
300
782
|
list List all assets
|
|
301
783
|
get <id> Get asset details and analysis
|
|
302
784
|
usage Show workspace usage and limits
|
|
785
|
+
creative-direction <id> Generate creative brief for an asset
|
|
786
|
+
typography <id> Suggest typography pairings for an asset
|
|
787
|
+
palette <id> Generate extended color palette from an asset
|
|
788
|
+
cohesion <id> <id> ... Compare visual cohesion across assets
|
|
789
|
+
style-guide <id> ... Generate a style guide from assets
|
|
790
|
+
keys list List API keys in the current workspace
|
|
791
|
+
keys create [--name] Create a new API key (shown once)
|
|
792
|
+
keys revoke <id> Revoke an API key (admin only)
|
|
303
793
|
|
|
304
794
|
\x1b[1mGlobal Options:\x1b[0m
|
|
305
795
|
--json Output raw JSON
|
|
@@ -308,6 +798,12 @@ function printHelp() {
|
|
|
308
798
|
--project NAME Assign to a project
|
|
309
799
|
--tags a,b,c Comma-separated tags
|
|
310
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
|
+
|
|
311
807
|
\x1b[1mSearch/List Options:\x1b[0m
|
|
312
808
|
--project NAME Filter by project
|
|
313
809
|
--status STATUS Filter by status (pending, ready, error)
|
|
@@ -315,10 +811,30 @@ function printHelp() {
|
|
|
315
811
|
--limit N Max results (default 50)
|
|
316
812
|
--offset N Pagination offset
|
|
317
813
|
|
|
814
|
+
\x1b[1mCreative Direction Options:\x1b[0m
|
|
815
|
+
--purpose TEXT Intended use (e.g. 'hero banner')
|
|
816
|
+
--audience TEXT Target audience
|
|
817
|
+
--tone TEXT Desired tone (e.g. 'professional')
|
|
818
|
+
--style TEXT Visual style (e.g. 'minimalist')
|
|
819
|
+
|
|
820
|
+
\x1b[1mTypography Options:\x1b[0m
|
|
821
|
+
--purpose TEXT Intended use context
|
|
822
|
+
|
|
823
|
+
\x1b[1mCohesion Options:\x1b[0m
|
|
824
|
+
--purpose TEXT Comparison context
|
|
825
|
+
--brand TEXT Brand name for context
|
|
826
|
+
|
|
827
|
+
\x1b[1mStyle Guide Options:\x1b[0m
|
|
828
|
+
--name TEXT Name for the style guide
|
|
829
|
+
--guide-id ID Existing guide ID to update
|
|
830
|
+
|
|
318
831
|
\x1b[1mLogin Options:\x1b[0m
|
|
319
832
|
--key KEY Set API key non-interactively
|
|
320
833
|
--url URL Set custom API base URL
|
|
321
834
|
|
|
835
|
+
\x1b[1mKeys Options:\x1b[0m
|
|
836
|
+
--name TEXT Name for the new key (keys create)
|
|
837
|
+
|
|
322
838
|
\x1b[1mEnvironment:\x1b[0m
|
|
323
839
|
ARGUS_API_KEY API key (overrides saved config)
|
|
324
840
|
ARGUS_BASE_URL API base URL (default: https://argus.build)
|
|
@@ -326,28 +842,76 @@ function printHelp() {
|
|
|
326
842
|
\x1b[1mExamples:\x1b[0m
|
|
327
843
|
argus-media-cli login
|
|
328
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
|
|
329
847
|
argus-media-cli search "sunset landscape" --json
|
|
330
848
|
argus-media-cli list --project website --limit 20
|
|
331
849
|
argus-media-cli get abc12345-def6-7890-abcd-ef1234567890
|
|
332
850
|
`);
|
|
333
851
|
}
|
|
852
|
+
// ─── Onboarding ───────────────────────────────────────────────────────────
|
|
853
|
+
function isAuthenticated(config) {
|
|
854
|
+
return !!(process.env.ARGUS_API_KEY || config.apiKey || config.sessionId);
|
|
855
|
+
}
|
|
856
|
+
async function cmdDashboard(_args, flags) {
|
|
857
|
+
const config = await loadConfig();
|
|
858
|
+
const res = await apiRequest(config, "GET", "/usage");
|
|
859
|
+
console.log(`\x1b[1mArgus Dashboard\x1b[0m\n`);
|
|
860
|
+
console.log(` Tier: ${res.tier}`);
|
|
861
|
+
console.log(` Assets: ${res.assets?.used ?? 0} / ${res.assets?.limit ?? "unlimited"}`);
|
|
862
|
+
if (res.storage != null) {
|
|
863
|
+
console.log(` Storage: ${res.storage?.used != null ? formatBytes(res.storage.used) : "0 B"} / ${res.storage?.limit != null ? formatBytes(res.storage.limit) : "unlimited"}`);
|
|
864
|
+
}
|
|
865
|
+
console.log(`\nRun \x1b[1margus-media-cli help\x1b[0m for available commands.`);
|
|
866
|
+
}
|
|
334
867
|
// ─── Main ──────────────────────────────────────────────────────────────────
|
|
335
868
|
const COMMANDS = {
|
|
869
|
+
dashboard: cmdDashboard,
|
|
336
870
|
login: cmdLogin,
|
|
337
871
|
upload: cmdUpload,
|
|
872
|
+
sync: cmdSync,
|
|
338
873
|
search: cmdSearch,
|
|
339
874
|
list: cmdList,
|
|
340
875
|
get: cmdGet,
|
|
341
876
|
usage: cmdUsage,
|
|
877
|
+
"creative-direction": cmdCreativeDirection,
|
|
878
|
+
typography: cmdTypography,
|
|
879
|
+
palette: cmdPalette,
|
|
880
|
+
cohesion: cmdCohesion,
|
|
881
|
+
"style-guide": cmdStyleGuide,
|
|
882
|
+
keys: cmdKeys,
|
|
342
883
|
};
|
|
343
884
|
async function main() {
|
|
344
|
-
const
|
|
885
|
+
const rawArgs = process.argv.slice(2);
|
|
886
|
+
// No args: onboarding — show dashboard or prompt login
|
|
887
|
+
if (rawArgs.length === 0) {
|
|
888
|
+
const config = await loadConfig();
|
|
889
|
+
if (isAuthenticated(config)) {
|
|
890
|
+
try {
|
|
891
|
+
await cmdDashboard([], {});
|
|
892
|
+
}
|
|
893
|
+
catch {
|
|
894
|
+
console.log("\x1b[1mArgus Media CLI\x1b[0m\n");
|
|
895
|
+
console.log("Run \x1b[1margus-media-cli help\x1b[0m for available commands.");
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
else {
|
|
899
|
+
console.log("\x1b[1mWelcome to Argus!\x1b[0m\n");
|
|
900
|
+
console.log("Get started by signing in:\n");
|
|
901
|
+
console.log(" argus-media-cli login\n");
|
|
902
|
+
console.log("Or set an API key for CI use:\n");
|
|
903
|
+
console.log(" argus-media-cli login --key <your-key>\n");
|
|
904
|
+
console.log("Run \x1b[1margus-media-cli help\x1b[0m for all commands.");
|
|
905
|
+
}
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
const { command, args, flags } = parseArgs(rawArgs);
|
|
345
909
|
if (command === "help" || command === "--help" || command === "-h") {
|
|
346
910
|
printHelp();
|
|
347
911
|
return;
|
|
348
912
|
}
|
|
349
913
|
if (command === "version" || command === "--version" || command === "-v") {
|
|
350
|
-
console.log("argus-media-cli 0.
|
|
914
|
+
console.log("argus-media-cli 0.4.0");
|
|
351
915
|
return;
|
|
352
916
|
}
|
|
353
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.1
|
|
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"
|