@pirol/nido 0.2.0 → 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/README.md +78 -80
- package/dist/api.js +35 -0
- package/dist/cli.js +23 -2
- package/dist/commands.js +7 -1
- package/dist/manifest.js +10 -1
- package/dist/storage.js +115 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,50 +1,52 @@
|
|
|
1
1
|
# nido
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
beschrieben. Man gibt nido einen Ordner, nido lässt darin einen Agenten
|
|
6
|
-
arbeiten und holt die Ergebnisse zurück. Jede Sandbox läuft in einer eigenen
|
|
7
|
-
Kata-VM.
|
|
3
|
+
Run coding agents and commands in isolated sandboxes. Give nido a folder, it lets an agent work
|
|
4
|
+
on it in a sandbox and brings the results back.
|
|
8
5
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
Kunden und reicht die Aufrufe an das OpenShell-Gateway weiter. Die Doku für
|
|
12
|
-
Kunden steht unter [pirol.ai/docs/sandboxes](https://pirol.ai/docs/sandboxes).
|
|
6
|
+
Every sandbox is its own small virtual machine (Kata Containers) in Frankfurt, run by
|
|
7
|
+
[Pirol](https://pirol.ai).
|
|
13
8
|
|
|
14
|
-
|
|
9
|
+
> **Closed beta.** Free during the beta, for invited accounts only, up to 5 sandboxes per account.
|
|
10
|
+
> Ask for access at [hello@pirol.ai](mailto:hello@pirol.ai).
|
|
15
11
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
12
|
+
Full documentation: [pirol.ai/docs/sandboxes](https://pirol.ai/docs/sandboxes)
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
Requires Node 20 or newer.
|
|
19
17
|
|
|
20
18
|
```bash
|
|
21
19
|
npm install -g @pirol/nido
|
|
22
20
|
nido login
|
|
23
21
|
```
|
|
24
22
|
|
|
25
|
-
`nido login`
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
die Umgebungsvariable `PIROL_API_KEY`. `NIDO_API_URL` zeigt nido auf eine
|
|
29
|
-
andere API, etwa eine lokale.
|
|
23
|
+
`nido login` opens your browser. You confirm with your Pirol account, and nido stores a key in
|
|
24
|
+
`~/.nido/config.json` (readable only by you). Without a browser, use `nido login --manual`. In CI,
|
|
25
|
+
set `PIROL_API_KEY` instead.
|
|
30
26
|
|
|
31
|
-
|
|
27
|
+
## Run an agent on a folder
|
|
32
28
|
|
|
33
29
|
```bash
|
|
34
|
-
cd
|
|
30
|
+
cd my-project
|
|
31
|
+
nido init # writes nido.json
|
|
32
|
+
nido secret set anthropic # once; asks for the key without echo
|
|
33
|
+
nido run
|
|
35
34
|
```
|
|
36
35
|
|
|
37
|
-
|
|
36
|
+
`nido run`:
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
38
|
+
1. Creates the sandbox if it does not exist yet. The name comes from `name` or the folder name.
|
|
39
|
+
A new sandbox is ready in about 20 seconds.
|
|
40
|
+
2. Uploads changed files. Unchanged files are not uploaded again; files you deleted locally are
|
|
41
|
+
deleted in the sandbox too.
|
|
42
|
+
3. Runs the agent and shows each step on one line.
|
|
43
|
+
4. Brings `output/` back. A file you changed locally is not overwritten; the sandbox version is
|
|
44
|
+
saved next to it, for example as `report.remote.md`.
|
|
45
|
+
|
|
46
|
+
The sandbox keeps running afterwards. The next `nido run` reuses it, `nido run --fresh` starts
|
|
47
|
+
over. `nido pull` fetches `output/` again.
|
|
46
48
|
|
|
47
|
-
|
|
49
|
+
### nido.json
|
|
48
50
|
|
|
49
51
|
```json
|
|
50
52
|
{
|
|
@@ -57,40 +59,39 @@ nido run
|
|
|
57
59
|
}
|
|
58
60
|
```
|
|
59
61
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
1. Sandbox anlegen, falls es sie noch nicht gibt. Der Name kommt aus
|
|
63
|
-
`name` oder dem Ordnernamen. Ein neuer Start dauert etwa 20 Sekunden.
|
|
64
|
-
2. Geänderte Dateien hochladen. nido merkt sich die Hashes in
|
|
65
|
-
`.nido/state.json`. Unveränderte Dateien gehen nicht noch einmal hoch,
|
|
66
|
-
lokal gelöschte verschwinden auch in der Sandbox.
|
|
67
|
-
3. Den Agenten starten. Bei Claude zeigt nido jeden Schritt in einer Zeile.
|
|
68
|
-
4. `output/` zurückholen. Neue Dateien landen im Ordner. Eine Datei, die
|
|
69
|
-
man lokal geändert hat, überschreibt nido nicht. Die Fassung aus der
|
|
70
|
-
Sandbox heißt dann zum Beispiel `report.remote.md`.
|
|
71
|
-
|
|
72
|
-
Die Sandbox bleibt danach stehen. Der nächste `nido run` nutzt sie weiter,
|
|
73
|
-
`nido run --fresh` fängt neu an.
|
|
74
|
-
|
|
75
|
-
Statt eines Agenten geht auch ein Befehl, das braucht keinen Schlüssel:
|
|
62
|
+
Instead of an agent, run any command. It needs no key:
|
|
76
63
|
|
|
77
64
|
```json
|
|
78
65
|
{ "command": "python analyze.py", "output": "output/" }
|
|
79
66
|
```
|
|
80
67
|
|
|
81
|
-
|
|
68
|
+
| Field | Meaning |
|
|
69
|
+
| --- | --- |
|
|
70
|
+
| `agent` | `claude`; more will follow |
|
|
71
|
+
| `task` | What the agent should do |
|
|
72
|
+
| `command` | Instead of `agent`: a command as a string (runs in bash) or a list |
|
|
73
|
+
| `name` | Sandbox name; defaults to the folder name |
|
|
74
|
+
| `image` | Your own container image; defaults to an image with Claude Code, Codex and OpenCode |
|
|
75
|
+
| `files.exclude` | Patterns like in `.gitignore`; `.git` and `.nido` are always excluded |
|
|
76
|
+
| `secrets` | Names of stored secrets, never values |
|
|
77
|
+
| `network` | Hosts the sandbox may reach on port 443; everything else is blocked |
|
|
78
|
+
| `storage` | Storage volumes, mounted at `/data/<name>` |
|
|
79
|
+
| `output` | Folder that comes back after the run; default `output/` |
|
|
82
80
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
81
|
+
## Storage
|
|
82
|
+
|
|
83
|
+
Keep large data in storage, independently of sandboxes:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
nido storage put ./data my-data # upload straight to Cloud Storage in Frankfurt
|
|
87
|
+
nido storage ls # volumes with file count and size
|
|
88
|
+
nido storage ls my-data # files in one volume
|
|
89
|
+
nido storage rm my-data # only once no sandbox uses it
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Add `"storage": ["my-data"]` to `nido.json` (or `nido create shop --storage my-data`), and the
|
|
93
|
+
sandbox reads and writes the data at `/data/my-data`, without copying. Volumes are attached when a
|
|
94
|
+
sandbox is created; after changing `storage`, run `nido run --fresh`.
|
|
94
95
|
|
|
95
96
|
## Secrets
|
|
96
97
|
|
|
@@ -102,14 +103,10 @@ nido secret ls
|
|
|
102
103
|
nido secret rm github
|
|
103
104
|
```
|
|
104
105
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
niemand mehr, auch der Kunde nicht.
|
|
108
|
-
In der Sandbox steht nur ein Platzhalter. Der Proxy von OpenShell setzt den
|
|
109
|
-
echten Wert erst beim Aufruf ein, und nur für die Hosts des Dienstes, etwa
|
|
110
|
-
`api.anthropic.com`. Andere Namen kennt nido noch nicht.
|
|
106
|
+
A secret is stored once and can never be read again, not even by you. Inside the sandbox the agent
|
|
107
|
+
only sees a placeholder; the real value is added on the way to the provider's API.
|
|
111
108
|
|
|
112
|
-
##
|
|
109
|
+
## More commands
|
|
113
110
|
|
|
114
111
|
```bash
|
|
115
112
|
nido create shop --secret anthropic --network pypi.org
|
|
@@ -120,26 +117,27 @@ nido cp shop:/sandbox/work/output ./out
|
|
|
120
117
|
nido logs shop
|
|
121
118
|
nido stop shop && nido start shop
|
|
122
119
|
nido rm shop
|
|
120
|
+
nido whoami
|
|
121
|
+
nido logout
|
|
123
122
|
```
|
|
124
123
|
|
|
125
|
-
|
|
124
|
+
Paths inside a sandbox must be below `/sandbox`.
|
|
126
125
|
|
|
127
|
-
|
|
128
|
-
npm test # unit tests
|
|
129
|
-
npm run build # dist/
|
|
130
|
-
```
|
|
126
|
+
## Limits in the beta
|
|
131
127
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
in
|
|
135
|
-
|
|
128
|
+
- Up to 5 sandboxes per account, and 10 storage volumes with 100 GB in total.
|
|
129
|
+
- No interactive shell yet; use `nido exec`.
|
|
130
|
+
- The agent runs in the foreground. If the connection drops, `nido pull` fetches what is already
|
|
131
|
+
in `output/`.
|
|
132
|
+
- No backups or templates of a sandbox yet. Keep anything important in `output/` or in storage.
|
|
136
133
|
|
|
137
|
-
|
|
134
|
+
## Development
|
|
138
135
|
|
|
139
|
-
|
|
136
|
+
```bash
|
|
137
|
+
cd nido && npm install && npm run build && npm link
|
|
138
|
+
npm test
|
|
139
|
+
```
|
|
140
140
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
`nido pull` das, was schon in `output/` liegt.
|
|
145
|
-
- Keine dauerhaften Arbeitsbereiche und kein Backup (#695).
|
|
141
|
+
`NIDO_API_URL` points nido at another API, for example a local agents-api. Upload and
|
|
142
|
+
download rules live in `src/sync.ts` as pure functions, API calls in `src/api.ts`, login in
|
|
143
|
+
`src/login.ts`. Publish with `npm publish` in this folder; it builds and tests first.
|
package/dist/api.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Client for pirol-agents-api. Files travel as tar.gz streams, command
|
|
2
2
|
// output comes back as NDJSON while the command runs.
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
|
+
import { createReadStream } from "node:fs";
|
|
5
|
+
import { request } from "node:https";
|
|
4
6
|
import { Readable } from "node:stream";
|
|
5
7
|
import { loadConfig } from "./config.js";
|
|
6
8
|
export class ApiError extends Error {
|
|
@@ -152,4 +154,37 @@ export class Api {
|
|
|
152
154
|
removeSecret(name) {
|
|
153
155
|
return this.json("DELETE", `/v1/secrets/${encodeURIComponent(name)}`);
|
|
154
156
|
}
|
|
157
|
+
storageList() {
|
|
158
|
+
return this.json("GET", "/v1/storage");
|
|
159
|
+
}
|
|
160
|
+
storageFiles(name) {
|
|
161
|
+
return this.json("GET", `/v1/storage/${encodeURIComponent(name)}`);
|
|
162
|
+
}
|
|
163
|
+
async storageCreate(name) {
|
|
164
|
+
return (await this.json("PUT", `/v1/storage/${encodeURIComponent(name)}`)).result;
|
|
165
|
+
}
|
|
166
|
+
/** One upload URL per file; each takes exactly the given number of bytes. */
|
|
167
|
+
async storageUploads(name, files) {
|
|
168
|
+
return (await this.json("POST", `/v1/storage/${encodeURIComponent(name)}/uploads`, { files })).uploads;
|
|
169
|
+
}
|
|
170
|
+
storageRemove(name) {
|
|
171
|
+
return this.json("DELETE", `/v1/storage/${encodeURIComponent(name)}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** Sends a local file to an upload URL from storageUploads (straight to Cloud Storage). */
|
|
175
|
+
export function putFile(url, file, size) {
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
const req = request(url, { method: "PUT", headers: { "content-length": size } }, (res) => {
|
|
178
|
+
let body = "";
|
|
179
|
+
res.on("data", (d) => (body += d.toString("utf8")));
|
|
180
|
+
res.on("end", () => res.statusCode === 200 || res.statusCode === 201
|
|
181
|
+
? resolve()
|
|
182
|
+
: reject(new Error(`upload failed: HTTP ${res.statusCode} ${body.slice(0, 200)}`)));
|
|
183
|
+
});
|
|
184
|
+
req.on("error", reject);
|
|
185
|
+
if (size === 0)
|
|
186
|
+
req.end();
|
|
187
|
+
else
|
|
188
|
+
createReadStream(file).on("error", reject).pipe(req);
|
|
189
|
+
});
|
|
155
190
|
}
|
package/dist/cli.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { readFileSync, realpathSync } from "node:fs";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { copy, createSandbox, exec, init, list, login, logout, pull, runFolder, secretList, secretRemove, secretSet, simple, whoami, } from "./commands.js";
|
|
5
|
+
import { storageLs, storagePut, storageRm } from "./storage.js";
|
|
5
6
|
const HELP = `nido — run agents in isolated sandboxes (closed beta)
|
|
6
7
|
|
|
7
8
|
Start
|
|
@@ -14,13 +15,18 @@ Folder workflow
|
|
|
14
15
|
nido pull bring output/ back again
|
|
15
16
|
|
|
16
17
|
Sandboxes
|
|
17
|
-
nido create <name> [--image <ref>] [--secret <name>]… [--network <host>]…
|
|
18
|
+
nido create <name> [--image <ref>] [--secret <name>]… [--network <host>]… [--storage <name>]…
|
|
18
19
|
nido ls list your sandboxes
|
|
19
20
|
nido exec <name> -- <cmd>… run a command
|
|
20
21
|
nido cp <src> <dst> copy files; write the sandbox side as <name>:<path>
|
|
21
22
|
nido logs <name> sandbox logs
|
|
22
23
|
nido stop|start|rm <name>
|
|
23
24
|
|
|
25
|
+
Storage (lives on without sandboxes, mounted at /data/<name>)
|
|
26
|
+
nido storage put <folder|file> <name> upload, straight to Cloud Storage
|
|
27
|
+
nido storage ls [name]
|
|
28
|
+
nido storage rm <name>
|
|
29
|
+
|
|
24
30
|
Secrets (stored once, never readable again)
|
|
25
31
|
nido secret set <name> [--from-gh] anthropic, openai, github
|
|
26
32
|
nido secret ls
|
|
@@ -69,6 +75,7 @@ async function main(argv) {
|
|
|
69
75
|
image: flags.get("image")?.[0],
|
|
70
76
|
secrets: flags.get("secret") ?? [],
|
|
71
77
|
network: flags.get("network") ?? [],
|
|
78
|
+
storage: flags.get("storage") ?? [],
|
|
72
79
|
});
|
|
73
80
|
return 0;
|
|
74
81
|
case "ls":
|
|
@@ -93,6 +100,8 @@ async function main(argv) {
|
|
|
93
100
|
return 0;
|
|
94
101
|
case "secret":
|
|
95
102
|
return secret(args, flags);
|
|
103
|
+
case "storage":
|
|
104
|
+
return storage(args);
|
|
96
105
|
default:
|
|
97
106
|
throw new Error(`Unknown command "${cmd}". See: nido help`);
|
|
98
107
|
}
|
|
@@ -109,7 +118,19 @@ async function secret(args, flags) {
|
|
|
109
118
|
throw new Error("Usage: nido secret set <name> | nido secret ls | nido secret rm <name>");
|
|
110
119
|
return 0;
|
|
111
120
|
}
|
|
112
|
-
|
|
121
|
+
async function storage(args) {
|
|
122
|
+
const [sub, a, b] = args;
|
|
123
|
+
if (sub === "put" && a && b)
|
|
124
|
+
await storagePut(a, b);
|
|
125
|
+
else if (sub === "ls")
|
|
126
|
+
await storageLs(a);
|
|
127
|
+
else if (sub === "rm" && a)
|
|
128
|
+
await storageRm(a);
|
|
129
|
+
else
|
|
130
|
+
throw new Error("Usage: nido storage put <folder|file> <name> | nido storage ls [name] | nido storage rm <name>");
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
const VALUE_FLAGS = new Set(["image", "secret", "network", "storage"]);
|
|
113
134
|
export function parse(argv) {
|
|
114
135
|
const flags = new Map();
|
|
115
136
|
const args = [];
|
package/dist/commands.js
CHANGED
|
@@ -164,7 +164,13 @@ async function ensureSandbox(api, m, fresh) {
|
|
|
164
164
|
}
|
|
165
165
|
if (!info) {
|
|
166
166
|
say(`Creating sandbox ${m.name}…`);
|
|
167
|
-
const created = await api.create({
|
|
167
|
+
const created = await api.create({
|
|
168
|
+
name: m.name,
|
|
169
|
+
image: m.imageSet ? m.image : undefined,
|
|
170
|
+
secrets: m.secrets,
|
|
171
|
+
network: m.network,
|
|
172
|
+
storage: m.storage,
|
|
173
|
+
});
|
|
168
174
|
say(`Sandbox ${m.name} ready in ${(created.ready_ms / 1000).toFixed(1)} s`);
|
|
169
175
|
return true;
|
|
170
176
|
}
|
package/dist/manifest.js
CHANGED
|
@@ -25,7 +25,7 @@ export function parseManifest(raw, folderName) {
|
|
|
25
25
|
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
26
26
|
throw new Error(`${MANIFEST} must be an object`);
|
|
27
27
|
const m = raw;
|
|
28
|
-
const known = ["name", "agent", "task", "command", "image", "files", "secrets", "network", "output"];
|
|
28
|
+
const known = ["name", "agent", "task", "command", "image", "files", "secrets", "network", "storage", "output"];
|
|
29
29
|
const unknown = Object.keys(m).filter((key) => !known.includes(key) && !key.startsWith("$"));
|
|
30
30
|
if (unknown.length)
|
|
31
31
|
throw new Error(`${MANIFEST}: unknown field ${unknown.map((k) => `"${k}"`).join(", ")}`);
|
|
@@ -65,6 +65,11 @@ export function parseManifest(raw, folderName) {
|
|
|
65
65
|
exclude,
|
|
66
66
|
secrets: stringList(m, "secrets"),
|
|
67
67
|
network: stringList(m, "network"),
|
|
68
|
+
storage: stringList(m, "storage").map((name) => {
|
|
69
|
+
if (!isVolumeName(name))
|
|
70
|
+
throw new Error(`${MANIFEST}: "${name}" in "storage" is not a valid name`);
|
|
71
|
+
return name;
|
|
72
|
+
}),
|
|
68
73
|
output,
|
|
69
74
|
};
|
|
70
75
|
}
|
|
@@ -78,6 +83,10 @@ export function sandboxName(input) {
|
|
|
78
83
|
.replace(/-+$/, "");
|
|
79
84
|
return name || "nest";
|
|
80
85
|
}
|
|
86
|
+
/** Storage names, like sandbox names: lowercase letters, digits and dashes, at most 40. */
|
|
87
|
+
export function isVolumeName(name) {
|
|
88
|
+
return /^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$/.test(name);
|
|
89
|
+
}
|
|
81
90
|
function optionalString(m, key) {
|
|
82
91
|
const value = m[key];
|
|
83
92
|
if (value === undefined)
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// nido storage: volumes in Cloud Storage that live independently of
|
|
2
|
+
// sandboxes. Files go straight to Cloud Storage; the API only hands out an
|
|
3
|
+
// upload URL per file.
|
|
4
|
+
import { existsSync, statSync } from "node:fs";
|
|
5
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
6
|
+
import { Api, putFile } from "./api.js";
|
|
7
|
+
import { excludeMatcher, listFiles } from "./files.js";
|
|
8
|
+
import { isVolumeName } from "./manifest.js";
|
|
9
|
+
const say = (text) => void process.stderr.write(text + "\n");
|
|
10
|
+
const BATCH = 500;
|
|
11
|
+
const PARALLEL = 8;
|
|
12
|
+
export function checkVolumeName(name) {
|
|
13
|
+
if (!isVolumeName(name))
|
|
14
|
+
throw new Error(`"${name}" is not a valid storage name: lowercase letters, digits and dashes, at most 40 characters`);
|
|
15
|
+
return name;
|
|
16
|
+
}
|
|
17
|
+
/** Local files to upload: a folder's contents, or one file. Paths use "/". */
|
|
18
|
+
export function localFiles(source) {
|
|
19
|
+
const full = resolve(source);
|
|
20
|
+
if (!existsSync(full))
|
|
21
|
+
throw new Error(`${source} does not exist`);
|
|
22
|
+
if (statSync(full).isFile())
|
|
23
|
+
return { root: dirname(full), files: [{ path: basename(full), size: statSync(full).size }] };
|
|
24
|
+
const paths = listFiles(full, excludeMatcher([]));
|
|
25
|
+
return { root: full, files: paths.map((path) => ({ path, size: statSync(join(full, path)).size })) };
|
|
26
|
+
}
|
|
27
|
+
/** nido storage put <folder|file> <name> */
|
|
28
|
+
export async function storagePut(source, name) {
|
|
29
|
+
checkVolumeName(name);
|
|
30
|
+
const { root, files } = localFiles(source);
|
|
31
|
+
if (!files.length)
|
|
32
|
+
throw new Error(`${source} has no files`);
|
|
33
|
+
const api = Api.fromConfig();
|
|
34
|
+
if ((await api.storageCreate(name)) === "created")
|
|
35
|
+
say(`Created storage ${name}`);
|
|
36
|
+
const total = files.reduce((sum, f) => sum + f.size, 0);
|
|
37
|
+
say(`Uploading ${files.length} file${files.length === 1 ? "" : "s"} (${size(total)}) to ${name}…`);
|
|
38
|
+
let done = 0;
|
|
39
|
+
let sent = 0;
|
|
40
|
+
const started = Date.now();
|
|
41
|
+
const progress = () => {
|
|
42
|
+
if (process.stderr.isTTY)
|
|
43
|
+
process.stderr.write(`\r ${done}/${files.length} files, ${size(sent)} of ${size(total)} `);
|
|
44
|
+
};
|
|
45
|
+
for (let i = 0; i < files.length; i += BATCH) {
|
|
46
|
+
const batch = files.slice(i, i + BATCH);
|
|
47
|
+
const queue = await api.storageUploads(name, batch);
|
|
48
|
+
const bySize = new Map(batch.map((f) => [f.path, f.size]));
|
|
49
|
+
const worker = async () => {
|
|
50
|
+
for (let item = queue.shift(); item; item = queue.shift()) {
|
|
51
|
+
const bytes = bySize.get(item.path);
|
|
52
|
+
await uploadWithRetry(api, name, item, join(root, item.path), bytes);
|
|
53
|
+
done += 1;
|
|
54
|
+
sent += bytes;
|
|
55
|
+
progress();
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
await Promise.all(Array.from({ length: PARALLEL }, worker));
|
|
59
|
+
}
|
|
60
|
+
if (process.stderr.isTTY)
|
|
61
|
+
process.stderr.write("\n");
|
|
62
|
+
const seconds = (Date.now() - started) / 1000;
|
|
63
|
+
say(`Done in ${seconds.toFixed(1)} s. Sandboxes see it at /data/${name} with "storage": ["${name}"] in nido.json`);
|
|
64
|
+
}
|
|
65
|
+
async function uploadWithRetry(api, name, item, file, bytes) {
|
|
66
|
+
let url = item.url;
|
|
67
|
+
for (let attempt = 1;; attempt++) {
|
|
68
|
+
try {
|
|
69
|
+
return await putFile(url, file, bytes);
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
if (attempt === 3)
|
|
73
|
+
throw new Error(`${item.path}: ${err.message}`);
|
|
74
|
+
// A failed session cannot take the whole file again; ask for a new one.
|
|
75
|
+
url = (await api.storageUploads(name, [{ path: item.path, size: bytes }]))[0].url;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** nido storage ls [name] */
|
|
80
|
+
export async function storageLs(name) {
|
|
81
|
+
const api = Api.fromConfig();
|
|
82
|
+
if (name) {
|
|
83
|
+
const volume = await api.storageFiles(checkVolumeName(name));
|
|
84
|
+
for (const f of volume.files)
|
|
85
|
+
process.stdout.write(`${size(f.size).padStart(10)} ${f.path}\n`);
|
|
86
|
+
if (volume.total_files > volume.files.length)
|
|
87
|
+
say(`… ${volume.total_files - volume.files.length} more files`);
|
|
88
|
+
say(`${volume.total_files} files, ${size(volume.bytes)}`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const { volumes, limit_bytes } = await api.storageList();
|
|
92
|
+
if (!volumes.length)
|
|
93
|
+
return say("No storage yet. Upload a folder with: nido storage put ./data my-data");
|
|
94
|
+
const width = Math.max(4, ...volumes.map((v) => v.name.length));
|
|
95
|
+
process.stdout.write(`${"NAME".padEnd(width)} ${"FILES".padStart(7)} ${"SIZE".padStart(10)}\n`);
|
|
96
|
+
for (const v of volumes)
|
|
97
|
+
process.stdout.write(`${v.name.padEnd(width)} ${String(v.files).padStart(7)} ${size(v.bytes).padStart(10)}\n`);
|
|
98
|
+
const used = volumes.reduce((sum, v) => sum + v.bytes, 0);
|
|
99
|
+
say(`${size(used)} of ${size(limit_bytes)} used`);
|
|
100
|
+
}
|
|
101
|
+
/** nido storage rm <name> */
|
|
102
|
+
export async function storageRm(name) {
|
|
103
|
+
await Api.fromConfig().storageRemove(checkVolumeName(name));
|
|
104
|
+
say(`Removed storage ${name}`);
|
|
105
|
+
}
|
|
106
|
+
export function size(bytes) {
|
|
107
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
108
|
+
let value = bytes;
|
|
109
|
+
let unit = 0;
|
|
110
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
111
|
+
value /= 1024;
|
|
112
|
+
unit++;
|
|
113
|
+
}
|
|
114
|
+
return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`;
|
|
115
|
+
}
|