@pirol/nido 0.3.0 → 0.5.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 +24 -2
- package/dist/api.js +27 -5
- package/dist/cli.js +32 -7
- package/dist/commands.js +181 -21
- package/dist/manifest.js +8 -1
- package/dist/storage.js +2 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -76,6 +76,7 @@ Instead of an agent, run any command. It needs no key:
|
|
|
76
76
|
| `secrets` | Names of stored secrets, never values |
|
|
77
77
|
| `network` | Hosts the sandbox may reach on port 443; everything else is blocked |
|
|
78
78
|
| `storage` | Storage volumes, mounted at `/data/<name>` |
|
|
79
|
+
| `from` | Template a new sandbox starts from (`nido save`) |
|
|
79
80
|
| `output` | Folder that comes back after the run; default `output/` |
|
|
80
81
|
|
|
81
82
|
## Storage
|
|
@@ -83,7 +84,7 @@ Instead of an agent, run any command. It needs no key:
|
|
|
83
84
|
Keep large data in storage, independently of sandboxes:
|
|
84
85
|
|
|
85
86
|
```bash
|
|
86
|
-
nido storage put ./data my-data # upload straight to
|
|
87
|
+
nido storage put ./data my-data # upload straight to storage in Frankfurt
|
|
87
88
|
nido storage ls # volumes with file count and size
|
|
88
89
|
nido storage ls my-data # files in one volume
|
|
89
90
|
nido storage rm my-data # only once no sandbox uses it
|
|
@@ -93,6 +94,27 @@ Add `"storage": ["my-data"]` to `nido.json` (or `nido create shop --storage my-d
|
|
|
93
94
|
sandbox reads and writes the data at `/data/my-data`, without copying. Volumes are attached when a
|
|
94
95
|
sandbox is created; after changing `storage`, run `nido run --fresh`.
|
|
95
96
|
|
|
97
|
+
## Backups and templates
|
|
98
|
+
|
|
99
|
+
Storage is safe: it lives in Frankfurt, independently of any sandbox. A sandbox's own folder
|
|
100
|
+
`/sandbox` is fast but local, and can be lost, for example when the machine it runs on is
|
|
101
|
+
replaced. nido backs it up to your storage whenever the sandbox stops.
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
nido stop shop # backs up /sandbox, then stops (--no-backup to skip)
|
|
105
|
+
nido restore shop # last backup back into a running sandbox
|
|
106
|
+
nido save shop base # save the work as template "base"
|
|
107
|
+
nido create other --from base # or "from": "base" in nido.json
|
|
108
|
+
nido fork shop experiment # new sandbox with the work of a running one
|
|
109
|
+
nido save ls
|
|
110
|
+
nido save rm base
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
A backup holds only what changed since the sandbox was created. If the folder was lost, `nido start`
|
|
114
|
+
restores the last backup by itself. Backups count as storage.
|
|
115
|
+
|
|
116
|
+
`nido rm` keeps the backup, and `nido ls` lists it. `nido rm <name> --backup` deletes it too.
|
|
117
|
+
|
|
96
118
|
## Secrets
|
|
97
119
|
|
|
98
120
|
```bash
|
|
@@ -129,7 +151,7 @@ Paths inside a sandbox must be below `/sandbox`.
|
|
|
129
151
|
- No interactive shell yet; use `nido exec`.
|
|
130
152
|
- The agent runs in the foreground. If the connection drops, `nido pull` fetches what is already
|
|
131
153
|
in `output/`.
|
|
132
|
-
-
|
|
154
|
+
- A backup holds at most 5 GB, and an account keeps up to 20 templates. Keep larger data in storage.
|
|
133
155
|
|
|
134
156
|
## Development
|
|
135
157
|
|
package/dist/api.js
CHANGED
|
@@ -69,11 +69,33 @@ export class Api {
|
|
|
69
69
|
create(spec) {
|
|
70
70
|
return this.json("POST", "/v1/sandboxes", spec);
|
|
71
71
|
}
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
/** The backup stays in storage and comes back, unless deleteBackup is set. */
|
|
73
|
+
async remove(name, opts = {}) {
|
|
74
|
+
const query = opts.deleteBackup ? "?backup=delete" : "";
|
|
75
|
+
const res = await this.json("DELETE", `/v1/sandboxes/${encodeURIComponent(name)}${query}`);
|
|
76
|
+
return res?.backup ?? null;
|
|
74
77
|
}
|
|
75
|
-
|
|
76
|
-
|
|
78
|
+
/** All backups, those of removed sandboxes included. */
|
|
79
|
+
async backups() {
|
|
80
|
+
return (await this.json("GET", "/v1/backups")).backups;
|
|
81
|
+
}
|
|
82
|
+
/** Backs /sandbox up into storage first, unless backup is false. */
|
|
83
|
+
async stop(name, opts = {}) {
|
|
84
|
+
const query = opts.backup === false ? "?backup=false" : "";
|
|
85
|
+
const res = await this.json("POST", `/v1/sandboxes/${encodeURIComponent(name)}/stop${query}`);
|
|
86
|
+
return res?.backup ?? null;
|
|
87
|
+
}
|
|
88
|
+
async restore(name) {
|
|
89
|
+
return (await this.json("POST", `/v1/sandboxes/${encodeURIComponent(name)}/restore`)).restored;
|
|
90
|
+
}
|
|
91
|
+
async templates() {
|
|
92
|
+
return (await this.json("GET", "/v1/templates")).templates;
|
|
93
|
+
}
|
|
94
|
+
saveTemplate(name, sandbox) {
|
|
95
|
+
return this.json("PUT", `/v1/templates/${encodeURIComponent(name)}`, { sandbox });
|
|
96
|
+
}
|
|
97
|
+
removeTemplate(name) {
|
|
98
|
+
return this.json("DELETE", `/v1/templates/${encodeURIComponent(name)}`);
|
|
77
99
|
}
|
|
78
100
|
start(name) {
|
|
79
101
|
return this.json("POST", `/v1/sandboxes/${encodeURIComponent(name)}/start`);
|
|
@@ -171,7 +193,7 @@ export class Api {
|
|
|
171
193
|
return this.json("DELETE", `/v1/storage/${encodeURIComponent(name)}`);
|
|
172
194
|
}
|
|
173
195
|
}
|
|
174
|
-
/** Sends a local file to an upload URL from storageUploads (straight to
|
|
196
|
+
/** Sends a local file to an upload URL from storageUploads (straight to the storage). */
|
|
175
197
|
export function putFile(url, file, size) {
|
|
176
198
|
return new Promise((resolve, reject) => {
|
|
177
199
|
const req = request(url, { method: "PUT", headers: { "content-length": size } }, (res) => {
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync, realpathSync } from "node:fs";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { copy, createSandbox, exec, init, list, login, logout, pull, runFolder, secretList, secretRemove, secretSet, simple, whoami, } from "./commands.js";
|
|
4
|
+
import { copy, createSandbox, exec, fork, init, list, login, logout, pull, remove, restore, runFolder, save, secretList, secretRemove, secretSet, simple, whoami, } from "./commands.js";
|
|
5
5
|
import { storageLs, storagePut, storageRm } from "./storage.js";
|
|
6
6
|
const HELP = `nido — run agents in isolated sandboxes (closed beta)
|
|
7
7
|
|
|
@@ -15,15 +15,23 @@ Folder workflow
|
|
|
15
15
|
nido pull bring output/ back again
|
|
16
16
|
|
|
17
17
|
Sandboxes
|
|
18
|
-
nido create <name> [--image <ref>] [--secret <name>]… [--network <host>]… [--storage <name>]…
|
|
18
|
+
nido create <name> [--image <ref>] [--secret <name>]… [--network <host>]… [--storage <name>]… [--gpu l4|h100] [--from <template>]
|
|
19
19
|
nido ls list your sandboxes
|
|
20
20
|
nido exec <name> -- <cmd>… run a command
|
|
21
21
|
nido cp <src> <dst> copy files; write the sandbox side as <name>:<path>
|
|
22
22
|
nido logs <name> sandbox logs
|
|
23
|
-
nido stop
|
|
23
|
+
nido stop <name> [--no-backup] stop; backs /sandbox up to your storage first
|
|
24
|
+
nido start <name>
|
|
25
|
+
nido rm <name> [--backup] remove; the backup stays unless --backup
|
|
26
|
+
nido restore <name> put the last backup back into a running sandbox
|
|
27
|
+
nido fork <from> <new> new sandbox with the work of a running one
|
|
28
|
+
|
|
29
|
+
Templates (a sandbox's work, saved in your storage)
|
|
30
|
+
nido save <sandbox> <template>
|
|
31
|
+
nido save ls | nido save rm <template>
|
|
24
32
|
|
|
25
33
|
Storage (lives on without sandboxes, mounted at /data/<name>)
|
|
26
|
-
nido storage put <folder|file> <name> upload, straight to
|
|
34
|
+
nido storage put <folder|file> <name> upload, straight to storage
|
|
27
35
|
nido storage ls [name]
|
|
28
36
|
nido storage rm <name>
|
|
29
37
|
|
|
@@ -76,6 +84,8 @@ async function main(argv) {
|
|
|
76
84
|
secrets: flags.get("secret") ?? [],
|
|
77
85
|
network: flags.get("network") ?? [],
|
|
78
86
|
storage: flags.get("storage") ?? [],
|
|
87
|
+
gpu: flags.get("gpu")?.[0]?.toLowerCase(),
|
|
88
|
+
from: flags.get("from")?.[0],
|
|
79
89
|
});
|
|
80
90
|
return 0;
|
|
81
91
|
case "ls":
|
|
@@ -87,12 +97,27 @@ async function main(argv) {
|
|
|
87
97
|
return exec(one("name"), passthrough);
|
|
88
98
|
case "shell":
|
|
89
99
|
throw new Error(`nido shell is not in the beta yet. Use: nido exec ${args[0] ?? "<name>"} -- <command>`);
|
|
90
|
-
case "logs":
|
|
91
100
|
case "stop":
|
|
101
|
+
await simple("stop", one("name"), { backup: !flags.has("no-backup") });
|
|
102
|
+
return 0;
|
|
103
|
+
case "logs":
|
|
92
104
|
case "start":
|
|
93
|
-
case "rm":
|
|
94
105
|
await simple(cmd, one("name"));
|
|
95
106
|
return 0;
|
|
107
|
+
case "rm":
|
|
108
|
+
await remove(one("name"), { backup: flags.has("backup") });
|
|
109
|
+
return 0;
|
|
110
|
+
case "restore":
|
|
111
|
+
await restore(one("name"));
|
|
112
|
+
return 0;
|
|
113
|
+
case "fork":
|
|
114
|
+
if (args.length !== 2)
|
|
115
|
+
throw new Error("Usage: nido fork <from> <new>");
|
|
116
|
+
await fork(args[0], args[1]);
|
|
117
|
+
return 0;
|
|
118
|
+
case "save":
|
|
119
|
+
await save(args);
|
|
120
|
+
return 0;
|
|
96
121
|
case "cp":
|
|
97
122
|
if (args.length !== 2)
|
|
98
123
|
throw new Error("Usage: nido cp <src> <dst> (sandbox side as <name>:<path>)");
|
|
@@ -130,7 +155,7 @@ async function storage(args) {
|
|
|
130
155
|
throw new Error("Usage: nido storage put <folder|file> <name> | nido storage ls [name] | nido storage rm <name>");
|
|
131
156
|
return 0;
|
|
132
157
|
}
|
|
133
|
-
const VALUE_FLAGS = new Set(["image", "secret", "network", "storage"]);
|
|
158
|
+
const VALUE_FLAGS = new Set(["image", "secret", "network", "storage", "gpu", "from"]);
|
|
134
159
|
export function parse(argv) {
|
|
135
160
|
const flags = new Map();
|
|
136
161
|
const args = [];
|
package/dist/commands.js
CHANGED
|
@@ -51,18 +51,106 @@ export function init(dir, kind) {
|
|
|
51
51
|
// ---------------------------------------------------------------- sandboxes
|
|
52
52
|
export async function createSandbox(name, spec) {
|
|
53
53
|
const api = Api.fromConfig();
|
|
54
|
-
|
|
54
|
+
const origin = spec.from ? ` from template ${spec.from}` : spec.fork ? ` as a copy of ${spec.fork}` : "";
|
|
55
|
+
say(`Creating sandbox ${name}${spec.gpu ? ` with a GPU (${spec.gpu})` : ""}${origin}…`);
|
|
56
|
+
const started = Date.now();
|
|
55
57
|
const info = await api.create({ name, ...spec });
|
|
56
|
-
|
|
58
|
+
if (info.phase !== "Ready")
|
|
59
|
+
await waitForGpu(api, name, info.gpu);
|
|
60
|
+
say(`Sandbox ${name} ready in ${((Date.now() - started) / 1000).toFixed(1)} s`);
|
|
61
|
+
if (info.backup) {
|
|
62
|
+
say(`A removed sandbox ${name} left a backup from ${when(info.backup.at)} (${size(info.backup.bytes)}).`);
|
|
63
|
+
say(`Put it in with: nido restore ${name}. The next nido stop replaces it.`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** nido fork: a new sandbox with the changes of a running one. */
|
|
67
|
+
export async function fork(from, to) {
|
|
68
|
+
await createSandbox(to, { secrets: [], network: [], storage: [], fork: from });
|
|
69
|
+
}
|
|
70
|
+
/** How long nido waits for a GPU node before it gives up (the sandbox stays queued). */
|
|
71
|
+
export const GPU_WAIT_MS = 30 * 60_000;
|
|
72
|
+
/**
|
|
73
|
+
* Waits until a sandbox is ready. GPU sandboxes need a node of their own,
|
|
74
|
+
* which takes minutes; an H100 may wait for free capacity. The API answers
|
|
75
|
+
* at once, so nido asks every few seconds and shows the time.
|
|
76
|
+
*/
|
|
77
|
+
export async function waitUntilReady(get, opts) {
|
|
78
|
+
const now = opts.now ?? Date.now;
|
|
79
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
80
|
+
const start = now();
|
|
81
|
+
for (;;) {
|
|
82
|
+
const info = await get();
|
|
83
|
+
if (!info)
|
|
84
|
+
throw new Error("The sandbox is gone");
|
|
85
|
+
if (info.phase === "Ready")
|
|
86
|
+
return info;
|
|
87
|
+
if (info.phase === "Error" || info.phase === "Failed")
|
|
88
|
+
throw new Error(`Sandbox ${info.name} failed to start`);
|
|
89
|
+
const elapsed = now() - start;
|
|
90
|
+
if (elapsed >= opts.timeoutMs)
|
|
91
|
+
throw new TimeoutError(info);
|
|
92
|
+
opts.onTick?.(elapsed);
|
|
93
|
+
await sleep(opts.intervalMs ?? 3000);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
class TimeoutError extends Error {
|
|
97
|
+
info;
|
|
98
|
+
constructor(info) {
|
|
99
|
+
super(`Sandbox ${info.name} is still ${info.phase}`);
|
|
100
|
+
this.info = info;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async function waitForGpu(api, name, gpu = "GPU") {
|
|
104
|
+
const tty = process.stderr.isTTY;
|
|
105
|
+
try {
|
|
106
|
+
await waitUntilReady(() => api.get(name), {
|
|
107
|
+
timeoutMs: GPU_WAIT_MS,
|
|
108
|
+
onTick: (ms) => {
|
|
109
|
+
const line = `Waiting for a free ${gpu.toUpperCase()} node… ${clock(ms)}`;
|
|
110
|
+
if (tty)
|
|
111
|
+
process.stderr.write(`\r${line}`);
|
|
112
|
+
else if (ms % 60_000 < 3000)
|
|
113
|
+
say(line);
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
if (err instanceof TimeoutError) {
|
|
119
|
+
throw new Error(`No ${gpu.toUpperCase()} free after ${GPU_WAIT_MS / 60_000} minutes. ${name} stays queued and starts when one is free. ` +
|
|
120
|
+
`Check with: nido ls. Remove it with: nido rm ${name}`);
|
|
121
|
+
}
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
if (tty)
|
|
126
|
+
process.stderr.write("\r\x1b[K");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function clock(ms) {
|
|
130
|
+
const s = Math.floor(ms / 1000);
|
|
131
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
|
57
132
|
}
|
|
58
133
|
export async function list() {
|
|
59
|
-
const
|
|
134
|
+
const api = Api.fromConfig();
|
|
135
|
+
const [sandboxes, backups] = await Promise.all([api.list(), api.backups()]);
|
|
136
|
+
// Backups stay after nido rm. They cost storage, so they show up here.
|
|
137
|
+
const left = backups.filter((b) => !sandboxes.some((s) => s.name === b.name));
|
|
60
138
|
if (!sandboxes.length)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
139
|
+
say("No sandboxes. Create one with: nido run or nido create <name>");
|
|
140
|
+
else {
|
|
141
|
+
const width = Math.max(4, ...sandboxes.map((s) => s.name.length));
|
|
142
|
+
process.stdout.write(`${"NAME".padEnd(width)} ${"GPU".padEnd(4)} ${"CREATED".padEnd(19)} ${"PHASE".padEnd(9)} LAST BACKUP\n`);
|
|
143
|
+
for (const s of sandboxes) {
|
|
144
|
+
const backup = s.backup ? `${when(s.backup.at)}, ${size(s.backup.bytes)}` : "-";
|
|
145
|
+
process.stdout.write(`${s.name.padEnd(width)} ${(s.gpu ?? "-").padEnd(4)} ${s.created.padEnd(19)} ${s.phase.padEnd(9)} ${backup}\n`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (!left.length)
|
|
149
|
+
return;
|
|
150
|
+
process.stdout.write(`\nBackups of removed sandboxes (delete with: nido rm <name> --backup)\n`);
|
|
151
|
+
const width = Math.max(4, ...left.map((b) => b.name.length));
|
|
152
|
+
for (const b of left)
|
|
153
|
+
process.stdout.write(`${b.name.padEnd(width)} ${when(b.at)}, ${size(b.bytes)}\n`);
|
|
66
154
|
}
|
|
67
155
|
export async function exec(name, command) {
|
|
68
156
|
return Api.fromConfig().exec(name, command, {
|
|
@@ -70,17 +158,90 @@ export async function exec(name, command) {
|
|
|
70
158
|
onErr: (text) => process.stderr.write(text),
|
|
71
159
|
});
|
|
72
160
|
}
|
|
73
|
-
export async function simple(action, name) {
|
|
161
|
+
export async function simple(action, name, opts = {}) {
|
|
74
162
|
const api = Api.fromConfig();
|
|
75
|
-
if (action === "
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
await api.stop(name);
|
|
79
|
-
|
|
80
|
-
|
|
163
|
+
if (action === "stop") {
|
|
164
|
+
if (opts.backup !== false)
|
|
165
|
+
say(`Backing up ${name}…`);
|
|
166
|
+
const backup = await api.stop(name, opts);
|
|
167
|
+
return say(stopMessage(name, backup, opts.backup !== false));
|
|
168
|
+
}
|
|
169
|
+
if (action === "start") {
|
|
170
|
+
const info = await api.start(name);
|
|
171
|
+
if (info.phase !== "Ready")
|
|
172
|
+
await waitForGpu(api, name, info.gpu);
|
|
173
|
+
if (info.restored) {
|
|
174
|
+
say(`The workspace of ${name} was lost: the machine it ran on was replaced.`);
|
|
175
|
+
say(`Restored the backup from ${when(info.restored.at)} (${size(info.restored.bytes)}).`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
81
178
|
if (action === "logs")
|
|
82
179
|
return void process.stdout.write(await api.logs(name));
|
|
83
|
-
say(
|
|
180
|
+
say(`Started ${name}`);
|
|
181
|
+
}
|
|
182
|
+
/** nido rm: the backup stays in storage, unless --backup. */
|
|
183
|
+
export async function remove(name, opts) {
|
|
184
|
+
const kept = await Api.fromConfig().remove(name, { deleteBackup: opts.backup });
|
|
185
|
+
say(removeMessage(name, kept, opts.backup));
|
|
186
|
+
}
|
|
187
|
+
export function removeMessage(name, kept, deleteBackup) {
|
|
188
|
+
if (deleteBackup)
|
|
189
|
+
return `Removed ${name} and its backup`;
|
|
190
|
+
if (!kept)
|
|
191
|
+
return `Removed ${name}`;
|
|
192
|
+
return `Removed ${name}. Its backup from ${when(kept.at)} (${size(kept.bytes)}) stays in your storage. Delete it with: nido rm ${name} --backup`;
|
|
193
|
+
}
|
|
194
|
+
export function stopMessage(name, backup, wanted) {
|
|
195
|
+
if (!wanted)
|
|
196
|
+
return `Stopped ${name}, without a backup`;
|
|
197
|
+
if (!backup)
|
|
198
|
+
return `Stopped ${name}. Nothing changed since the last backup.`;
|
|
199
|
+
return `Stopped ${name}. Backed up ${size(backup.bytes)} to your storage.`;
|
|
200
|
+
}
|
|
201
|
+
/** nido restore: the last backup back into a running sandbox. */
|
|
202
|
+
export async function restore(name) {
|
|
203
|
+
const backup = await Api.fromConfig().restore(name);
|
|
204
|
+
say(`Restored the backup of ${name} from ${when(backup.at)} (${size(backup.bytes)})`);
|
|
205
|
+
}
|
|
206
|
+
/** nido save: templates. */
|
|
207
|
+
export async function save(args) {
|
|
208
|
+
const api = Api.fromConfig();
|
|
209
|
+
const [a, b] = args;
|
|
210
|
+
if (a === "ls" && !b) {
|
|
211
|
+
const templates = await api.templates();
|
|
212
|
+
if (!templates.length)
|
|
213
|
+
return say("No templates. Save one with: nido save <sandbox> <template>");
|
|
214
|
+
const width = Math.max(8, ...templates.map((t) => t.name.length));
|
|
215
|
+
process.stdout.write(`${"TEMPLATE".padEnd(width)} ${"SAVED".padEnd(16)} SIZE\n`);
|
|
216
|
+
for (const t of templates)
|
|
217
|
+
process.stdout.write(`${t.name.padEnd(width)} ${when(t.at).padEnd(16)} ${size(t.bytes)}\n`);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (a === "rm" && b) {
|
|
221
|
+
await api.removeTemplate(b);
|
|
222
|
+
return say(`Removed template ${b}`);
|
|
223
|
+
}
|
|
224
|
+
if (a && b && a !== "ls" && a !== "rm") {
|
|
225
|
+
say(`Saving ${a} as template ${b}…`);
|
|
226
|
+
const t = await api.saveTemplate(b, a);
|
|
227
|
+
return say(`Saved template ${b} (${size(t.bytes)}). Start from it with: nido create <name> --from ${b}`);
|
|
228
|
+
}
|
|
229
|
+
throw new Error("Usage: nido save <sandbox> <template> | nido save ls | nido save rm <template>");
|
|
230
|
+
}
|
|
231
|
+
/** "2026-11-06T14:05:12Z" as "2026-11-06 14:05" in local time. */
|
|
232
|
+
export function when(iso) {
|
|
233
|
+
const d = new Date(iso);
|
|
234
|
+
if (Number.isNaN(d.getTime()))
|
|
235
|
+
return iso;
|
|
236
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
237
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
238
|
+
}
|
|
239
|
+
export function size(bytes) {
|
|
240
|
+
if (bytes < 1024 ** 2)
|
|
241
|
+
return `${Math.max(bytes / 1024, bytes ? 0.1 : 0).toFixed(1)} KB`;
|
|
242
|
+
if (bytes < 1024 ** 3)
|
|
243
|
+
return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
|
244
|
+
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
|
|
84
245
|
}
|
|
85
246
|
/** nido cp: "shop:/sandbox/x" is in the sandbox, anything else is local. */
|
|
86
247
|
export async function copy(src, dst) {
|
|
@@ -163,19 +324,18 @@ async function ensureSandbox(api, m, fresh) {
|
|
|
163
324
|
info = undefined;
|
|
164
325
|
}
|
|
165
326
|
if (!info) {
|
|
166
|
-
|
|
167
|
-
const created = await api.create({
|
|
168
|
-
name: m.name,
|
|
327
|
+
await createSandbox(m.name, {
|
|
169
328
|
image: m.imageSet ? m.image : undefined,
|
|
170
329
|
secrets: m.secrets,
|
|
171
330
|
network: m.network,
|
|
172
331
|
storage: m.storage,
|
|
332
|
+
gpu: m.gpu,
|
|
333
|
+
from: m.from,
|
|
173
334
|
});
|
|
174
|
-
say(`Sandbox ${m.name} ready in ${(created.ready_ms / 1000).toFixed(1)} s`);
|
|
175
335
|
return true;
|
|
176
336
|
}
|
|
177
337
|
if (info.phase !== "Ready")
|
|
178
|
-
await
|
|
338
|
+
await simple("start", m.name);
|
|
179
339
|
return false;
|
|
180
340
|
}
|
|
181
341
|
async function upload(api, dir, m, uploaded) {
|
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", "storage", "output"];
|
|
28
|
+
const known = ["name", "agent", "task", "command", "image", "files", "secrets", "network", "storage", "gpu", "from", "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(", ")}`);
|
|
@@ -70,6 +70,8 @@ export function parseManifest(raw, folderName) {
|
|
|
70
70
|
throw new Error(`${MANIFEST}: "${name}" in "storage" is not a valid name`);
|
|
71
71
|
return name;
|
|
72
72
|
}),
|
|
73
|
+
gpu: optionalString(m, "gpu")?.toLowerCase(),
|
|
74
|
+
from: checkFrom(optionalString(m, "from")),
|
|
73
75
|
output,
|
|
74
76
|
};
|
|
75
77
|
}
|
|
@@ -83,6 +85,11 @@ export function sandboxName(input) {
|
|
|
83
85
|
.replace(/-+$/, "");
|
|
84
86
|
return name || "nest";
|
|
85
87
|
}
|
|
88
|
+
function checkFrom(name) {
|
|
89
|
+
if (name !== undefined && !isVolumeName(name))
|
|
90
|
+
throw new Error(`${MANIFEST}: "${name}" in "from" is not a valid template name`);
|
|
91
|
+
return name;
|
|
92
|
+
}
|
|
86
93
|
/** Storage names, like sandbox names: lowercase letters, digits and dashes, at most 40. */
|
|
87
94
|
export function isVolumeName(name) {
|
|
88
95
|
return /^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$/.test(name);
|
package/dist/storage.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// nido storage: volumes in
|
|
2
|
-
// sandboxes. Files go straight to
|
|
1
|
+
// nido storage: volumes in object storage that live independently of
|
|
2
|
+
// sandboxes. Files go straight to the storage; the API only hands out an
|
|
3
3
|
// upload URL per file.
|
|
4
4
|
import { existsSync, statSync } from "node:fs";
|
|
5
5
|
import { basename, dirname, join, resolve } from "node:path";
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pirol/nido",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Run agents in isolated sandboxes
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Run coding agents and commands in isolated sandboxes in Frankfurt, with the nido CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"nido": "dist/cli.js"
|