@pirol/nido 0.0.0-stage → 0.2.1
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 +126 -2
- package/dist/agents.js +52 -0
- package/dist/api.js +155 -0
- package/dist/cli.js +155 -0
- package/dist/commands.js +317 -0
- package/dist/config.js +35 -0
- package/dist/files.js +76 -0
- package/dist/login.js +145 -0
- package/dist/manifest.js +113 -0
- package/dist/paths.js +6 -0
- package/dist/run.js +12 -0
- package/dist/secrets.js +30 -0
- package/dist/sync.js +56 -0
- package/package.json +37 -4
package/README.md
CHANGED
|
@@ -1,3 +1,127 @@
|
|
|
1
|
-
#
|
|
1
|
+
# nido
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
5
|
+
|
|
6
|
+
Every sandbox is its own small virtual machine (Kata Containers) in Frankfurt, run by
|
|
7
|
+
[Pirol](https://pirol.ai).
|
|
8
|
+
|
|
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).
|
|
11
|
+
|
|
12
|
+
Full documentation: [pirol.ai/docs/sandboxes](https://pirol.ai/docs/sandboxes)
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
Requires Node 20 or newer.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install -g @pirol/nido
|
|
20
|
+
nido login
|
|
21
|
+
```
|
|
22
|
+
|
|
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.
|
|
26
|
+
|
|
27
|
+
## Run an agent on a folder
|
|
28
|
+
|
|
29
|
+
```bash
|
|
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
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`nido run`:
|
|
37
|
+
|
|
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.
|
|
48
|
+
|
|
49
|
+
### nido.json
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"agent": "claude",
|
|
54
|
+
"task": "Analyze results.csv and write a report to output/report.md",
|
|
55
|
+
"files": { "exclude": ["node_modules", ".venv", "*.log"] },
|
|
56
|
+
"secrets": ["anthropic"],
|
|
57
|
+
"network": ["pypi.org", "files.pythonhosted.org"],
|
|
58
|
+
"output": "output/"
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Instead of an agent, run any command. It needs no key:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{ "command": "python analyze.py", "output": "output/" }
|
|
66
|
+
```
|
|
67
|
+
|
|
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
|
+
| `output` | Folder that comes back after the run; default `output/` |
|
|
79
|
+
|
|
80
|
+
## Secrets
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
nido secret set anthropic # Anthropic API key
|
|
84
|
+
nido secret set github --from-gh # token from the GitHub CLI
|
|
85
|
+
nido secret set openai
|
|
86
|
+
nido secret ls
|
|
87
|
+
nido secret rm github
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
A secret is stored once and can never be read again, not even by you. Inside the sandbox the agent
|
|
91
|
+
only sees a placeholder; the real value is added on the way to the provider's API.
|
|
92
|
+
|
|
93
|
+
## More commands
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
nido create shop --secret anthropic --network pypi.org
|
|
97
|
+
nido ls
|
|
98
|
+
nido exec shop -- ls -la /sandbox
|
|
99
|
+
nido cp ./data shop:/sandbox/data
|
|
100
|
+
nido cp shop:/sandbox/work/output ./out
|
|
101
|
+
nido logs shop
|
|
102
|
+
nido stop shop && nido start shop
|
|
103
|
+
nido rm shop
|
|
104
|
+
nido whoami
|
|
105
|
+
nido logout
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Paths inside a sandbox must be below `/sandbox`.
|
|
109
|
+
|
|
110
|
+
## Limits in the beta
|
|
111
|
+
|
|
112
|
+
- Up to 5 sandboxes per account.
|
|
113
|
+
- No interactive shell yet; use `nido exec`.
|
|
114
|
+
- The agent runs in the foreground. If the connection drops, `nido pull` fetches what is already
|
|
115
|
+
in `output/`.
|
|
116
|
+
- No persistent workspaces or backups yet. Keep anything important in `output/`.
|
|
117
|
+
|
|
118
|
+
## Development
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
cd nido && npm install && npm run build && npm link
|
|
122
|
+
npm test
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`NIDO_API_URL` points nido at another API, for example a local agents-api. Upload and
|
|
126
|
+
download rules live in `src/sync.ts` as pure functions, API calls in `src/api.ts`, login in
|
|
127
|
+
`src/login.ts`. Publish with `npm publish` in this folder; it builds and tests first.
|
package/dist/agents.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Working folder in the sandbox. The local folder ends up here. */
|
|
2
|
+
export const WORKDIR = "/sandbox/work";
|
|
3
|
+
/** The command that runs in the sandbox for a manifest. */
|
|
4
|
+
export function agentCommand(m) {
|
|
5
|
+
if (m.agent === "claude") {
|
|
6
|
+
const task = `${m.task}\n\nPut every file meant for the user into ${m.output}/ (relative to the current folder).`;
|
|
7
|
+
return ["claude", "-p", task, "--dangerously-skip-permissions", "--output-format", "stream-json", "--verbose"];
|
|
8
|
+
}
|
|
9
|
+
if (typeof m.command === "string")
|
|
10
|
+
return ["bash", "-lc", m.command];
|
|
11
|
+
if (Array.isArray(m.command))
|
|
12
|
+
return m.command;
|
|
13
|
+
throw new Error("Nothing to run");
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Claude Code's stream-json output, shortened to one line per step.
|
|
17
|
+
* Returns undefined for events that are not worth showing.
|
|
18
|
+
*/
|
|
19
|
+
export function formatClaudeEvent(line) {
|
|
20
|
+
let event;
|
|
21
|
+
try {
|
|
22
|
+
event = JSON.parse(line);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return line.trim() ? line : undefined;
|
|
26
|
+
}
|
|
27
|
+
if (event.type === "assistant") {
|
|
28
|
+
const parts = [];
|
|
29
|
+
for (const item of event.message?.content ?? []) {
|
|
30
|
+
if (item.type === "text" && item.text?.trim())
|
|
31
|
+
parts.push(item.text.trim());
|
|
32
|
+
if (item.type === "tool_use")
|
|
33
|
+
parts.push(`▸ ${item.name} ${summarizeInput(item.input)}`.trimEnd());
|
|
34
|
+
}
|
|
35
|
+
return parts.length ? parts.join("\n") : undefined;
|
|
36
|
+
}
|
|
37
|
+
if (event.type === "result") {
|
|
38
|
+
const seconds = event.duration_ms ? `${Math.round(event.duration_ms / 1000)} s` : "";
|
|
39
|
+
const cost = typeof event.total_cost_usd === "number" ? `$${event.total_cost_usd.toFixed(2)}` : "";
|
|
40
|
+
const status = event.is_error ? "failed" : "done";
|
|
41
|
+
return `■ ${status}${seconds || cost ? ` (${[seconds, cost].filter(Boolean).join(", ")})` : ""}`;
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
function summarizeInput(input) {
|
|
46
|
+
if (!input)
|
|
47
|
+
return "";
|
|
48
|
+
const value = input.command ?? input.file_path ?? input.path ?? input.pattern ?? input.url ?? input.description;
|
|
49
|
+
const text = typeof value === "string" ? value : "";
|
|
50
|
+
const oneLine = text.replace(/\s+/g, " ").trim();
|
|
51
|
+
return oneLine.length > 100 ? `${oneLine.slice(0, 97)}…` : oneLine;
|
|
52
|
+
}
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Client for pirol-agents-api. Files travel as tar.gz streams, command
|
|
2
|
+
// output comes back as NDJSON while the command runs.
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { Readable } from "node:stream";
|
|
5
|
+
import { loadConfig } from "./config.js";
|
|
6
|
+
export class ApiError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
constructor(status, message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.status = status;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export class Api {
|
|
14
|
+
base;
|
|
15
|
+
key;
|
|
16
|
+
constructor(base, key) {
|
|
17
|
+
this.base = base;
|
|
18
|
+
this.key = key;
|
|
19
|
+
}
|
|
20
|
+
static fromConfig() {
|
|
21
|
+
const config = loadConfig();
|
|
22
|
+
if (!config.key)
|
|
23
|
+
throw new Error("Not logged in. Run: nido login");
|
|
24
|
+
return new Api(config.api, config.key);
|
|
25
|
+
}
|
|
26
|
+
async call(method, path, init = {}) {
|
|
27
|
+
let res;
|
|
28
|
+
try {
|
|
29
|
+
res = await fetch(`${this.base}${path}`, {
|
|
30
|
+
...init,
|
|
31
|
+
method,
|
|
32
|
+
headers: { authorization: `Bearer ${this.key}`, ...(init.headers ?? {}) },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
throw new Error(`Cannot reach ${this.base}: ${err.message}`);
|
|
37
|
+
}
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
const body = (await res.json().catch(() => ({})));
|
|
40
|
+
throw new ApiError(res.status, body.error ?? `HTTP ${res.status}`);
|
|
41
|
+
}
|
|
42
|
+
return res;
|
|
43
|
+
}
|
|
44
|
+
async json(method, path, body) {
|
|
45
|
+
const res = await this.call(method, path, {
|
|
46
|
+
headers: body === undefined ? {} : { "content-type": "application/json" },
|
|
47
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
48
|
+
});
|
|
49
|
+
return (res.status === 204 ? undefined : await res.json());
|
|
50
|
+
}
|
|
51
|
+
whoami() {
|
|
52
|
+
return this.json("GET", "/v1/whoami");
|
|
53
|
+
}
|
|
54
|
+
async list() {
|
|
55
|
+
return (await this.json("GET", "/v1/sandboxes")).sandboxes;
|
|
56
|
+
}
|
|
57
|
+
async get(name) {
|
|
58
|
+
try {
|
|
59
|
+
return await this.json("GET", `/v1/sandboxes/${encodeURIComponent(name)}`);
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
if (err instanceof ApiError && err.status === 404)
|
|
63
|
+
return undefined;
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
create(spec) {
|
|
68
|
+
return this.json("POST", "/v1/sandboxes", spec);
|
|
69
|
+
}
|
|
70
|
+
remove(name) {
|
|
71
|
+
return this.json("DELETE", `/v1/sandboxes/${encodeURIComponent(name)}`);
|
|
72
|
+
}
|
|
73
|
+
stop(name) {
|
|
74
|
+
return this.json("POST", `/v1/sandboxes/${encodeURIComponent(name)}/stop`);
|
|
75
|
+
}
|
|
76
|
+
start(name) {
|
|
77
|
+
return this.json("POST", `/v1/sandboxes/${encodeURIComponent(name)}/start`);
|
|
78
|
+
}
|
|
79
|
+
async logs(name, lines = 200) {
|
|
80
|
+
return (await this.call("GET", `/v1/sandboxes/${encodeURIComponent(name)}/logs?lines=${lines}`)).text();
|
|
81
|
+
}
|
|
82
|
+
/** Runs a command; onOut/onErr get output as it arrives. Returns the exit code. */
|
|
83
|
+
async exec(name, command, opts) {
|
|
84
|
+
const res = await this.call("POST", `/v1/sandboxes/${encodeURIComponent(name)}/exec`, {
|
|
85
|
+
headers: { "content-type": "application/json" },
|
|
86
|
+
body: JSON.stringify({ command, workdir: opts.workdir, stdin: opts.stdin }),
|
|
87
|
+
});
|
|
88
|
+
let exit = 1;
|
|
89
|
+
let partial = "";
|
|
90
|
+
const decoder = new TextDecoder();
|
|
91
|
+
for await (const chunk of res.body) {
|
|
92
|
+
const lines = (partial + decoder.decode(chunk, { stream: true })).split("\n");
|
|
93
|
+
partial = lines.pop() ?? "";
|
|
94
|
+
for (const line of lines) {
|
|
95
|
+
if (!line)
|
|
96
|
+
continue;
|
|
97
|
+
const event = JSON.parse(line);
|
|
98
|
+
if (event.o !== undefined)
|
|
99
|
+
opts.onOut(event.o);
|
|
100
|
+
if (event.e !== undefined)
|
|
101
|
+
opts.onErr(event.e);
|
|
102
|
+
if (event.exit !== undefined)
|
|
103
|
+
exit = event.exit;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return exit;
|
|
107
|
+
}
|
|
108
|
+
/** Uploads the contents of a local folder into `path` (merging). */
|
|
109
|
+
async upload(name, localDir, path) {
|
|
110
|
+
// COPYFILE_DISABLE: macOS tar would add a ._ file with metadata for every file.
|
|
111
|
+
const tar = spawn("tar", ["-czf", "-", "-C", localDir, "."], {
|
|
112
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
113
|
+
env: { ...process.env, COPYFILE_DISABLE: "1" },
|
|
114
|
+
});
|
|
115
|
+
let stderr = "";
|
|
116
|
+
tar.stderr.on("data", (d) => (stderr += d.toString("utf8")));
|
|
117
|
+
const done = new Promise((resolve, reject) => tar.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`tar failed: ${stderr.trim()}`)))));
|
|
118
|
+
await this.call("PUT", `/v1/sandboxes/${encodeURIComponent(name)}/files?path=${encodeURIComponent(path)}`, {
|
|
119
|
+
headers: { "content-type": "application/gzip" },
|
|
120
|
+
body: Readable.toWeb(tar.stdout),
|
|
121
|
+
// @ts-expect-error Node's fetch needs this for streamed request bodies.
|
|
122
|
+
duplex: "half",
|
|
123
|
+
});
|
|
124
|
+
await done;
|
|
125
|
+
}
|
|
126
|
+
/** Downloads `path` into a local folder. Returns false if it does not exist. */
|
|
127
|
+
async download(name, path, localDir) {
|
|
128
|
+
let res;
|
|
129
|
+
try {
|
|
130
|
+
res = await this.call("GET", `/v1/sandboxes/${encodeURIComponent(name)}/files?path=${encodeURIComponent(path)}`);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
if (err instanceof ApiError && err.status === 404)
|
|
134
|
+
return false;
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
await new Promise((resolve, reject) => {
|
|
138
|
+
const tar = spawn("tar", ["-xzf", "-", "-C", localDir], { stdio: ["pipe", "ignore", "pipe"] });
|
|
139
|
+
let stderr = "";
|
|
140
|
+
tar.stderr.on("data", (d) => (stderr += d.toString("utf8")));
|
|
141
|
+
tar.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`tar failed: ${stderr.trim()}`))));
|
|
142
|
+
Readable.fromWeb(res.body).pipe(tar.stdin);
|
|
143
|
+
});
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
async secrets() {
|
|
147
|
+
return (await this.json("GET", "/v1/secrets")).secrets;
|
|
148
|
+
}
|
|
149
|
+
async setSecret(name, value) {
|
|
150
|
+
return (await this.json("PUT", `/v1/secrets/${encodeURIComponent(name)}`, { value })).result;
|
|
151
|
+
}
|
|
152
|
+
removeSecret(name) {
|
|
153
|
+
return this.json("DELETE", `/v1/secrets/${encodeURIComponent(name)}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
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";
|
|
5
|
+
const HELP = `nido — run agents in isolated sandboxes (closed beta)
|
|
6
|
+
|
|
7
|
+
Start
|
|
8
|
+
nido login [--manual] log in with your Pirol account
|
|
9
|
+
nido whoami | nido logout
|
|
10
|
+
|
|
11
|
+
Folder workflow
|
|
12
|
+
nido init [--command] write a nido.json into this folder
|
|
13
|
+
nido run [--fresh] upload the folder, run the agent, bring output/ back
|
|
14
|
+
nido pull bring output/ back again
|
|
15
|
+
|
|
16
|
+
Sandboxes
|
|
17
|
+
nido create <name> [--image <ref>] [--secret <name>]… [--network <host>]…
|
|
18
|
+
nido ls list your sandboxes
|
|
19
|
+
nido exec <name> -- <cmd>… run a command
|
|
20
|
+
nido cp <src> <dst> copy files; write the sandbox side as <name>:<path>
|
|
21
|
+
nido logs <name> sandbox logs
|
|
22
|
+
nido stop|start|rm <name>
|
|
23
|
+
|
|
24
|
+
Secrets (stored once, never readable again)
|
|
25
|
+
nido secret set <name> [--from-gh] anthropic, openai, github
|
|
26
|
+
nido secret ls
|
|
27
|
+
nido secret rm <name>
|
|
28
|
+
|
|
29
|
+
Docs: https://pirol.ai/docs/sandboxes
|
|
30
|
+
`;
|
|
31
|
+
async function main(argv) {
|
|
32
|
+
const [cmd, ...rest] = argv;
|
|
33
|
+
const { flags, args, passthrough } = parse(rest);
|
|
34
|
+
const one = (what) => {
|
|
35
|
+
if (!args[0])
|
|
36
|
+
throw new Error(`Usage: nido ${cmd} <${what}>`);
|
|
37
|
+
return args[0];
|
|
38
|
+
};
|
|
39
|
+
switch (cmd) {
|
|
40
|
+
case undefined:
|
|
41
|
+
case "help":
|
|
42
|
+
case "--help":
|
|
43
|
+
case "-h":
|
|
44
|
+
process.stdout.write(HELP);
|
|
45
|
+
return 0;
|
|
46
|
+
case "version":
|
|
47
|
+
case "--version":
|
|
48
|
+
process.stdout.write(`nido ${version()}\n`);
|
|
49
|
+
return 0;
|
|
50
|
+
case "login":
|
|
51
|
+
await login({ manual: flags.has("manual") });
|
|
52
|
+
return 0;
|
|
53
|
+
case "whoami":
|
|
54
|
+
await whoami();
|
|
55
|
+
return 0;
|
|
56
|
+
case "logout":
|
|
57
|
+
logout();
|
|
58
|
+
return 0;
|
|
59
|
+
case "init":
|
|
60
|
+
init(process.cwd(), flags.has("command") ? "command" : "claude");
|
|
61
|
+
return 0;
|
|
62
|
+
case "run":
|
|
63
|
+
return runFolder(process.cwd(), { fresh: flags.has("fresh") });
|
|
64
|
+
case "pull":
|
|
65
|
+
await pull(process.cwd());
|
|
66
|
+
return 0;
|
|
67
|
+
case "create":
|
|
68
|
+
await createSandbox(one("name"), {
|
|
69
|
+
image: flags.get("image")?.[0],
|
|
70
|
+
secrets: flags.get("secret") ?? [],
|
|
71
|
+
network: flags.get("network") ?? [],
|
|
72
|
+
});
|
|
73
|
+
return 0;
|
|
74
|
+
case "ls":
|
|
75
|
+
await list();
|
|
76
|
+
return 0;
|
|
77
|
+
case "exec":
|
|
78
|
+
if (!passthrough.length)
|
|
79
|
+
throw new Error("Usage: nido exec <name> -- <command>…");
|
|
80
|
+
return exec(one("name"), passthrough);
|
|
81
|
+
case "shell":
|
|
82
|
+
throw new Error(`nido shell is not in the beta yet. Use: nido exec ${args[0] ?? "<name>"} -- <command>`);
|
|
83
|
+
case "logs":
|
|
84
|
+
case "stop":
|
|
85
|
+
case "start":
|
|
86
|
+
case "rm":
|
|
87
|
+
await simple(cmd, one("name"));
|
|
88
|
+
return 0;
|
|
89
|
+
case "cp":
|
|
90
|
+
if (args.length !== 2)
|
|
91
|
+
throw new Error("Usage: nido cp <src> <dst> (sandbox side as <name>:<path>)");
|
|
92
|
+
await copy(args[0], args[1]);
|
|
93
|
+
return 0;
|
|
94
|
+
case "secret":
|
|
95
|
+
return secret(args, flags);
|
|
96
|
+
default:
|
|
97
|
+
throw new Error(`Unknown command "${cmd}". See: nido help`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function secret(args, flags) {
|
|
101
|
+
const [sub, name] = args;
|
|
102
|
+
if (sub === "set" && name)
|
|
103
|
+
await secretSet(name, { fromGh: flags.has("from-gh") });
|
|
104
|
+
else if (sub === "ls")
|
|
105
|
+
await secretList();
|
|
106
|
+
else if (sub === "rm" && name)
|
|
107
|
+
await secretRemove(name);
|
|
108
|
+
else
|
|
109
|
+
throw new Error("Usage: nido secret set <name> | nido secret ls | nido secret rm <name>");
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
const VALUE_FLAGS = new Set(["image", "secret", "network"]);
|
|
113
|
+
export function parse(argv) {
|
|
114
|
+
const flags = new Map();
|
|
115
|
+
const args = [];
|
|
116
|
+
const dash = argv.indexOf("--");
|
|
117
|
+
const own = dash === -1 ? argv : argv.slice(0, dash);
|
|
118
|
+
for (let i = 0; i < own.length; i++) {
|
|
119
|
+
const token = own[i];
|
|
120
|
+
if (!token.startsWith("--")) {
|
|
121
|
+
args.push(token);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const [key, inline] = token.slice(2).split(/=(.*)/s, 2);
|
|
125
|
+
const values = flags.get(key) ?? [];
|
|
126
|
+
if (VALUE_FLAGS.has(key)) {
|
|
127
|
+
const value = inline ?? own[++i];
|
|
128
|
+
if (value === undefined)
|
|
129
|
+
throw new Error(`--${key} needs a value`);
|
|
130
|
+
values.push(value);
|
|
131
|
+
}
|
|
132
|
+
flags.set(key, values);
|
|
133
|
+
}
|
|
134
|
+
return { flags, args, passthrough: dash === -1 ? [] : argv.slice(dash + 1) };
|
|
135
|
+
}
|
|
136
|
+
function version() {
|
|
137
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
138
|
+
return pkg.version;
|
|
139
|
+
}
|
|
140
|
+
// Run only as the program itself, not when tests import this file. npm
|
|
141
|
+
// links the binary, so compare real paths.
|
|
142
|
+
const isMain = (() => {
|
|
143
|
+
try {
|
|
144
|
+
return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
})();
|
|
150
|
+
if (isMain) {
|
|
151
|
+
main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
|
|
152
|
+
process.stderr.write(`nido: ${err.message}\n`);
|
|
153
|
+
process.exit(1);
|
|
154
|
+
});
|
|
155
|
+
}
|
package/dist/commands.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { agentCommand, formatClaudeEvent, WORKDIR } from "./agents.js";
|
|
6
|
+
import { Api, ApiError } from "./api.js";
|
|
7
|
+
import { forgetLogin, loadConfig, saveLogin } from "./config.js";
|
|
8
|
+
import { excludeMatcher, hashFiles, listFiles } from "./files.js";
|
|
9
|
+
import { loginWithPirol } from "./login.js";
|
|
10
|
+
import { MANIFEST, manifestTemplate, readManifest } from "./manifest.js";
|
|
11
|
+
import { secretKind } from "./secrets.js";
|
|
12
|
+
import { planDownload, planUpload, readState, writeState } from "./sync.js";
|
|
13
|
+
const say = (text) => void process.stderr.write(text + "\n");
|
|
14
|
+
// ---------------------------------------------------------------- login
|
|
15
|
+
export async function login(opts) {
|
|
16
|
+
const result = await loginWithPirol({ manual: opts.manual, ask });
|
|
17
|
+
saveLogin(result.key, result.email);
|
|
18
|
+
try {
|
|
19
|
+
const me = await Api.fromConfig().whoami();
|
|
20
|
+
say(`Logged in as ${me.email}`);
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
if (err instanceof ApiError && err.status === 403) {
|
|
24
|
+
say(`Logged in as ${result.email}. ${err.message}`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
throw err;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function whoami() {
|
|
31
|
+
const config = loadConfig();
|
|
32
|
+
if (!config.key)
|
|
33
|
+
throw new Error("Not logged in. Run: nido login");
|
|
34
|
+
const me = await Api.fromConfig().whoami();
|
|
35
|
+
process.stdout.write(`${me.email}\n`);
|
|
36
|
+
}
|
|
37
|
+
export function logout() {
|
|
38
|
+
forgetLogin();
|
|
39
|
+
say("Logged out");
|
|
40
|
+
}
|
|
41
|
+
// ---------------------------------------------------------------- init
|
|
42
|
+
export function init(dir, kind) {
|
|
43
|
+
const file = join(dir, MANIFEST);
|
|
44
|
+
if (existsSync(file))
|
|
45
|
+
throw new Error(`${MANIFEST} exists already`);
|
|
46
|
+
writeFileSync(file, manifestTemplate(kind));
|
|
47
|
+
say(`Wrote ${MANIFEST}. Edit the task, then: nido run`);
|
|
48
|
+
if (kind === "claude")
|
|
49
|
+
say("Claude needs your Anthropic key once: nido secret set anthropic");
|
|
50
|
+
}
|
|
51
|
+
// ---------------------------------------------------------------- sandboxes
|
|
52
|
+
export async function createSandbox(name, spec) {
|
|
53
|
+
const api = Api.fromConfig();
|
|
54
|
+
say(`Creating sandbox ${name}…`);
|
|
55
|
+
const info = await api.create({ name, ...spec });
|
|
56
|
+
say(`Sandbox ${name} ready in ${(info.ready_ms / 1000).toFixed(1)} s`);
|
|
57
|
+
}
|
|
58
|
+
export async function list() {
|
|
59
|
+
const sandboxes = await Api.fromConfig().list();
|
|
60
|
+
if (!sandboxes.length)
|
|
61
|
+
return say("No sandboxes. Create one with: nido run or nido create <name>");
|
|
62
|
+
const width = Math.max(4, ...sandboxes.map((s) => s.name.length));
|
|
63
|
+
process.stdout.write(`${"NAME".padEnd(width)} ${"CREATED".padEnd(19)} PHASE\n`);
|
|
64
|
+
for (const s of sandboxes)
|
|
65
|
+
process.stdout.write(`${s.name.padEnd(width)} ${s.created.padEnd(19)} ${s.phase}\n`);
|
|
66
|
+
}
|
|
67
|
+
export async function exec(name, command) {
|
|
68
|
+
return Api.fromConfig().exec(name, command, {
|
|
69
|
+
onOut: (text) => process.stdout.write(text),
|
|
70
|
+
onErr: (text) => process.stderr.write(text),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
export async function simple(action, name) {
|
|
74
|
+
const api = Api.fromConfig();
|
|
75
|
+
if (action === "rm")
|
|
76
|
+
await api.remove(name);
|
|
77
|
+
if (action === "stop")
|
|
78
|
+
await api.stop(name);
|
|
79
|
+
if (action === "start")
|
|
80
|
+
await api.start(name);
|
|
81
|
+
if (action === "logs")
|
|
82
|
+
return void process.stdout.write(await api.logs(name));
|
|
83
|
+
say(`${action === "rm" ? "Removed" : action === "stop" ? "Stopped" : "Started"} ${name}`);
|
|
84
|
+
}
|
|
85
|
+
/** nido cp: "shop:/sandbox/x" is in the sandbox, anything else is local. */
|
|
86
|
+
export async function copy(src, dst) {
|
|
87
|
+
const remote = /^([a-z0-9][a-z0-9-]*):(\/.*)$/;
|
|
88
|
+
const from = remote.exec(src);
|
|
89
|
+
const to = remote.exec(dst);
|
|
90
|
+
const api = Api.fromConfig();
|
|
91
|
+
if (to && !from) {
|
|
92
|
+
const local = resolve(src);
|
|
93
|
+
if (!existsSync(local))
|
|
94
|
+
throw new Error(`${src} does not exist`);
|
|
95
|
+
if (statSync(local).isDirectory())
|
|
96
|
+
return api.upload(to[1], local, to[2]);
|
|
97
|
+
const tmp = mkdtempSync(join(tmpdir(), "nido-"));
|
|
98
|
+
try {
|
|
99
|
+
copyFileSync(local, join(tmp, basename(local)));
|
|
100
|
+
await api.upload(to[1], tmp, to[2]);
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (from && !to) {
|
|
108
|
+
mkdirSync(resolve(dst), { recursive: true });
|
|
109
|
+
if (!(await api.download(from[1], from[2], resolve(dst))))
|
|
110
|
+
throw new Error(`${from[2]} does not exist in ${from[1]}`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
throw new Error("nido cp copies between this machine and one sandbox: nido cp ./data shop:/sandbox/data");
|
|
114
|
+
}
|
|
115
|
+
// ---------------------------------------------------------------- run
|
|
116
|
+
/** nido run: upload the folder, run the agent, bring the results back. */
|
|
117
|
+
export async function runFolder(dir, opts = {}) {
|
|
118
|
+
const m = readManifest(dir);
|
|
119
|
+
const api = Api.fromConfig();
|
|
120
|
+
const isNew = await ensureSandbox(api, m, Boolean(opts.fresh));
|
|
121
|
+
const state = readState(dir, m.name);
|
|
122
|
+
if (isNew)
|
|
123
|
+
state.uploaded = {};
|
|
124
|
+
await upload(api, dir, m, state.uploaded);
|
|
125
|
+
writeState(dir, state);
|
|
126
|
+
const command = agentCommand(m);
|
|
127
|
+
say(m.agent ? `Running ${m.agent} in ${m.name}…` : `Running in ${m.name}: ${command.join(" ")}`);
|
|
128
|
+
let partial = "";
|
|
129
|
+
const code = await api.exec(m.name, command, {
|
|
130
|
+
workdir: WORKDIR,
|
|
131
|
+
onOut: m.agent
|
|
132
|
+
? (text) => {
|
|
133
|
+
const lines = (partial + text).split("\n");
|
|
134
|
+
partial = lines.pop() ?? "";
|
|
135
|
+
for (const line of lines) {
|
|
136
|
+
const shown = formatClaudeEvent(line);
|
|
137
|
+
if (shown)
|
|
138
|
+
process.stdout.write(shown + "\n");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
: (text) => process.stdout.write(text),
|
|
142
|
+
onErr: (text) => process.stderr.write(text),
|
|
143
|
+
});
|
|
144
|
+
await download(api, dir, m, state.downloaded);
|
|
145
|
+
writeState(dir, state);
|
|
146
|
+
say(`Sandbox ${m.name} keeps running. Remove it with: nido rm ${m.name}`);
|
|
147
|
+
return code;
|
|
148
|
+
}
|
|
149
|
+
/** nido pull: fetch the output folder again. */
|
|
150
|
+
export async function pull(dir) {
|
|
151
|
+
const m = readManifest(dir);
|
|
152
|
+
const api = Api.fromConfig();
|
|
153
|
+
if (!(await api.get(m.name)))
|
|
154
|
+
throw new Error(`Sandbox ${m.name} does not exist`);
|
|
155
|
+
const state = readState(dir, m.name);
|
|
156
|
+
await download(api, dir, m, state.downloaded);
|
|
157
|
+
writeState(dir, state);
|
|
158
|
+
}
|
|
159
|
+
async function ensureSandbox(api, m, fresh) {
|
|
160
|
+
let info = await api.get(m.name);
|
|
161
|
+
if (info && fresh) {
|
|
162
|
+
await api.remove(m.name);
|
|
163
|
+
info = undefined;
|
|
164
|
+
}
|
|
165
|
+
if (!info) {
|
|
166
|
+
say(`Creating sandbox ${m.name}…`);
|
|
167
|
+
const created = await api.create({ name: m.name, image: m.imageSet ? m.image : undefined, secrets: m.secrets, network: m.network });
|
|
168
|
+
say(`Sandbox ${m.name} ready in ${(created.ready_ms / 1000).toFixed(1)} s`);
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
if (info.phase !== "Ready")
|
|
172
|
+
await api.start(m.name);
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
async function upload(api, dir, m, uploaded) {
|
|
176
|
+
const isExcluded = excludeMatcher([...m.exclude, `/${m.output}`]);
|
|
177
|
+
const local = await hashFiles(dir, listFiles(dir, isExcluded));
|
|
178
|
+
const plan = planUpload(local, uploaded);
|
|
179
|
+
if (!plan.changed.length && !plan.removed.length)
|
|
180
|
+
return say("Files unchanged, nothing to upload");
|
|
181
|
+
if (plan.changed.length) {
|
|
182
|
+
const stage = mkdtempSync(join(tmpdir(), "nido-"));
|
|
183
|
+
try {
|
|
184
|
+
for (const path of plan.changed) {
|
|
185
|
+
mkdirSync(dirname(join(stage, path)), { recursive: true });
|
|
186
|
+
copyFileSync(join(dir, path), join(stage, path));
|
|
187
|
+
}
|
|
188
|
+
say(`Uploading ${count(plan.changed.length, "file")}…`);
|
|
189
|
+
await api.upload(m.name, stage, WORKDIR);
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
rmSync(stage, { recursive: true, force: true });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (plan.removed.length) {
|
|
196
|
+
await api.exec(m.name, ["rm", "-f", "--", ...plan.removed], { workdir: WORKDIR, onOut: () => { }, onErr: () => { } });
|
|
197
|
+
}
|
|
198
|
+
for (const path of plan.removed)
|
|
199
|
+
delete uploaded[path];
|
|
200
|
+
Object.assign(uploaded, local);
|
|
201
|
+
}
|
|
202
|
+
async function download(api, dir, m, downloaded) {
|
|
203
|
+
const tmp = mkdtempSync(join(tmpdir(), "nido-"));
|
|
204
|
+
try {
|
|
205
|
+
if (!(await api.download(m.name, `${WORKDIR}/${m.output}`, tmp))) {
|
|
206
|
+
return say(`No ${m.output}/ in the sandbox, nothing to bring back`);
|
|
207
|
+
}
|
|
208
|
+
const paths = listFiles(tmp, () => false);
|
|
209
|
+
const remote = await hashFiles(tmp, paths);
|
|
210
|
+
const outDir = join(dir, m.output);
|
|
211
|
+
const local = await hashFiles(outDir, paths.filter((path) => existsSync(join(outDir, path))));
|
|
212
|
+
const lines = [];
|
|
213
|
+
for (const action of planDownload(remote, local, downloaded)) {
|
|
214
|
+
const target = action.kind === "conflict" ? action.savedAs : action.path;
|
|
215
|
+
if (action.kind !== "same") {
|
|
216
|
+
mkdirSync(dirname(join(outDir, target)), { recursive: true });
|
|
217
|
+
copyFileSync(join(tmp, action.path), join(outDir, target));
|
|
218
|
+
}
|
|
219
|
+
// A conflict leaves the user's file alone; its last download stays
|
|
220
|
+
// the reference, so the next pull reports it again.
|
|
221
|
+
if (action.kind !== "conflict")
|
|
222
|
+
downloaded[action.path] = remote[action.path];
|
|
223
|
+
if (action.kind === "new")
|
|
224
|
+
lines.push(` + ${m.output}/${action.path}`);
|
|
225
|
+
if (action.kind === "update")
|
|
226
|
+
lines.push(` ~ ${m.output}/${action.path}`);
|
|
227
|
+
if (action.kind === "conflict") {
|
|
228
|
+
lines.push(` ! ${m.output}/${action.path} changed here too, sandbox version saved as ${m.output}/${action.savedAs}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
say(lines.length ? `Results:\n${lines.join("\n")}` : "Results unchanged");
|
|
232
|
+
}
|
|
233
|
+
finally {
|
|
234
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function count(n, word) {
|
|
238
|
+
return `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
239
|
+
}
|
|
240
|
+
// ---------------------------------------------------------------- secrets
|
|
241
|
+
export async function secretSet(name, opts) {
|
|
242
|
+
const kind = secretKind(name);
|
|
243
|
+
let value;
|
|
244
|
+
if (opts.fromGh) {
|
|
245
|
+
const { run } = await import("./run.js");
|
|
246
|
+
const gh = await run("gh", ["auth", "token"]);
|
|
247
|
+
if (gh.code !== 0)
|
|
248
|
+
throw new Error("gh auth token failed. Log in with: gh auth login");
|
|
249
|
+
value = gh.stdout.trim();
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
value = (await readSecret(`${kind.hint}: `)).trim();
|
|
253
|
+
}
|
|
254
|
+
if (!value)
|
|
255
|
+
throw new Error("No value given");
|
|
256
|
+
const result = await Api.fromConfig().setSecret(name, value);
|
|
257
|
+
say(result === "updated" ? `Updated secret ${name}. Running sandboxes get it for new processes.` : `Saved secret ${name}`);
|
|
258
|
+
}
|
|
259
|
+
export async function secretList() {
|
|
260
|
+
const names = await Api.fromConfig().secrets();
|
|
261
|
+
process.stdout.write(names.length ? names.join("\n") + "\n" : "");
|
|
262
|
+
if (!names.length)
|
|
263
|
+
say("No secrets yet. Add one with: nido secret set anthropic");
|
|
264
|
+
}
|
|
265
|
+
export async function secretRemove(name) {
|
|
266
|
+
await Api.fromConfig().removeSecret(name);
|
|
267
|
+
say(`Removed secret ${name}`);
|
|
268
|
+
}
|
|
269
|
+
async function ask(question) {
|
|
270
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
271
|
+
try {
|
|
272
|
+
return await rl.question(question);
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
rl.close();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/** Reads a value without echo; from stdin when it is not a terminal. */
|
|
279
|
+
async function readSecret(prompt) {
|
|
280
|
+
const stdin = process.stdin;
|
|
281
|
+
if (!stdin.isTTY) {
|
|
282
|
+
let data = "";
|
|
283
|
+
for await (const chunk of stdin)
|
|
284
|
+
data += chunk;
|
|
285
|
+
return data;
|
|
286
|
+
}
|
|
287
|
+
process.stderr.write(prompt);
|
|
288
|
+
stdin.setRawMode(true);
|
|
289
|
+
stdin.resume();
|
|
290
|
+
stdin.setEncoding("utf8");
|
|
291
|
+
return new Promise((resolvePromise, reject) => {
|
|
292
|
+
let value = "";
|
|
293
|
+
const onData = (chunk) => {
|
|
294
|
+
for (const char of chunk) {
|
|
295
|
+
if (char === "\r" || char === "\n")
|
|
296
|
+
return done();
|
|
297
|
+
if (char === "\u0003")
|
|
298
|
+
return done(new Error("Cancelled"));
|
|
299
|
+
if (char === "\u007f")
|
|
300
|
+
value = value.slice(0, -1);
|
|
301
|
+
else
|
|
302
|
+
value += char;
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
const done = (err) => {
|
|
306
|
+
stdin.off("data", onData);
|
|
307
|
+
stdin.setRawMode(false);
|
|
308
|
+
stdin.pause();
|
|
309
|
+
process.stderr.write("\n");
|
|
310
|
+
if (err)
|
|
311
|
+
reject(err);
|
|
312
|
+
else
|
|
313
|
+
resolvePromise(value);
|
|
314
|
+
};
|
|
315
|
+
stdin.on("data", onData);
|
|
316
|
+
});
|
|
317
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Where nido finds the API and the Pirol key. `nido login` writes
|
|
2
|
+
// ~/.nido/config.json (0600); PIROL_API_KEY and NIDO_API_URL override it,
|
|
3
|
+
// for CI and for agents that already hold a key.
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { nidoHome } from "./paths.js";
|
|
7
|
+
export const DEFAULT_API = "https://nido.pirol.ai";
|
|
8
|
+
function file() {
|
|
9
|
+
return join(nidoHome(), "config.json");
|
|
10
|
+
}
|
|
11
|
+
export function loadConfig() {
|
|
12
|
+
let saved = {};
|
|
13
|
+
if (existsSync(file())) {
|
|
14
|
+
try {
|
|
15
|
+
saved = JSON.parse(readFileSync(file(), "utf8"));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
saved = {};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
api: (process.env.NIDO_API_URL ?? saved.api ?? DEFAULT_API).replace(/\/+$/, ""),
|
|
23
|
+
key: process.env.PIROL_API_KEY ?? saved.key,
|
|
24
|
+
email: process.env.PIROL_API_KEY ? undefined : saved.email,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export function saveLogin(key, email, api) {
|
|
28
|
+
mkdirSync(nidoHome(), { recursive: true, mode: 0o700 });
|
|
29
|
+
const current = existsSync(file()) ? JSON.parse(readFileSync(file(), "utf8")) : {};
|
|
30
|
+
writeFileSync(file(), JSON.stringify({ ...current, ...(api ? { api } : {}), key, email }, null, 2) + "\n", { mode: 0o600 });
|
|
31
|
+
chmodSync(file(), 0o600);
|
|
32
|
+
}
|
|
33
|
+
export function forgetLogin() {
|
|
34
|
+
rmSync(file(), { force: true });
|
|
35
|
+
}
|
package/dist/files.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream, readdirSync } from "node:fs";
|
|
3
|
+
import { join, posix } from "node:path";
|
|
4
|
+
/** Never uploaded: nido's own state and version control. */
|
|
5
|
+
const ALWAYS_EXCLUDED = [".nido", ".git", ".DS_Store"];
|
|
6
|
+
/**
|
|
7
|
+
* Turns an exclude pattern into a test on relative paths (with "/").
|
|
8
|
+
* Without a slash a pattern matches any file or folder name, like
|
|
9
|
+
* .gitignore. With a slash it matches the path from the folder root.
|
|
10
|
+
*/
|
|
11
|
+
export function excludeMatcher(patterns) {
|
|
12
|
+
const tests = [...ALWAYS_EXCLUDED, ...patterns].map((raw) => {
|
|
13
|
+
const pattern = raw.replace(/\/+$/, "");
|
|
14
|
+
const anchored = pattern.includes("/");
|
|
15
|
+
const regex = globToRegex(pattern.replace(/^\//, ""));
|
|
16
|
+
return anchored
|
|
17
|
+
? (path) => prefixes(path).some((p) => regex.test(p))
|
|
18
|
+
: (path) => path.split("/").some((segment) => regex.test(segment));
|
|
19
|
+
});
|
|
20
|
+
return (path) => tests.some((test) => test(path));
|
|
21
|
+
}
|
|
22
|
+
function prefixes(path) {
|
|
23
|
+
const parts = path.split("/");
|
|
24
|
+
return parts.map((_, i) => parts.slice(0, i + 1).join("/"));
|
|
25
|
+
}
|
|
26
|
+
export function globToRegex(glob) {
|
|
27
|
+
let out = "";
|
|
28
|
+
for (let i = 0; i < glob.length; i++) {
|
|
29
|
+
const c = glob[i];
|
|
30
|
+
if (c === "*" && glob[i + 1] === "*") {
|
|
31
|
+
out += ".*";
|
|
32
|
+
i++;
|
|
33
|
+
if (glob[i + 1] === "/")
|
|
34
|
+
i++;
|
|
35
|
+
}
|
|
36
|
+
else if (c === "*")
|
|
37
|
+
out += "[^/]*";
|
|
38
|
+
else if (c === "?")
|
|
39
|
+
out += "[^/]";
|
|
40
|
+
else
|
|
41
|
+
out += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
42
|
+
}
|
|
43
|
+
return new RegExp(`^${out}$`);
|
|
44
|
+
}
|
|
45
|
+
/** All regular files under root, as sorted paths relative to root. */
|
|
46
|
+
export function listFiles(root, isExcluded) {
|
|
47
|
+
const out = [];
|
|
48
|
+
const walk = (rel) => {
|
|
49
|
+
for (const entry of readdirSync(join(root, rel), { withFileTypes: true })) {
|
|
50
|
+
const path = rel ? posix.join(rel, entry.name) : entry.name;
|
|
51
|
+
if (isExcluded(path))
|
|
52
|
+
continue;
|
|
53
|
+
if (entry.isDirectory())
|
|
54
|
+
walk(path);
|
|
55
|
+
else if (entry.isFile())
|
|
56
|
+
out.push(path);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
walk("");
|
|
60
|
+
return out.sort();
|
|
61
|
+
}
|
|
62
|
+
export function hashFile(file) {
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const hash = createHash("sha256");
|
|
65
|
+
createReadStream(file)
|
|
66
|
+
.on("data", (chunk) => hash.update(chunk))
|
|
67
|
+
.on("error", reject)
|
|
68
|
+
.on("end", () => resolve(hash.digest("hex")));
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
export async function hashFiles(root, paths) {
|
|
72
|
+
const out = {};
|
|
73
|
+
for (const path of paths)
|
|
74
|
+
out[path] = await hashFile(join(root, path));
|
|
75
|
+
return out;
|
|
76
|
+
}
|
package/dist/login.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// "Login with Pirol", the same flow as `kyte login`: the browser opens the
|
|
2
|
+
// Pirol consent page, a one-time code comes back to a loopback address
|
|
3
|
+
// (127.0.0.1, random port) and is exchanged for a Pirol key. PKCE ties the
|
|
4
|
+
// code to this process. --manual prints the URL and takes the pasted code,
|
|
5
|
+
// for SSH sessions without a browser.
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
8
|
+
import { createServer } from "node:http";
|
|
9
|
+
export const CLI_AUTH_URL = process.env.PIROL_CLI_AUTH_URL ?? "https://pirol.ai/cli-auth";
|
|
10
|
+
export const PIROL_API = process.env.PIROL_API_BASE_URL ?? "https://api.pirol.ai";
|
|
11
|
+
const TIMEOUT_MS = 180_000;
|
|
12
|
+
export function randomVerifier() {
|
|
13
|
+
return randomBytes(32).toString("base64url");
|
|
14
|
+
}
|
|
15
|
+
export function pkceChallenge(verifier) {
|
|
16
|
+
return createHash("sha256").update(verifier).digest("base64url");
|
|
17
|
+
}
|
|
18
|
+
export function authUrl(challenge, redirectUri) {
|
|
19
|
+
const url = new URL(CLI_AUTH_URL);
|
|
20
|
+
url.searchParams.set("redirect_uri", redirectUri);
|
|
21
|
+
url.searchParams.set("app_name", "nido");
|
|
22
|
+
url.searchParams.set("code_challenge", challenge);
|
|
23
|
+
return url.toString();
|
|
24
|
+
}
|
|
25
|
+
/** The code from a callback URL, or the code itself when pasted alone. */
|
|
26
|
+
export function codeFrom(input) {
|
|
27
|
+
const text = input.trim();
|
|
28
|
+
if (!text.includes("?") && !text.includes("=")) {
|
|
29
|
+
if (!text)
|
|
30
|
+
throw new Error("No code given");
|
|
31
|
+
return text;
|
|
32
|
+
}
|
|
33
|
+
const params = new URLSearchParams(text.slice(text.indexOf("?") + 1));
|
|
34
|
+
const error = params.get("error");
|
|
35
|
+
if (error)
|
|
36
|
+
throw new Error(params.get("error_description") ?? error);
|
|
37
|
+
const code = params.get("code");
|
|
38
|
+
if (!code)
|
|
39
|
+
throw new Error("The callback contains no code");
|
|
40
|
+
return code;
|
|
41
|
+
}
|
|
42
|
+
function page(title, body) {
|
|
43
|
+
return `<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font-family:system-ui;margin:3rem;max-width:34rem;line-height:1.5"><h2>${title}</h2><p>${body}</p></body>`;
|
|
44
|
+
}
|
|
45
|
+
function startReceiver() {
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
let settle;
|
|
48
|
+
const code = new Promise((ok, fail) => (settle = { ok, fail }));
|
|
49
|
+
code.catch(() => { });
|
|
50
|
+
let done = false;
|
|
51
|
+
const finish = (fn) => {
|
|
52
|
+
if (!done) {
|
|
53
|
+
done = true;
|
|
54
|
+
fn();
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const server = createServer((req, res) => {
|
|
58
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
59
|
+
if (url.pathname !== "/callback")
|
|
60
|
+
return void res.writeHead(404).end();
|
|
61
|
+
try {
|
|
62
|
+
const value = codeFrom(url.toString());
|
|
63
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(page("Logged in ✓", "You can close this window and go back to the terminal."));
|
|
64
|
+
finish(() => settle.ok(value));
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
res.writeHead(400, { "content-type": "text/html; charset=utf-8" }).end(page("Login failed", String(err.message)));
|
|
68
|
+
finish(() => settle.fail(err));
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
const timer = setTimeout(() => {
|
|
72
|
+
finish(() => settle.fail(new Error("The browser did not come back in time")));
|
|
73
|
+
server.close();
|
|
74
|
+
}, TIMEOUT_MS);
|
|
75
|
+
timer.unref();
|
|
76
|
+
server.on("error", reject);
|
|
77
|
+
server.listen(0, "127.0.0.1", () => {
|
|
78
|
+
const addr = server.address();
|
|
79
|
+
if (!addr || typeof addr === "string")
|
|
80
|
+
return reject(new Error("Could not open a local port"));
|
|
81
|
+
resolve({
|
|
82
|
+
redirectUri: `http://127.0.0.1:${addr.port}/callback`,
|
|
83
|
+
code,
|
|
84
|
+
close() {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
finish(() => settle.fail(new Error("cancelled")));
|
|
87
|
+
server.close();
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
export function browserCommand(url, platform = process.platform) {
|
|
94
|
+
if (platform === "darwin")
|
|
95
|
+
return { cmd: "open", args: [url] };
|
|
96
|
+
if (platform === "win32")
|
|
97
|
+
return { cmd: "cmd.exe", args: ["/c", "start", "", url.replace(/&/g, "^&")] };
|
|
98
|
+
return { cmd: "xdg-open", args: [url] };
|
|
99
|
+
}
|
|
100
|
+
function openBrowser(url) {
|
|
101
|
+
return new Promise((resolve) => {
|
|
102
|
+
try {
|
|
103
|
+
const { cmd, args } = browserCommand(url);
|
|
104
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
105
|
+
child.on("error", () => resolve(false));
|
|
106
|
+
child.on("spawn", () => {
|
|
107
|
+
child.unref();
|
|
108
|
+
resolve(true);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
resolve(false);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
export async function loginWithPirol(opts) {
|
|
117
|
+
const verifier = randomVerifier();
|
|
118
|
+
const challenge = pkceChallenge(verifier);
|
|
119
|
+
let code;
|
|
120
|
+
if (opts.manual) {
|
|
121
|
+
process.stderr.write(`Open this page in a browser:\n\n ${authUrl(challenge, "http://127.0.0.1:1/callback")}\n\n`);
|
|
122
|
+
code = codeFrom(await opts.ask("Paste the code (or the whole callback URL): "));
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
const receiver = await startReceiver();
|
|
126
|
+
try {
|
|
127
|
+
const url = authUrl(challenge, receiver.redirectUri);
|
|
128
|
+
const opened = await openBrowser(url);
|
|
129
|
+
process.stderr.write(opened ? "Browser opened. Confirm the login there…\n" : `Open this page in a browser:\n\n ${url}\n\n`);
|
|
130
|
+
code = await receiver.code;
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
receiver.close();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const res = await fetch(`${PIROL_API}/v1/auth/exchange`, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers: { "content-type": "application/json" },
|
|
139
|
+
body: JSON.stringify({ code, code_verifier: verifier }),
|
|
140
|
+
});
|
|
141
|
+
const body = (await res.json().catch(() => ({})));
|
|
142
|
+
if (!res.ok || !body.key)
|
|
143
|
+
throw new Error(`Login failed: ${body.error?.message ?? `HTTP ${res.status}`}`);
|
|
144
|
+
return { key: body.key, email: body.email ?? "" };
|
|
145
|
+
}
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
export const MANIFEST = "nido.json";
|
|
4
|
+
export const AGENTS = ["claude"];
|
|
5
|
+
/**
|
|
6
|
+
* OpenShell community image with Claude Code, Codex and OpenCode, used until
|
|
7
|
+
* our own `coding` template exists. Pinned, and pre-pulled on every node
|
|
8
|
+
* (infra/agent-sandbox/versions.env, SANDBOX_IMAGE).
|
|
9
|
+
*/
|
|
10
|
+
export const DEFAULT_IMAGE = "ghcr.io/nvidia/openshell-community/sandboxes/base@sha256:aeef1c63f00e2913ea002ccb3aaf925f338b5c5d70e63576f0d95c16a138044e";
|
|
11
|
+
export function readManifest(dir) {
|
|
12
|
+
const file = join(dir, MANIFEST);
|
|
13
|
+
if (!existsSync(file))
|
|
14
|
+
throw new Error(`No ${MANIFEST} here. Create one with: nido init`);
|
|
15
|
+
let raw;
|
|
16
|
+
try {
|
|
17
|
+
raw = JSON.parse(readFileSync(file, "utf8"));
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
throw new Error(`${MANIFEST} is not valid JSON: ${err.message}`);
|
|
21
|
+
}
|
|
22
|
+
return parseManifest(raw, basename(dir));
|
|
23
|
+
}
|
|
24
|
+
export function parseManifest(raw, folderName) {
|
|
25
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
26
|
+
throw new Error(`${MANIFEST} must be an object`);
|
|
27
|
+
const m = raw;
|
|
28
|
+
const known = ["name", "agent", "task", "command", "image", "files", "secrets", "network", "output"];
|
|
29
|
+
const unknown = Object.keys(m).filter((key) => !known.includes(key) && !key.startsWith("$"));
|
|
30
|
+
if (unknown.length)
|
|
31
|
+
throw new Error(`${MANIFEST}: unknown field ${unknown.map((k) => `"${k}"`).join(", ")}`);
|
|
32
|
+
const agent = optionalString(m, "agent");
|
|
33
|
+
if (agent !== undefined && !AGENTS.includes(agent)) {
|
|
34
|
+
throw new Error(`${MANIFEST}: agent "${agent}" is not supported yet. Use ${AGENTS.join(", ")} or "command".`);
|
|
35
|
+
}
|
|
36
|
+
const task = optionalString(m, "task");
|
|
37
|
+
const command = m.command;
|
|
38
|
+
if (command !== undefined && typeof command !== "string" && !isStringArray(command)) {
|
|
39
|
+
throw new Error(`${MANIFEST}: "command" must be a string or a list of strings`);
|
|
40
|
+
}
|
|
41
|
+
if (agent && !task)
|
|
42
|
+
throw new Error(`${MANIFEST}: "agent" needs a "task"`);
|
|
43
|
+
if (agent && command !== undefined)
|
|
44
|
+
throw new Error(`${MANIFEST}: use either "agent" or "command", not both`);
|
|
45
|
+
if (!agent && command === undefined)
|
|
46
|
+
throw new Error(`${MANIFEST}: set "agent" and "task", or "command"`);
|
|
47
|
+
const files = m.files;
|
|
48
|
+
if (files !== undefined && (typeof files !== "object" || files === null || Array.isArray(files))) {
|
|
49
|
+
throw new Error(`${MANIFEST}: "files" must be an object like {"exclude": ["node_modules"]}`);
|
|
50
|
+
}
|
|
51
|
+
const exclude = files?.exclude ?? [];
|
|
52
|
+
if (!isStringArray(exclude))
|
|
53
|
+
throw new Error(`${MANIFEST}: "files.exclude" must be a list of strings`);
|
|
54
|
+
const output = (optionalString(m, "output") ?? "output/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
55
|
+
if (!output || output.startsWith("/") || output.split("/").includes("..")) {
|
|
56
|
+
throw new Error(`${MANIFEST}: "output" must be a folder inside this folder`);
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
name: sandboxName(optionalString(m, "name") ?? folderName),
|
|
60
|
+
agent: agent,
|
|
61
|
+
task,
|
|
62
|
+
command: command,
|
|
63
|
+
image: optionalString(m, "image") ?? DEFAULT_IMAGE,
|
|
64
|
+
imageSet: m.image !== undefined,
|
|
65
|
+
exclude,
|
|
66
|
+
secrets: stringList(m, "secrets"),
|
|
67
|
+
network: stringList(m, "network"),
|
|
68
|
+
output,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** Sandbox names are DNS labels: lowercase letters, digits and dashes. */
|
|
72
|
+
export function sandboxName(input) {
|
|
73
|
+
const name = input
|
|
74
|
+
.toLowerCase()
|
|
75
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
76
|
+
.replace(/^-+|-+$/g, "")
|
|
77
|
+
.slice(0, 40)
|
|
78
|
+
.replace(/-+$/, "");
|
|
79
|
+
return name || "nest";
|
|
80
|
+
}
|
|
81
|
+
function optionalString(m, key) {
|
|
82
|
+
const value = m[key];
|
|
83
|
+
if (value === undefined)
|
|
84
|
+
return undefined;
|
|
85
|
+
if (typeof value !== "string" || !value.trim())
|
|
86
|
+
throw new Error(`${MANIFEST}: "${key}" must be a non-empty string`);
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
function stringList(m, key) {
|
|
90
|
+
const value = m[key] ?? [];
|
|
91
|
+
if (!isStringArray(value))
|
|
92
|
+
throw new Error(`${MANIFEST}: "${key}" must be a list of strings`);
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
function isStringArray(value) {
|
|
96
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
97
|
+
}
|
|
98
|
+
export function manifestTemplate(agent) {
|
|
99
|
+
const body = agent === "claude"
|
|
100
|
+
? {
|
|
101
|
+
agent: "claude",
|
|
102
|
+
task: "Read the files in this folder and write a short summary to output/summary.md",
|
|
103
|
+
files: { exclude: ["node_modules", ".venv", "*.log"] },
|
|
104
|
+
secrets: ["anthropic"],
|
|
105
|
+
output: "output/",
|
|
106
|
+
}
|
|
107
|
+
: {
|
|
108
|
+
command: "mkdir -p output && ls -la > output/files.txt && uname -a >> output/files.txt",
|
|
109
|
+
files: { exclude: ["node_modules", ".venv", "*.log"] },
|
|
110
|
+
output: "output/",
|
|
111
|
+
};
|
|
112
|
+
return JSON.stringify(body, null, 2) + "\n";
|
|
113
|
+
}
|
package/dist/paths.js
ADDED
package/dist/run.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
export function run(cmd, args) {
|
|
3
|
+
return new Promise((resolve, reject) => {
|
|
4
|
+
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
5
|
+
let stdout = "";
|
|
6
|
+
let stderr = "";
|
|
7
|
+
child.stdout.on("data", (d) => (stdout += d.toString("utf8")));
|
|
8
|
+
child.stderr.on("data", (d) => (stderr += d.toString("utf8")));
|
|
9
|
+
child.on("error", reject);
|
|
10
|
+
child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
11
|
+
});
|
|
12
|
+
}
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Secret names in nido map to OpenShell provider profiles. A profile says
|
|
2
|
+
// which env var holds the value and which hosts may receive it. The value
|
|
3
|
+
// stays in the gateway; the sandbox only sees a placeholder.
|
|
4
|
+
export const SECRET_KINDS = {
|
|
5
|
+
anthropic: {
|
|
6
|
+
profile: "claude-code",
|
|
7
|
+
envVar: "ANTHROPIC_API_KEY",
|
|
8
|
+
hosts: ["api.anthropic.com", "statsig.anthropic.com", "sentry.io"],
|
|
9
|
+
hint: "Anthropic API key (console.anthropic.com → API keys)",
|
|
10
|
+
},
|
|
11
|
+
openai: {
|
|
12
|
+
profile: "openai",
|
|
13
|
+
envVar: "OPENAI_API_KEY",
|
|
14
|
+
hosts: ["api.openai.com"],
|
|
15
|
+
hint: "OpenAI API key",
|
|
16
|
+
},
|
|
17
|
+
github: {
|
|
18
|
+
profile: "github",
|
|
19
|
+
envVar: "GITHUB_TOKEN",
|
|
20
|
+
hosts: ["api.github.com", "github.com"],
|
|
21
|
+
hint: "GitHub token (or use --from-gh)",
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
export function secretKind(name) {
|
|
25
|
+
const kind = SECRET_KINDS[name];
|
|
26
|
+
if (!kind) {
|
|
27
|
+
throw new Error(`Unknown secret "${name}". Supported for now: ${Object.keys(SECRET_KINDS).join(", ")}`);
|
|
28
|
+
}
|
|
29
|
+
return kind;
|
|
30
|
+
}
|
package/dist/sync.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Decides what goes up and what comes back. Pure functions, so the rules
|
|
2
|
+
// are easy to test: upload only changes, never overwrite a file the user
|
|
3
|
+
// changed locally since the last download.
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname, extname, join } from "node:path";
|
|
6
|
+
export const STATE_FILE = ".nido/state.json";
|
|
7
|
+
export function readState(dir, sandbox) {
|
|
8
|
+
const empty = { sandbox, uploaded: {}, downloaded: {} };
|
|
9
|
+
const file = join(dir, STATE_FILE);
|
|
10
|
+
if (!existsSync(file))
|
|
11
|
+
return empty;
|
|
12
|
+
try {
|
|
13
|
+
const state = JSON.parse(readFileSync(file, "utf8"));
|
|
14
|
+
// A different sandbox has none of our files yet. Downloads stay valid,
|
|
15
|
+
// they describe the local folder.
|
|
16
|
+
return state.sandbox === sandbox ? state : { ...empty, downloaded: state.downloaded ?? {} };
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return empty;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function writeState(dir, state) {
|
|
23
|
+
const file = join(dir, STATE_FILE);
|
|
24
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
25
|
+
writeFileSync(file, JSON.stringify(state, null, 2) + "\n");
|
|
26
|
+
}
|
|
27
|
+
export function planUpload(local, uploaded) {
|
|
28
|
+
return {
|
|
29
|
+
changed: Object.keys(local).filter((path) => uploaded[path] !== local[path]).sort(),
|
|
30
|
+
removed: Object.keys(uploaded).filter((path) => !(path in local)).sort(),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* remote: files in the sandbox's output folder.
|
|
35
|
+
* local: the same paths in the local folder (missing = not there).
|
|
36
|
+
* downloaded: what we wrote last time.
|
|
37
|
+
*/
|
|
38
|
+
export function planDownload(remote, local, downloaded) {
|
|
39
|
+
return Object.keys(remote)
|
|
40
|
+
.sort()
|
|
41
|
+
.map((path) => {
|
|
42
|
+
const here = local[path];
|
|
43
|
+
if (here === undefined)
|
|
44
|
+
return { path, kind: "new" };
|
|
45
|
+
if (here === remote[path])
|
|
46
|
+
return { path, kind: "same" };
|
|
47
|
+
if (here === downloaded[path])
|
|
48
|
+
return { path, kind: "update" };
|
|
49
|
+
return { path, kind: "conflict", savedAs: conflictName(path) };
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/** report.md → report.remote.md, so the copy opens in the same app. */
|
|
53
|
+
export function conflictName(path) {
|
|
54
|
+
const ext = extname(path);
|
|
55
|
+
return ext && !path.endsWith(`/${ext}`) ? `${path.slice(0, -ext.length)}.remote${ext}` : `${path}.remote`;
|
|
56
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pirol/nido",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Run agents in isolated sandboxes. Give it a folder, get results back.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"nido": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=20"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"prebuild": "rm -rf dist",
|
|
17
|
+
"build": "tsc -p tsconfig.json && chmod +x dist/cli.js",
|
|
18
|
+
"test": "vitest run",
|
|
19
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
20
|
+
"prepublishOnly": "npm run build && npm test"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^22.10.0",
|
|
24
|
+
"typescript": "^5.6.3",
|
|
25
|
+
"vitest": "^4.1.9"
|
|
26
|
+
},
|
|
27
|
+
"license": "UNLICENSED",
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://pirol.ai/docs/sandboxes",
|
|
32
|
+
"keywords": [
|
|
33
|
+
"sandbox",
|
|
34
|
+
"agents",
|
|
35
|
+
"claude",
|
|
36
|
+
"kata",
|
|
37
|
+
"openshell"
|
|
38
|
+
]
|
|
39
|
+
}
|