@cloudglue/tinycloud 0.3.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.md ADDED
@@ -0,0 +1 @@
1
+ © Aviary Inc. (d/b/a Cloudglue). All rights reserved. Use is subject to Aviary Inc. Terms of Service (https://cloudglue.dev/terms).
package/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # Tinycloud
2
+
3
+ Agent CLI for deep video work. Point it at videos and ask for analysis,
4
+ dashboards, subtitles, clips, search, or repurposed content — or drive its
5
+ verbs directly from your own agent. Powered by [Cloudglue](https://cloudglue.dev).
6
+ Learn more at [tinycloud.sh](https://tinycloud.sh).
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm install -g @cloudglue/tinycloud # then: tinycloud
12
+ npx @cloudglue/tinycloud # or run directly
13
+ ```
14
+
15
+ The npm package is a small launcher: on first run it downloads the matching
16
+ platform distribution from Cloudglue's CDN (cached under
17
+ `~/.tinycloud/versions/<version>/`), verifies its checksum, and execs the real
18
+ binary. The package version pins the binary version, so
19
+ `npx @cloudglue/tinycloud@0.3.0` always runs tinycloud 0.3.0. It also adds two
20
+ wrapper commands:
21
+
22
+ ```bash
23
+ tinycloud install --version 0.3.0 # pre-download a version
24
+ tinycloud install --latest # install latest stable and pin to it
25
+ tinycloud update # move to latest stable, prune old versions
26
+ ```
27
+
28
+ Alternatively, the shell installer (installs to `~/.tinycloud/bin` and adds
29
+ it to your PATH):
30
+
31
+ ```bash
32
+ curl -fsSL https://app.cloudglue.dev/tinycloud.sh | bash
33
+ ```
34
+
35
+ Platforms: macOS (arm64, x64) and Linux (x64, arm64). Windows is not
36
+ supported — use WSL2.
37
+
38
+ ### Setup
39
+
40
+ Cloud features need a Cloudglue API key:
41
+
42
+ ```bash
43
+ tinycloud setup cloudglue --api-key <key> # or: export CLOUDGLUE_API_KEY=...
44
+ tinycloud setup --check --json # verify
45
+ ```
46
+
47
+ ## Use tinycloud from your agent
48
+
49
+ This repo also distributes agent skills that teach coding agents (Claude
50
+ Code, Codex, and anything else following the
51
+ [Agent Skills](https://agentskills.io) standard) to drive the tinycloud CLI.
52
+
53
+ **One command** (detects your agent and installs the bundled skills):
54
+
55
+ ```bash
56
+ npx @cloudglue/tinycloud skills install # project-level (.claude/skills, .agents/skills)
57
+ npx @cloudglue/tinycloud skills install --global # ~/.claude/skills (all your projects)
58
+ npx @cloudglue/tinycloud skills install --skill tinycloud,blog-post # just some
59
+ ```
60
+
61
+ **Claude Code** (as a plugin):
62
+
63
+ ```text
64
+ /plugin marketplace add cloudglue/tinycloud
65
+ /plugin install tinycloud@cloudglue
66
+ ```
67
+
68
+ Also works with the generic installer (`npx skills add cloudglue/tinycloud`)
69
+ or a plain copy (`cp -r skills/* ~/.claude/skills/`).
70
+
71
+ | Skill | What it does |
72
+ |---|---|
73
+ | `tinycloud-init` | First-time setup: install the CLI, configure the API key, verify with a free command |
74
+ | `tinycloud` | The general skill: full CLI usage, JSON envelope contract, verbs, workflows, glossary, troubleshooting |
75
+ | `sales-coaching` | Sales call → coaching dashboard (scores, speech metrics, objections) |
76
+ | `blog-post` | Video → rich blog post with sections, thumbnails, takeaways |
77
+ | `ad-analysis` | Video ad → shot timeline, hook, pacing, CTA breakdown |
78
+ | `meeting-breakdown` | Meeting recording → speaker timeline, summaries, action items |
79
+ | `youtube-publish` | Video → YouTube title, description, chapters, tags, subtitles |
80
+ | `tinycloud-skill-creator` | Author your own tinycloud-powered skills (recipe + render script) |
81
+
82
+ New to tinycloud? Invoke `tinycloud-init` in your agent for guided setup.
83
+ Each skill checks compatibility via the general skill's
84
+ `scripts/preflight.sh`, which gates on the installed binary's version and
85
+ feature ids (`skills/tinycloud/tinycloud-skill.json` declares the
86
+ requirements).
87
+
88
+ ### Team setup
89
+
90
+ To give every agent session in a repo the same skills, commit them:
91
+
92
+ ```bash
93
+ cd your-project
94
+ npx @cloudglue/tinycloud skills install # writes .claude/skills/ (and .agents/skills/ if present)
95
+ git add .claude .agents 2>/dev/null; git commit -m "Add tinycloud agent skills"
96
+ ```
97
+
98
+ Optionally add a line to your project's `CLAUDE.md` so agents reach for them:
99
+ `Video work (analysis, captions, clips, workflows) goes through the tinycloud
100
+ CLI — see the tinycloud skill; run tinycloud-init if the CLI isn't set up.`
101
+
102
+ ## License
103
+
104
+ © Aviary Inc. (d/b/a Cloudglue). All rights reserved. Use is subject to [Aviary Inc. Terms of Service](https://cloudglue.dev/terms).
@@ -0,0 +1,41 @@
1
+ # Third-Party Notices
2
+
3
+ This file lists third-party software included in or used by Tinycloud, along with their respective licenses.
4
+
5
+ ## Bun
6
+ - Version: 1.x (bundled runtime)
7
+ - License: MIT
8
+ - https://bun.sh
9
+ - https://github.com/oven-sh/bun
10
+
11
+ ## FFmpeg
12
+ - Version: 5.3.0 (bundled binary)
13
+ - License: GPL-3.0-or-later
14
+ - Source: https://ffmpeg.org/download.html
15
+ - Binary obtained via: https://github.com/eugeneware/ffmpeg-static
16
+
17
+ ## ffprobe (bundled with FFmpeg)
18
+ - Version: 3.1.0 (bundled binary)
19
+ - License: GPL-3.0-or-later
20
+ - Source: https://ffmpeg.org/download.html
21
+ - Binary obtained via: https://github.com/joshwnj/ffprobe-static
22
+
23
+ ## chalk
24
+ - Version: 5.6.2
25
+ - License: MIT
26
+ - https://github.com/chalk/chalk
27
+
28
+ ## @mariozechner/pi-agent-core
29
+ - Version: 0.55.3
30
+ - License: MIT
31
+ - https://github.com/badlogic/pi-mono
32
+
33
+ ## @mariozechner/pi-ai
34
+ - Version: 0.55.3
35
+ - License: MIT
36
+ - https://github.com/badlogic/pi-mono
37
+
38
+ ## @mariozechner/pi-tui
39
+ - Version: 0.55.3
40
+ - License: MIT
41
+ - https://github.com/badlogic/pi-mono
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const path = require("node:path");
5
+ const { resolveTarget } = require("../lib/platform");
6
+ const {
7
+ ensureInstalled,
8
+ pruneVersions,
9
+ readOverrideVersion,
10
+ writeOverrideVersion,
11
+ normalizeVersion,
12
+ isInstalled,
13
+ } = require("../lib/installer");
14
+ const { fetchManifest } = require("../lib/manifest");
15
+ const { cmdSkills } = require("../lib/skills");
16
+ const { run } = require("../lib/run");
17
+ const pkg = require("../package.json");
18
+
19
+ function pickVersion() {
20
+ return normalizeVersion(process.env.TINYCLOUD_VERSION || readOverrideVersion() || pkg.version);
21
+ }
22
+
23
+ /**
24
+ * pickVersion() for advisory uses (prune-protect lists): a malformed
25
+ * TINYCLOUD_VERSION should fail the run path loudly, but it must not crash
26
+ * an install/update that never needed it.
27
+ */
28
+ function pickVersionSafe() {
29
+ try {
30
+ return pickVersion();
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ async function cmdInstall(args, target) {
37
+ let version = null;
38
+ let latest = false;
39
+ let prune = false;
40
+ for (let i = 0; i < args.length; i++) {
41
+ if (args[i] === "--version" && args[i + 1]) version = normalizeVersion(args[++i]);
42
+ else if (args[i] === "--latest") latest = true;
43
+ else if (args[i] === "--prune") prune = true;
44
+ else throw new Error(`Unknown install option: ${args[i]} (expected --version <v>, --latest, or --prune)`);
45
+ }
46
+ if (latest && version) throw new Error("install options --version and --latest cannot be used together");
47
+ if (prune && (latest || version)) {
48
+ // Combining an install spec with --prune is ambiguous (two Bugbot
49
+ // findings pulled opposite ways here): --prune is standalone cache
50
+ // maintenance. `tinycloud update` is install-latest-and-prune.
51
+ throw new Error(
52
+ "--prune is a standalone action: run the install first, then `tinycloud install --prune` (or use `tinycloud update`)"
53
+ );
54
+ }
55
+ if (prune) {
56
+ // Protect what the run path resolves to (env pin / wrapper-version /
57
+ // package default). A "latest" pin is resolved to its concrete version —
58
+ // the literal string would never match a versions/<semver>/ cache dir,
59
+ // leaving the tree latest actually runs unprotected.
60
+ let protect = [pickVersionSafe()].filter(Boolean);
61
+ if (protect.includes("latest")) {
62
+ const manifest = await fetchManifest().catch(() => null);
63
+ const stable = manifest && manifest.channels && manifest.channels.stable;
64
+ const resolved = stable ? normalizeVersion(stable) : null;
65
+ if (!resolved) {
66
+ // Don't guess: pruning by age could delete the tree the pin runs.
67
+ process.stderr.write(
68
+ "tinycloud: cannot resolve the 'latest' pin without the release manifest — skipping prune\n"
69
+ );
70
+ return;
71
+ }
72
+ protect = protect.map((p) => (p === "latest" ? resolved : p)).filter(Boolean);
73
+ }
74
+ const removed = pruneVersions(2, protect, target);
75
+ console.log(removed.length ? `Pruned: ${removed.join(", ")}` : "Nothing to prune.");
76
+ return;
77
+ }
78
+ // An explicit --version wins without consulting the env/override, so a
79
+ // malformed TINYCLOUD_VERSION can't block an explicit install.
80
+ if (!version && !latest) version = pickVersion();
81
+ let manifest;
82
+ if (latest) {
83
+ manifest = await fetchManifest();
84
+ if (!manifest) throw new Error("`install --latest` requires the release manifest, which is not available");
85
+ version = manifest.channels && manifest.channels.stable;
86
+ if (!version) throw new Error("The release manifest has no stable version");
87
+ }
88
+ const res = await ensureInstalled(version, target, { manifest });
89
+ if (latest) writeOverrideVersion(res.version);
90
+ console.log(`tinycloud ${res.version} installed at ${res.dir}`);
91
+ }
92
+
93
+ async function cmdUpdate(target) {
94
+ const manifest = await fetchManifest();
95
+ if (!manifest) throw new Error("`update` requires the release manifest, which is not available");
96
+ const version = normalizeVersion(manifest.channels && manifest.channels.stable);
97
+ if (!version) throw new Error("The release manifest has no stable version");
98
+ const alreadyCurrent = isInstalled(version, target);
99
+ const res = await ensureInstalled(version, target, { manifest });
100
+ writeOverrideVersion(res.version);
101
+ // Protect both the new stable and whatever the run path still resolves to
102
+ // (e.g. a TINYCLOUD_VERSION env pin). Advisory only — a malformed env
103
+ // value must not fail an update that already completed. A "latest" pin
104
+ // resolves to the stable we just installed.
105
+ const pinned = pickVersionSafe();
106
+ const removed = pruneVersions(2, [res.version, pinned === "latest" ? res.version : pinned].filter(Boolean), target);
107
+ console.log(
108
+ alreadyCurrent
109
+ ? `tinycloud ${res.version} is already current (${res.dir})`
110
+ : `tinycloud updated to ${res.version} (${res.dir})`
111
+ );
112
+ if (removed.length) console.log(`Pruned old versions: ${removed.join(", ")}`);
113
+ }
114
+
115
+ async function main() {
116
+ const args = process.argv.slice(2);
117
+
118
+ // Wrapper-owned subcommands. The binary has no install/update/skills
119
+ // verbs; these names are reserved with the binary owners (guarded by a
120
+ // regression test in the source repo). `skills` only copies bundled
121
+ // files, so it dispatches before the platform gate (works on Windows).
122
+ if (args[0] === "skills") return cmdSkills(args.slice(1));
123
+
124
+ const target = resolveTarget();
125
+ if (args[0] === "install") return cmdInstall(args.slice(1), target);
126
+ if (args[0] === "update") return cmdUpdate(target);
127
+
128
+ const { dir } = await ensureInstalled(pickVersion(), target);
129
+ run(path.join(dir, "tinycloud"), args, dir);
130
+ }
131
+
132
+ main().catch((err) => {
133
+ process.stderr.write(`tinycloud: ${err.message}\n`);
134
+ process.exit(typeof err.exitCode === "number" ? err.exitCode : 1);
135
+ });
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+
3
+ const crypto = require("node:crypto");
4
+ const fs = require("node:fs");
5
+ const { execFileSync } = require("node:child_process");
6
+ const { Readable } = require("node:stream");
7
+ const { pipeline } = require("node:stream/promises");
8
+
9
+ function fmtMB(bytes) {
10
+ return (bytes / 1048576).toFixed(1);
11
+ }
12
+
13
+ function sha256File(path) {
14
+ const hash = crypto.createHash("sha256");
15
+ hash.update(fs.readFileSync(path));
16
+ return hash.digest("hex");
17
+ }
18
+
19
+ /**
20
+ * Download a URL to a file, computing sha256 in the same pass. Shows progress
21
+ * on stderr when it's a TTY. Node's fetch ignores proxy env vars, so when an
22
+ * HTTPS proxy is configured we shell out to curl instead.
23
+ * @returns {Promise<{sha256: string, etag: string|null}>}
24
+ */
25
+ async function downloadWithHash(url, destPath) {
26
+ const proxy = process.env.HTTPS_PROXY || process.env.https_proxy;
27
+ if (proxy || process.env.TINYCLOUD_USE_CURL === "1") {
28
+ const headerFile = `${destPath}.headers`;
29
+ execFileSync("curl", ["-fsSL", "-D", headerFile, "-o", destPath, url], {
30
+ stdio: ["ignore", "ignore", "inherit"],
31
+ });
32
+ let etag = null;
33
+ try {
34
+ const m = fs.readFileSync(headerFile, "utf8").match(/^etag:\s*(.+)$/im);
35
+ etag = m ? m[1].trim() : null;
36
+ fs.unlinkSync(headerFile);
37
+ } catch {}
38
+ return { sha256: sha256File(destPath), etag };
39
+ }
40
+
41
+ const res = await fetch(url);
42
+ if (res.status === 403 || res.status === 404) {
43
+ throw new Error(`Download not found: ${url} (HTTP ${res.status}) — the requested version may not be published`);
44
+ }
45
+ if (!res.ok || !res.body) throw new Error(`Download failed: ${url} (HTTP ${res.status})`);
46
+
47
+ const etag = res.headers.get("etag");
48
+ const total = Number(res.headers.get("content-length")) || 0;
49
+ const hash = crypto.createHash("sha256");
50
+ const out = fs.createWriteStream(destPath);
51
+ const showProgress = process.stderr.isTTY;
52
+ let received = 0;
53
+ let lastRender = 0;
54
+
55
+ const source = Readable.fromWeb(res.body);
56
+ source.on("data", (chunk) => {
57
+ hash.update(chunk);
58
+ received += chunk.length;
59
+ const now = Date.now();
60
+ if (showProgress && now - lastRender > 250) {
61
+ lastRender = now;
62
+ const pct = total ? ` (${Math.floor((received / total) * 100)}%)` : "";
63
+ process.stderr.write(`\rtinycloud: downloading ${fmtMB(received)}/${total ? fmtMB(total) : "?"} MB${pct}`);
64
+ }
65
+ });
66
+ await pipeline(source, out);
67
+ if (showProgress) process.stderr.write("\n");
68
+ return { sha256: hash.digest("hex"), etag };
69
+ }
70
+
71
+ module.exports = { downloadWithHash, sha256File };
@@ -0,0 +1,300 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const os = require("node:os");
5
+ const path = require("node:path");
6
+ const { execFileSync } = require("node:child_process");
7
+ const { resolveDownload, httpHeadEtag } = require("./manifest");
8
+ const { downloadWithHash } = require("./download");
9
+
10
+ function rootDir() {
11
+ return process.env.TINYCLOUD_INSTALL_DIR || path.join(os.homedir(), ".tinycloud");
12
+ }
13
+
14
+ /**
15
+ * Accept v-prefixed versions everywhere a version is taken ("v0.3.0" ==
16
+ * "0.3.0"), and reject anything that isn't a plain version token — version
17
+ * strings become cache directory names, so path separators or ".." would
18
+ * escape ~/.tinycloud/versions.
19
+ */
20
+ function normalizeVersion(version) {
21
+ if (typeof version !== "string") return version;
22
+ const v = version.replace(/^v/, "");
23
+ if (v !== "latest" && !/^[0-9A-Za-z][0-9A-Za-z.+_-]*$/.test(v)) {
24
+ throw new Error(`Invalid version "${version}" (expected a version like 0.3.0)`);
25
+ }
26
+ return v;
27
+ }
28
+
29
+ /**
30
+ * Is this version installed — and, when `target` is given, installed for
31
+ * this platform? A cache root synced across machines (network home, mounted
32
+ * volume) can hold another OS/arch's extract under the same version.
33
+ */
34
+ function isInstalled(version, target) {
35
+ const okPath = path.join(versionDir(version), ".ok");
36
+ if (!fs.existsSync(okPath)) return false;
37
+ if (!hasExecutableBinary(versionDir(version))) return false;
38
+ if (!target) return true;
39
+ try {
40
+ const meta = JSON.parse(fs.readFileSync(okPath, "utf8"));
41
+ if (meta.target) return meta.target === target;
42
+ // Older .ok files lack the target; the recorded URL embeds it.
43
+ if (meta.url) return String(meta.url).includes(`tinycloud-${target}`);
44
+ return true;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ function versionDir(version) {
51
+ return path.join(rootDir(), "versions", version);
52
+ }
53
+
54
+ function hasExecutableBinary(dir) {
55
+ try {
56
+ fs.accessSync(path.join(dir, "tinycloud"), fs.constants.X_OK);
57
+ return true;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ function overrideFile() {
64
+ return path.join(rootDir(), "wrapper-version");
65
+ }
66
+
67
+ /** Newest healthy cached install for this platform, or null. */
68
+ function findNewestHealthy(target) {
69
+ const dir = path.join(rootDir(), "versions");
70
+ let entries;
71
+ try {
72
+ entries = fs.readdirSync(dir);
73
+ } catch {
74
+ return null;
75
+ }
76
+ const healthy = entries
77
+ .filter((v) => isInstalled(v, target))
78
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
79
+ if (!healthy.length) return null;
80
+ const version = healthy[healthy.length - 1];
81
+ return { dir: path.join(dir, version), version };
82
+ }
83
+
84
+ /** Scan installed versions for a .ok recorded from the same URL + ETag. */
85
+ function findInstalledByUrlEtag(url, etag) {
86
+ const dir = path.join(rootDir(), "versions");
87
+ let entries;
88
+ try {
89
+ entries = fs.readdirSync(dir);
90
+ } catch {
91
+ return null;
92
+ }
93
+ for (const v of entries) {
94
+ try {
95
+ const meta = JSON.parse(fs.readFileSync(path.join(dir, v, ".ok"), "utf8"));
96
+ if (meta.url === url && meta.etag && meta.etag === etag && hasExecutableBinary(path.join(dir, v))) {
97
+ return { dir: path.join(dir, v), version: v };
98
+ }
99
+ } catch {}
100
+ }
101
+ return null;
102
+ }
103
+
104
+ function readOverrideVersion() {
105
+ try {
106
+ const v = fs.readFileSync(overrideFile(), "utf8").trim();
107
+ return v || null;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ function writeOverrideVersion(version) {
114
+ fs.mkdirSync(rootDir(), { recursive: true });
115
+ fs.writeFileSync(overrideFile(), version + "\n");
116
+ }
117
+
118
+ /**
119
+ * Ensure the given version is installed under <root>/versions/<version>.
120
+ * Returns the install dir. Concurrency-safe via extract-then-atomic-rename:
121
+ * a half-extracted directory never carries the .ok marker, and a lost rename
122
+ * race just means another process won.
123
+ *
124
+ * @param {string} version exact semver, or "latest" (resolved via manifest
125
+ * when available; the latest alias tarball otherwise)
126
+ * @returns {Promise<{dir: string, version: string}>}
127
+ */
128
+ async function ensureInstalled(version, target, opts = {}) {
129
+ version = normalizeVersion(version);
130
+ if (version !== "latest") {
131
+ if (isInstalled(version, target)) return { dir: versionDir(version), version };
132
+ }
133
+
134
+ try {
135
+ return await resolveAndInstall(version, target, opts);
136
+ } catch (err) {
137
+ // "latest" means "the best you can give me": when resolution or the
138
+ // download is unreachable but a healthy install is cached, run that
139
+ // instead of failing — a warm laptop going offline shouldn't brick
140
+ // `tinycloud --help`. Pinned versions and strict mode still fail.
141
+ if (version === "latest" && process.env.TINYCLOUD_REQUIRE_MANIFEST !== "1") {
142
+ const fallback = findNewestHealthy(target);
143
+ if (fallback) {
144
+ process.stderr.write(
145
+ `tinycloud: could not resolve latest (${err.message.split("\n")[0]}); using cached ${fallback.version}\n`
146
+ );
147
+ return fallback;
148
+ }
149
+ }
150
+ throw err;
151
+ }
152
+ }
153
+
154
+ async function resolveAndInstall(version, target, opts = {}) {
155
+ const res = await resolveDownload(version, "stable", target, opts.manifest);
156
+ // Strict mode means verified-or-fail, not just manifest-present — same
157
+ // rule as install.sh (e.g. a manifest entry without sha256).
158
+ if (process.env.TINYCLOUD_REQUIRE_MANIFEST === "1" && !res.sha256) {
159
+ throw new Error(`TINYCLOUD_REQUIRE_MANIFEST=1 but no checksum is available for ${res.url}`);
160
+ }
161
+ // Manifest-sourced version strings become cache dir names too — run them
162
+ // through the same normalize/validate as user input.
163
+ if (res.version) res.version = normalizeVersion(res.version);
164
+ if (res.version) {
165
+ if (isInstalled(res.version, target)) return { dir: versionDir(res.version), version: res.version };
166
+ } else {
167
+ // "latest" with no manifest: the alias tarball has no version attached,
168
+ // but if its ETag matches a cached install of the same URL we can skip
169
+ // the ~90MB re-download.
170
+ const etag = await httpHeadEtag(res.url);
171
+ if (etag) {
172
+ const warm = findInstalledByUrlEtag(res.url, etag);
173
+ if (warm) return warm;
174
+ }
175
+ }
176
+
177
+ const root = rootDir();
178
+ fs.mkdirSync(path.join(root, "tmp"), { recursive: true });
179
+ const stage = fs.mkdtempSync(path.join(root, "tmp", "dl-"));
180
+ try {
181
+ const tarball = path.join(stage, "tinycloud.tar.gz");
182
+ process.stderr.write(`tinycloud: fetching ${res.url}\n`);
183
+ const { sha256: actual, etag } = await downloadWithHash(res.url, tarball);
184
+ if (res.sha256 && actual !== res.sha256.toLowerCase()) {
185
+ throw new Error(
186
+ `Checksum mismatch for ${res.url}\n expected ${res.sha256}\n actual ${actual}\n` +
187
+ "Refusing to install. Retry, or report to Cloudglue if it persists."
188
+ );
189
+ }
190
+
191
+ const extracted = path.join(stage, "x");
192
+ fs.mkdirSync(extracted);
193
+ execFileSync("tar", ["-xzf", tarball, "-C", extracted]);
194
+ const binPath = path.join(extracted, "tinycloud");
195
+ if (!fs.existsSync(binPath)) throw new Error("Downloaded tarball is missing the ./tinycloud binary");
196
+
197
+ // Resolve "latest" with no manifest: ask the binary itself. stdin must be
198
+ // closed and the call bounded — pre-0.3.0 binaries open an interactive
199
+ // TUI on this invocation instead of printing JSON.
200
+ let version_ = res.version;
201
+ if (!version_) {
202
+ let out;
203
+ try {
204
+ out = execFileSync(binPath, ["--version", "--json"], {
205
+ encoding: "utf8",
206
+ stdio: ["ignore", "pipe", "ignore"],
207
+ timeout: 15000,
208
+ });
209
+ version_ = JSON.parse(out).version;
210
+ } catch {
211
+ throw new Error(
212
+ "Downloaded binary did not report a machine-readable version (older than 0.3.0?) — refusing to install"
213
+ );
214
+ }
215
+ if (!version_) throw new Error("Downloaded binary reported no version — refusing to install");
216
+ version_ = normalizeVersion(version_); // binary-reported strings become dir names too
217
+ }
218
+
219
+ fs.writeFileSync(
220
+ path.join(extracted, ".ok"),
221
+ JSON.stringify({ version: version_, target, sha256: actual, verified: !!res.sha256, url: res.url, etag }) + "\n"
222
+ );
223
+ const dest = versionDir(version_);
224
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
225
+ try {
226
+ fs.renameSync(extracted, dest);
227
+ } catch (err) {
228
+ // A peer's completed install only counts if it's for THIS platform —
229
+ // a wrong-platform extract under the same version is stale.
230
+ if (!isInstalled(version_, target)) {
231
+ if (fs.existsSync(dest)) {
232
+ // Leftover from an interrupted install (dir present, no .ok):
233
+ // clear it and claim the slot rather than failing forever. If a
234
+ // concurrent peer completes between the .ok check and this swap,
235
+ // we replace its install with our byte-identical extract of the
236
+ // same checksum-verified tarball — a momentary swap, not
237
+ // corruption (eliminating it entirely would need cross-process
238
+ // locking, which isn't worth it for this window).
239
+ fs.rmSync(dest, { recursive: true, force: true });
240
+ fs.renameSync(extracted, dest);
241
+ } else {
242
+ throw err;
243
+ }
244
+ }
245
+ // else: lost the race → another process completed the install
246
+ }
247
+ return { dir: dest, version: version_ };
248
+ } finally {
249
+ fs.rmSync(stage, { recursive: true, force: true });
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Keep the newest `keep` installed versions for THIS platform; remove the
255
+ * rest. Versions in `protect` (e.g. the one the wrapper currently resolves
256
+ * to) are never removed and don't count against `keep`. Broken entries (a
257
+ * .ok marker but no runnable binary) are removed outright and never occupy
258
+ * a retention slot. When `target` is given, healthy entries for OTHER
259
+ * platforms (a shared/synced cache root) are left untouched and don't
260
+ * count either — this machine's prune must not delete a peer machine's
261
+ * builds, nor let them crowd out the only build that runs here.
262
+ */
263
+ function pruneVersions(keep = 2, protect = [], target) {
264
+ const dir = path.join(rootDir(), "versions");
265
+ const protected_ = new Set(protect.map(normalizeVersion).filter(Boolean));
266
+ let entries;
267
+ try {
268
+ entries = fs.readdirSync(dir);
269
+ } catch {
270
+ return [];
271
+ }
272
+ const healthy = [];
273
+ const broken = [];
274
+ for (const e of entries) {
275
+ // Dirs without .ok are crash leftovers; ensureInstalled reclaims those.
276
+ if (!fs.existsSync(path.join(dir, e, ".ok"))) continue;
277
+ if (protected_.has(e)) continue;
278
+ if (!isInstalled(e)) {
279
+ broken.push(e);
280
+ continue;
281
+ }
282
+ if (target && !isInstalled(e, target)) continue; // peer platform's tree
283
+ healthy.push(e);
284
+ }
285
+ healthy.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
286
+ const removed = [...broken, ...healthy.slice(0, Math.max(0, healthy.length - keep))];
287
+ for (const v of removed) fs.rmSync(path.join(dir, v), { recursive: true, force: true });
288
+ return removed;
289
+ }
290
+
291
+ module.exports = {
292
+ rootDir,
293
+ versionDir,
294
+ ensureInstalled,
295
+ pruneVersions,
296
+ readOverrideVersion,
297
+ writeOverrideVersion,
298
+ normalizeVersion,
299
+ isInstalled,
300
+ };