@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 +1 -0
- package/README.md +104 -0
- package/THIRD_PARTY_NOTICES.md +41 -0
- package/bin/tinycloud.js +135 -0
- package/lib/download.js +71 -0
- package/lib/installer.js +300 -0
- package/lib/manifest.js +181 -0
- package/lib/platform.js +22 -0
- package/lib/run.js +43 -0
- package/lib/skills.js +126 -0
- package/package.json +28 -0
- package/skills/ad-analysis/SKILL.md +65 -0
- package/skills/blog-post/SKILL.md +65 -0
- package/skills/meeting-breakdown/SKILL.md +65 -0
- package/skills/sales-coaching/SKILL.md +66 -0
- package/skills/tinycloud/SKILL.md +157 -0
- package/skills/tinycloud/reference/envelope.md +73 -0
- package/skills/tinycloud/reference/glossary.md +73 -0
- package/skills/tinycloud/reference/pipelines.md +104 -0
- package/skills/tinycloud/reference/setup.md +97 -0
- package/skills/tinycloud/reference/verbs.md +180 -0
- package/skills/tinycloud/reference/workflow-authoring.md +145 -0
- package/skills/tinycloud/scripts/preflight.sh +77 -0
- package/skills/tinycloud/tinycloud-skill.json +26 -0
- package/skills/tinycloud-init/SKILL.md +89 -0
- package/skills/tinycloud-skill-creator/SKILL.md +129 -0
- package/skills/youtube-publish/SKILL.md +66 -0
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
|
package/bin/tinycloud.js
ADDED
|
@@ -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
|
+
});
|
package/lib/download.js
ADDED
|
@@ -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 };
|
package/lib/installer.js
ADDED
|
@@ -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
|
+
};
|