argonuts 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 the Argonuts Core contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,52 @@
1
- # Temporary Holding Version
1
+ # argonuts
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Your media library from the terminal. `argonuts` keeps a list of your media folders, scans them for
4
+ videos and pictures, and keeps their thumbnails ready. It is the terminal companion of Argonuts Core.
5
+
6
+ ```sh
7
+ npx argonuts@latest library add ~/Movies
8
+ npx argonuts@latest scan
9
+ ```
10
+
11
+ Or install it once and type `argonuts`:
12
+
13
+ ```sh
14
+ npm install --global argonuts
15
+ argonuts scan
16
+ ```
17
+
18
+ ## Commands
19
+
20
+ | Command | What it does |
21
+ | --- | --- |
22
+ | `argonuts library` | Your library's folders and their last scans |
23
+ | `argonuts library add <folder>...` | Add folders to your library |
24
+ | `argonuts library remove <folder>` | Remove a folder; its last scan stays on disk |
25
+ | `argonuts scan [<folder>...]` | Scan every library folder, or the folders named, which join your library |
26
+ | `argonuts scan --cached-only` | Use earlier thumbnails only and run no ffmpeg |
27
+ | `argonuts --version` | The versions of argonuts and argonuts-cli |
28
+
29
+ Ctrl-C stops a scan; a second Ctrl-C stops it at once. A scan reads your files and never changes
30
+ them.
31
+
32
+ ## Where your library is kept
33
+
34
+ In the core's data folder, shared with the Argonuts desktop app:
35
+
36
+ - macOS: `~/Library/Application Support/Argonuts`
37
+ - Windows: `%LOCALAPPDATA%\Argonuts`
38
+
39
+ Set `ARGONUTS_DATA_DIR` to keep it somewhere else.
40
+
41
+ ## Computers it runs on
42
+
43
+ macOS on Apple Silicon and Intel, and Windows on x64, with Node.js 20 or later. Linux and Windows on
44
+ ARM come next.
45
+
46
+ The work is done by `argonuts-cli`, the core's own program, which npm installs with argonuts for your
47
+ computer only, together with the `ffmpeg` and `ffprobe` it uses for thumbnails.
48
+
49
+ ## Licenses
50
+
51
+ `argonuts` and `argonuts-cli` are MIT licensed. `ffmpeg` and `ffprobe` are FFmpeg, under the GNU
52
+ LGPL 2.1 or later; the platform package that carries them includes its notice and license.
@@ -0,0 +1,364 @@
1
+ #!/usr/bin/env node
2
+ // src/core.ts
3
+ import { spawn } from "node:child_process";
4
+ import { accessSync, chmodSync, constants, existsSync } from "node:fs";
5
+ import { createRequire } from "node:module";
6
+ import path from "node:path";
7
+ var PLATFORMS = {
8
+ "darwin-arm64": "macOS on Apple Silicon",
9
+ "darwin-x64": "macOS on Intel",
10
+ "win32-x64": "Windows on x64"
11
+ };
12
+
13
+ class CompanionError extends Error {
14
+ exitCode;
15
+ constructor(message, exitCode = 1) {
16
+ super(message);
17
+ this.exitCode = exitCode;
18
+ }
19
+ }
20
+ function platformKey() {
21
+ return `${process.platform}-${process.arch}`;
22
+ }
23
+ function corePath() {
24
+ const given = process.env.ARGONUTS_CLI;
25
+ if (given) {
26
+ if (!existsSync(given))
27
+ throw new CompanionError(`ARGONUTS_CLI is set to ${given}, which does not exist.`);
28
+ return given;
29
+ }
30
+ const key = platformKey();
31
+ if (!(key in PLATFORMS)) {
32
+ throw new CompanionError(`argonuts has no build for ${key} yet. It runs on ${Object.values(PLATFORMS).join(", ")}.`);
33
+ }
34
+ const name = `@argonuts/cli-${key}`;
35
+ let manifest;
36
+ try {
37
+ manifest = createRequire(import.meta.url).resolve(`${name}/package.json`);
38
+ } catch {
39
+ throw new CompanionError(`${name} is not installed. Reinstall argonuts: npm installs it for this computer.`);
40
+ }
41
+ const binary = path.join(path.dirname(manifest), "bin", key.startsWith("win32") ? "argonuts-cli.exe" : "argonuts-cli");
42
+ if (!existsSync(binary))
43
+ throw new CompanionError(`${binary} is missing. Reinstall argonuts.`);
44
+ makeRunnable(binary);
45
+ return binary;
46
+ }
47
+ function makeRunnable(binary) {
48
+ if (process.platform === "win32")
49
+ return;
50
+ try {
51
+ accessSync(binary, constants.X_OK);
52
+ } catch {
53
+ try {
54
+ chmodSync(binary, 493);
55
+ } catch {}
56
+ }
57
+ }
58
+ function startCore(args) {
59
+ return spawn(corePath(), args, { windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
60
+ }
61
+ function captureCore(args) {
62
+ return new Promise((resolve, reject) => {
63
+ const child = spawn(corePath(), args, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
64
+ let stdout = "";
65
+ let stderr = "";
66
+ child.stdout.setEncoding("utf8").on("data", (chunk) => stdout += chunk);
67
+ child.stderr.setEncoding("utf8").on("data", (chunk) => stderr += chunk);
68
+ child.on("error", reject);
69
+ child.on("close", (code) => resolve({ code, stdout, stderr }));
70
+ });
71
+ }
72
+ async function coreJson(args) {
73
+ const { code, stdout, stderr } = await captureCore(args);
74
+ if (code !== 0)
75
+ throw new CompanionError(refusal(stderr) || `argonuts-cli stopped with exit code ${code}.`, code ?? 1);
76
+ try {
77
+ return JSON.parse(stdout);
78
+ } catch {
79
+ throw new CompanionError(`argonuts-cli answered with something that is not JSON: ${stdout.slice(0, 200)}`);
80
+ }
81
+ }
82
+ function refusal(stderr) {
83
+ return stderr.trim().replace(/^Error:\s*/, "");
84
+ }
85
+
86
+ // src/library.ts
87
+ import path3 from "node:path";
88
+
89
+ // src/show.ts
90
+ import os from "node:os";
91
+ import path2 from "node:path";
92
+ function count(value) {
93
+ return Math.trunc(value).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
94
+ }
95
+ function bytes(value) {
96
+ const units = ["B", "KB", "MB", "GB", "TB", "PB"];
97
+ let scaled = value;
98
+ let unit = 0;
99
+ while (scaled >= 1000 && unit < units.length - 1) {
100
+ scaled /= 1000;
101
+ unit += 1;
102
+ }
103
+ return unit === 0 ? `${value} B` : `${scaled.toFixed(1)} ${units[unit]}`;
104
+ }
105
+ function plural(value, word) {
106
+ return value === 1 ? `1 ${word}` : `${count(value)} ${word}s`;
107
+ }
108
+ function when(rfc3339) {
109
+ const time = new Date(rfc3339);
110
+ if (Number.isNaN(time.getTime()))
111
+ return rfc3339;
112
+ return `${time.toISOString().slice(0, 16).replace("T", " ")} UTC`;
113
+ }
114
+ function shown(folder) {
115
+ const home = os.homedir();
116
+ if (!home)
117
+ return folder;
118
+ if (folder === home)
119
+ return "~";
120
+ if (folder.startsWith(home + path2.sep))
121
+ return `~${folder.slice(home.length)}`;
122
+ return folder;
123
+ }
124
+ function fit(line, columns = process.stderr.columns) {
125
+ if (!columns || line.length < columns)
126
+ return line;
127
+ return `${line.slice(0, Math.max(0, columns - 2))}…`;
128
+ }
129
+
130
+ // src/library.ts
131
+ async function libraryCommand(args) {
132
+ const [action = "list", ...rest] = args;
133
+ let library;
134
+ switch (action) {
135
+ case "list":
136
+ if (rest.length > 0)
137
+ throw new CompanionError("`argonuts library list` takes no folders.", 2);
138
+ library = await coreJson(["library", "list"]);
139
+ break;
140
+ case "add":
141
+ if (rest.length === 0)
142
+ throw new CompanionError("Name a folder: argonuts library add <folder>", 2);
143
+ library = await coreJson(["library", "add", ...rest.map((folder) => path3.resolve(folder))]);
144
+ break;
145
+ case "remove":
146
+ if (rest.length !== 1)
147
+ throw new CompanionError("Name one folder: argonuts library remove <folder>", 2);
148
+ library = await coreJson(["library", "remove", path3.resolve(rest[0])]);
149
+ break;
150
+ default:
151
+ throw new CompanionError(`There is no \`argonuts library ${action}\`. Use list, add or remove.`, 2);
152
+ }
153
+ printLibrary(library);
154
+ return 0;
155
+ }
156
+ function printLibrary({ folders }) {
157
+ if (folders.length === 0) {
158
+ console.log("Your library has no folders yet. Add one: argonuts library add <folder>");
159
+ return;
160
+ }
161
+ const scans = folders.flatMap((entry) => entry.last_scan ? [entry.last_scan] : []);
162
+ const files = scans.reduce((sum, scan) => sum + scan.files, 0);
163
+ const size = scans.reduce((sum, scan) => sum + scan.total_bytes, 0);
164
+ const width = Math.max(...folders.map((entry) => shown(entry.folder).length));
165
+ console.log(`Your library: ${plural(folders.length, "folder")} · ${count(files)} files · ${bytes(size)}`);
166
+ for (const entry of folders) {
167
+ const scan = entry.last_scan;
168
+ const state = scan ? `${plural(scan.files, "file")} · ${bytes(scan.total_bytes)} · scanned ${when(scan.scanned_at)}` : "not scanned yet";
169
+ const connected = entry.present ? "" : " · ⚠ not connected";
170
+ console.log(` ${shown(entry.folder).padEnd(width)} ${state}${connected}`);
171
+ }
172
+ }
173
+
174
+ // src/scan.ts
175
+ import { statSync } from "node:fs";
176
+ import path4 from "node:path";
177
+ import { createInterface } from "node:readline";
178
+ var EXIT_CANCELLED = 130;
179
+ async function scanCommand(args) {
180
+ const named = [];
181
+ let cachedOnly = false;
182
+ for (const arg of args) {
183
+ if (arg === "--cached-only")
184
+ cachedOnly = true;
185
+ else if (arg.startsWith("-"))
186
+ throw new CompanionError(`\`argonuts scan\` has no option ${arg}.`, 2);
187
+ else
188
+ named.push(path4.resolve(arg));
189
+ }
190
+ const library = await coreJson(named.length > 0 ? ["library", "add", ...named] : ["library", "list"]);
191
+ const targets = named.length > 0 ? named.map((folder) => ({ folder, present: isFolder(folder) })) : library.folders;
192
+ if (targets.length === 0) {
193
+ console.log("Your library has no folders yet. Add one: argonuts library add <folder>");
194
+ return 1;
195
+ }
196
+ let failed = false;
197
+ for (const { folder, present } of targets) {
198
+ if (!present) {
199
+ console.log(`⚠ ${shown(folder)} is not connected; skipped. Its last scan stays.`);
200
+ continue;
201
+ }
202
+ const snapshot = await scanFolder(folder, cachedOnly);
203
+ printSummary(snapshot);
204
+ if (snapshot.phase === "cancelled") {
205
+ console.log("Stopped.");
206
+ return EXIT_CANCELLED;
207
+ }
208
+ failed ||= snapshot.phase === "failed";
209
+ }
210
+ console.log();
211
+ printLibrary(await coreJson(["library", "list"]));
212
+ return failed ? 1 : 0;
213
+ }
214
+ function isFolder(folder) {
215
+ try {
216
+ return statSync(folder).isDirectory();
217
+ } catch {
218
+ return false;
219
+ }
220
+ }
221
+ function scanFolder(folder, cachedOnly) {
222
+ const label = shown(folder);
223
+ const redraw = process.stderr.isTTY === true;
224
+ const draw = (text) => redraw && process.stderr.write(`\r\x1B[2K${fit(`${label} · ${text}`)}`);
225
+ return new Promise((resolve, reject) => {
226
+ const child = startCore([
227
+ "--stop-on-stdin-close",
228
+ "scan",
229
+ folder,
230
+ "--json",
231
+ ...cachedOnly ? ["--cached-only"] : []
232
+ ]);
233
+ let last = null;
234
+ let finished = false;
235
+ let interrupts = 0;
236
+ let stderr = "";
237
+ const onInterrupt = () => {
238
+ interrupts += 1;
239
+ if (interrupts === 1) {
240
+ child.stdin.write(`cancel
241
+ `);
242
+ draw("stopping…");
243
+ } else {
244
+ child.kill();
245
+ }
246
+ };
247
+ process.on("SIGINT", onInterrupt);
248
+ child.stdin.on("error", () => {});
249
+ child.stderr.setEncoding("utf8").on("data", (chunk) => stderr += chunk);
250
+ createInterface({ input: child.stdout }).on("line", (raw) => {
251
+ let line;
252
+ try {
253
+ line = JSON.parse(raw);
254
+ } catch {
255
+ return;
256
+ }
257
+ last = line.snapshot;
258
+ if (line.event === "done")
259
+ finished = true;
260
+ else
261
+ draw(progressLine(line.snapshot));
262
+ });
263
+ child.on("error", (error) => {
264
+ process.off("SIGINT", onInterrupt);
265
+ reject(error);
266
+ });
267
+ child.on("close", (code) => {
268
+ process.off("SIGINT", onInterrupt);
269
+ child.stdin.end();
270
+ if (redraw)
271
+ process.stderr.write("\r\x1B[2K");
272
+ if (!last) {
273
+ reject(new CompanionError(refusal(stderr) || `The scan of ${label} stopped with exit code ${code}.`, code ?? 1));
274
+ return;
275
+ }
276
+ if (!finished)
277
+ last.phase = interrupts > 0 ? "cancelled" : "failed";
278
+ resolve(last);
279
+ });
280
+ });
281
+ }
282
+ function progressLine(snapshot) {
283
+ if (snapshot.phase === "discovering")
284
+ return `Looking for media… ${count(snapshot.discovered_files)} files`;
285
+ return `Scanning ${count(snapshot.processed_files)}/${count(snapshot.discovered_files)} files · ` + `${count(snapshot.new_files)} new · ${count(snapshot.cached_files)} from cache · ` + `${count(snapshot.failed_files)} failed`;
286
+ }
287
+ function printSummary(snapshot) {
288
+ const verdict = { finished: "Scanned", cancelled: "Stopped", failed: "Failed" }[snapshot.phase] ?? "Failed";
289
+ console.log(`${verdict} ${shown(snapshot.root)}`);
290
+ console.log(` ${count(snapshot.discovered_files)} files (${count(snapshot.video_files)} videos, ` + `${count(snapshot.image_files)} pictures), ${count(snapshot.new_files)} new, ` + `${count(snapshot.cached_files)} from cache, ${count(snapshot.failed_files)} failed`);
291
+ console.log(` ${count(snapshot.thumbnails_generated)} thumbnails made`);
292
+ if (snapshot.unreadable_dirs > 0)
293
+ console.log(` ${count(snapshot.unreadable_dirs)} folders could not be read`);
294
+ for (const failure of snapshot.failures)
295
+ console.error(` ✗ ${failure.relative_path}: ${failure.error}`);
296
+ if (snapshot.error)
297
+ console.error(`✗ ${snapshot.error}`);
298
+ }
299
+
300
+ // src/main.ts
301
+ var VERSION = "0.1.0";
302
+ var HELP = `argonuts ${VERSION}: your media library, from the terminal.
303
+
304
+ Usage:
305
+ argonuts library Your library's folders and their last scans
306
+ argonuts library add <folder>... Add folders to your library
307
+ argonuts library remove <folder> Remove a folder; its last scan stays on disk
308
+ argonuts scan [<folder>...] Scan every library folder, or the folders named,
309
+ which join your library
310
+ --cached-only Use earlier thumbnails only and run no ffmpeg
311
+ argonuts --version The versions of argonuts and argonuts-cli
312
+
313
+ Ctrl-C stops a scan; a second Ctrl-C stops it at once.
314
+ Your library is kept in the core's data folder, shared with the desktop app.
315
+ Set ARGONUTS_DATA_DIR to keep it somewhere else.
316
+ `;
317
+ async function main(argv) {
318
+ const [command, ...rest] = argv;
319
+ if (rest.includes("--help") || rest.includes("-h")) {
320
+ process.stdout.write(HELP);
321
+ return 0;
322
+ }
323
+ switch (command) {
324
+ case undefined:
325
+ case "help":
326
+ case "--help":
327
+ case "-h":
328
+ process.stdout.write(HELP);
329
+ return 0;
330
+ case "version":
331
+ case "--version":
332
+ case "-v":
333
+ return printVersion();
334
+ case "library":
335
+ return libraryCommand(rest);
336
+ case "scan":
337
+ return scanCommand(rest);
338
+ default:
339
+ process.stderr.write(`✗ There is no \`argonuts ${command}\`.
340
+
341
+ ${HELP}`);
342
+ return 2;
343
+ }
344
+ }
345
+ async function printVersion() {
346
+ console.log(`argonuts ${VERSION}`);
347
+ try {
348
+ const { code, stdout, stderr } = await captureCore(["--version"]);
349
+ if (code !== 0)
350
+ throw new CompanionError(refusal(stderr) || `argonuts-cli stopped with exit code ${code}.`);
351
+ console.log(stdout.trim());
352
+ return 0;
353
+ } catch (error) {
354
+ console.error(`✗ ${error instanceof Error ? error.message : String(error)}`);
355
+ return 1;
356
+ }
357
+ }
358
+ main(process.argv.slice(2)).then((code) => {
359
+ process.exitCode = code;
360
+ }, (error) => {
361
+ process.stderr.write(`✗ ${error instanceof Error ? error.message : String(error)}
362
+ `);
363
+ process.exitCode = error instanceof CompanionError ? error.exitCode : 1;
364
+ });
package/package.json CHANGED
@@ -1,6 +1,23 @@
1
1
  {
2
2
  "name": "argonuts",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Your media library from the terminal: add folders, scan them and keep their thumbnails ready.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "argonuts": "bin/argonuts.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "optionalDependencies": {
19
+ "@argonuts/cli-darwin-arm64": "0.1.0",
20
+ "@argonuts/cli-darwin-x64": "0.1.0",
21
+ "@argonuts/cli-win32-x64": "0.1.0"
22
+ }
23
+ }