@wardby/cli 0.1.0 → 0.2.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 +62 -50
- package/deploy/local/docker-compose.yml +5 -5
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +5 -0
- package/dist/cli.js +0 -0
- package/dist/env.js +8 -1
- package/dist/quickstart/config.d.ts +30 -0
- package/dist/quickstart/config.js +106 -0
- package/dist/quickstart/index.d.ts +5 -0
- package/dist/quickstart/index.js +536 -0
- package/dist/wardby-bin.js +35 -9
- package/package.json +4 -1
- package/dist/providers/auth/release-gate.d.ts +0 -1
- package/dist/providers/auth/release-gate.js +0 -4
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<div>
|
|
2
|
-
<img align="left" hspace="24" src="docs/assets/brand/wardby-mascot.png" alt="Wardby guardian robot protecting an agent budget" width="280">
|
|
2
|
+
<img align="left" hspace="24" src="https://raw.githubusercontent.com/wardby/wardby/main/docs/assets/brand/wardby-mascot.png" alt="Wardby guardian robot protecting an agent budget" width="280">
|
|
3
3
|
<h3><big><big>Wardby</big></big> <small><em>(pronounced “WARD‑bee”)</em></small></h3>
|
|
4
4
|
<h3>Autonomous agents, bounded by design.</h3>
|
|
5
5
|
<p><strong>Most agent runners focus on helping a model complete a task. Wardby is the self-hosted control plane that decides whether the task should run, limits what it can access, and returns a reviewable outcome governed by explicit policy.</strong></p>
|
|
@@ -11,19 +11,26 @@
|
|
|
11
11
|
<a href="#a-full-cycle-agent-from-one-conversation">Full-cycle example</a> ·
|
|
12
12
|
<a href="#host-it-in-your-cloud">Deployments</a> ·
|
|
13
13
|
<a href="#bring-your-own-observability">Observability</a> ·
|
|
14
|
-
<a href="
|
|
14
|
+
<a href="https://github.com/wardby/wardby/blob/main/docs/getting-started.md">Getting started</a> ·
|
|
15
15
|
<a href="#security-boundaries">Security</a>
|
|
16
16
|
</p>
|
|
17
17
|
</div>
|
|
18
18
|
<br clear="left">
|
|
19
19
|
|
|
20
|
-
<!--
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
<!-- The first four badges are live: the npm version, Node floor and license are
|
|
21
|
+
read from the published package, and the security badge shows the latest run of
|
|
22
|
+
the security workflow on main. The last three are capability labels describing
|
|
23
|
+
what that workflow enforces -- they are not status and never change colour.
|
|
24
|
+
Every URL is absolute because npm renders this README too, and a relative path
|
|
25
|
+
there resolves against npmjs.com instead of this repository. -->
|
|
26
|
+
|
|
27
|
+
[](https://www.npmjs.com/package/@wardby/cli)
|
|
28
|
+
[](https://github.com/wardby/wardby/actions/workflows/security.yml?query=branch%3Amain)
|
|
29
|
+
[](https://www.npmjs.com/package/@wardby/cli)
|
|
30
|
+
[](https://github.com/wardby/wardby/blob/main/LICENSE)
|
|
23
31
|
[](https://github.com/wardby/wardby/actions/workflows/security.yml?query=branch%3Amain)
|
|
24
32
|
[](https://github.com/wardby/wardby/actions/workflows/security.yml?query=branch%3Amain)
|
|
25
33
|
[](https://github.com/wardby/wardby/actions/workflows/security.yml?query=branch%3Amain)
|
|
26
|
-
[](LICENSE)
|
|
27
34
|
|
|
28
35
|
> **Project status:** the control plane, scheduler, budget groups, MCP server,
|
|
29
36
|
> native agents, isolated Codex and Claude Code workers, GitHub draft-PR flow,
|
|
@@ -68,7 +75,7 @@ result.
|
|
|
68
75
|
|
|
69
76
|
## One control plane, the full lifecycle
|
|
70
77
|
|
|
71
|
-

|
|
78
|
+

|
|
72
79
|
|
|
73
80
|
Claude and Codex are the operator experience. Wardby is the durable system
|
|
74
81
|
behind them: it stores agent definitions, triggers work, reserves budget,
|
|
@@ -126,17 +133,17 @@ The core does not import a cloud SDK. Jobs, model access, email, secrets,
|
|
|
126
133
|
authentication, and object storage sit behind provider interfaces so operators
|
|
127
134
|
can choose the infrastructure boundary that fits their environment.
|
|
128
135
|
|
|
129
|
-
| Target | Current support
|
|
130
|
-
| ------------------------------------ |
|
|
131
|
-
| **Local Docker, VM, or on-premises** | Portable PostgreSQL and container workflow for development and self-hosting.
|
|
132
|
-
| **Production container baseline** | Separate runtime and migration images plus a Compose/Caddy reference boundary.
|
|
133
|
-
| **
|
|
134
|
-
| **AWS** | The portable runtime and Bedrock model adapter are available; a native AWS deployment module is planned.
|
|
135
|
-
| **Other clouds** | Run the production image and provide equivalent PostgreSQL, secrets, ingress, egress, and monitoring controls.
|
|
136
|
-
|
|
137
|
-
Start with [deployment targets](deploy/README.md), the
|
|
138
|
-
[portable production boundary](deploy/production/README.md), or the
|
|
139
|
-
[
|
|
136
|
+
| Target | Current support |
|
|
137
|
+
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
|
|
138
|
+
| **Local Docker, VM, or on-premises** | Portable PostgreSQL and container workflow for development and self-hosting. |
|
|
139
|
+
| **Production container baseline** | Separate runtime and migration images plus a Compose/Caddy reference boundary. |
|
|
140
|
+
| **GKE Autopilot** | Supported GCP reference with private Cloud SQL, isolated Codex worker pods, HTTPS Gateway, and immutable images. |
|
|
141
|
+
| **AWS** | The portable runtime and Bedrock model adapter are available; a native AWS deployment module is planned. |
|
|
142
|
+
| **Other clouds** | Run the production image and provide equivalent PostgreSQL, secrets, ingress, egress, and monitoring controls. |
|
|
143
|
+
|
|
144
|
+
Start with [deployment targets](https://github.com/wardby/wardby/blob/main/deploy/README.md), the
|
|
145
|
+
[portable production boundary](https://github.com/wardby/wardby/blob/main/deploy/production/README.md), or the
|
|
146
|
+
[GKE getting-started guide](https://github.com/wardby/wardby/blob/main/docs/getting-started-gke.md). Reference deployments are examples,
|
|
140
147
|
not a requirement to use one vendor.
|
|
141
148
|
|
|
142
149
|
**A complete deployment runs more than `wardby mcp`.** `mcp` serves the MCP
|
|
@@ -172,7 +179,7 @@ npm run observability:down
|
|
|
172
179
|
The included cloud deployments do not yet provision AWS or GCP collectors.
|
|
173
180
|
Current metrics primarily cover the coding proxy; application/MCP metrics,
|
|
174
181
|
production alerts, and SLOs remain operator responsibilities. See the
|
|
175
|
-
[observability guide](docs/observability.md) for boundaries and retention.
|
|
182
|
+
[observability guide](https://github.com/wardby/wardby/blob/main/docs/observability.md) for boundaries and retention.
|
|
176
183
|
|
|
177
184
|
## Security boundaries
|
|
178
185
|
|
|
@@ -195,9 +202,9 @@ production alerts, and SLOs remain operator responsibilities. See the
|
|
|
195
202
|
- **Sanitized operations:** lifecycle events and metrics exclude prompts,
|
|
196
203
|
repository content, credentials, diffs, and raw worker output.
|
|
197
204
|
|
|
198
|
-
Read the [runtime architecture](docs/architecture-runtime.md),
|
|
199
|
-
[coding-worker isolation model](docs/coding-worker-isolation.md), and
|
|
200
|
-
[security deployment guide](docs/security-deployment.md) before enabling a
|
|
205
|
+
Read the [runtime architecture](https://github.com/wardby/wardby/blob/main/docs/architecture-runtime.md),
|
|
206
|
+
[coding-worker isolation model](https://github.com/wardby/wardby/blob/main/docs/coding-worker-isolation.md), and
|
|
207
|
+
[security deployment guide](https://github.com/wardby/wardby/blob/main/docs/security-deployment.md) before enabling a
|
|
201
208
|
production repository.
|
|
202
209
|
|
|
203
210
|
Review policy is designed to support designated agents as well as people.
|
|
@@ -210,33 +217,38 @@ Requirements: Node.js 24 or newer, Docker, and one supported model-provider
|
|
|
210
217
|
credential.
|
|
211
218
|
|
|
212
219
|
```sh
|
|
213
|
-
|
|
214
|
-
cd wardby
|
|
215
|
-
npm ci
|
|
216
|
-
cp .env.example .env
|
|
220
|
+
npx --yes @wardby/cli@latest quickstart
|
|
217
221
|
```
|
|
218
222
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
`
|
|
222
|
-
server
|
|
223
|
+
The guided command checks prerequisites, creates private project-local
|
|
224
|
+
configuration, starts PostgreSQL in Docker, applies migrations, and offers to
|
|
225
|
+
run a small agent with a `$1` maximum budget. It can also register Wardby as a
|
|
226
|
+
local MCP server in Codex, Claude Code, or both.
|
|
227
|
+
|
|
228
|
+
Read the [Getting started guide](https://github.com/wardby/wardby/blob/main/docs/getting-started.md) for unattended setup,
|
|
229
|
+
MCP usage, lifecycle commands, and the boundary between the native demo and
|
|
230
|
+
isolated coding agents. For a shared cloud installation, follow the full
|
|
231
|
+
[GKE getting-started guide](https://github.com/wardby/wardby/blob/main/docs/getting-started-gke.md).
|
|
232
|
+
|
|
233
|
+
### Installing from npm
|
|
223
234
|
|
|
224
235
|
```sh
|
|
225
|
-
npm
|
|
226
|
-
npm run prisma:generate
|
|
227
|
-
npm run prisma:migrate
|
|
228
|
-
npm run cli -- mcp
|
|
236
|
+
npm install @wardby/cli
|
|
229
237
|
```
|
|
230
238
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
239
|
+
`npm audit` will report a high-severity advisory in `deepmerge-ts`
|
|
240
|
+
([GHSA-ggr8-5vv4-36mx](https://github.com/advisories/GHSA-ggr8-5vv4-36mx)). It
|
|
241
|
+
is reached only through Prisma's CLI while it loads your own Prisma
|
|
242
|
+
configuration, and Prisma has not patched it in the 6.x line. This repository
|
|
243
|
+
clears it with an override, but npm does not apply a package's overrides to the
|
|
244
|
+
projects that install it — so add the same entry to your own `package.json`:
|
|
245
|
+
|
|
246
|
+
```json
|
|
247
|
+
"overrides": { "deepmerge-ts": "8.0.2" }
|
|
248
|
+
```
|
|
235
249
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
additionally requires a dedicated GitHub App, immutable worker images, and the
|
|
239
|
-
proxy boundary described in the [local coding-agent setup guide](docs/coding-agent-setup.md).
|
|
250
|
+
That override is tested against Prisma 6 and the CLI; the reasoning and the
|
|
251
|
+
retirement plan are in [SR-009](https://github.com/wardby/wardby/blob/main/docs/security-deployment.md#images-and-dependency-exception).
|
|
240
252
|
|
|
241
253
|
## What is implemented
|
|
242
254
|
|
|
@@ -251,12 +263,12 @@ proxy boundary described in the [local coding-agent setup guide](docs/coding-age
|
|
|
251
263
|
- In-process reconciliation and optional DBOS durable workflows.
|
|
252
264
|
- Self-hosted or delegated OAuth for remote MCP access.
|
|
253
265
|
- Prometheus metrics and provisioned Grafana dashboards for local operations.
|
|
254
|
-
- Portable production images, a Compose/Caddy boundary, and a
|
|
255
|
-
Terraform reference.
|
|
266
|
+
- Portable production images, a Compose/Caddy boundary, and a GKE Autopilot
|
|
267
|
+
Terraform reference deployment.
|
|
256
268
|
|
|
257
269
|
This project is under active development. Run the
|
|
258
|
-
[release verification](docs/release-verification.md) checks and review the
|
|
259
|
-
[security deployment guide](docs/security-deployment.md) before production use.
|
|
270
|
+
[release verification](https://github.com/wardby/wardby/blob/main/docs/release-verification.md) checks and review the
|
|
271
|
+
[security deployment guide](https://github.com/wardby/wardby/blob/main/docs/security-deployment.md) before production use.
|
|
260
272
|
|
|
261
273
|
## Repository map
|
|
262
274
|
|
|
@@ -273,10 +285,10 @@ docs/ Architecture, security, setup, and operator guidance
|
|
|
273
285
|
|
|
274
286
|
## License
|
|
275
287
|
|
|
276
|
-
Apache License 2.0. See [LICENSE](LICENSE) for the license terms and
|
|
277
|
-
[NOTICE](NOTICE) for attribution.
|
|
288
|
+
Apache License 2.0. See [LICENSE](https://github.com/wardby/wardby/blob/main/LICENSE) for the license terms and
|
|
289
|
+
[NOTICE](https://github.com/wardby/wardby/blob/main/NOTICE) for attribution.
|
|
278
290
|
|
|
279
|
-
See [CONTRIBUTING.md](CONTRIBUTING.md) to contribute and [SECURITY.md](SECURITY.md)
|
|
291
|
+
See [CONTRIBUTING.md](https://github.com/wardby/wardby/blob/main/CONTRIBUTING.md) to contribute and [SECURITY.md](https://github.com/wardby/wardby/blob/main/SECURITY.md)
|
|
280
292
|
to report a vulnerability privately.
|
|
281
293
|
|
|
282
|
-
Built as an independent reimplementation. See [CLEANROOM.md](CLEANROOM.md).
|
|
294
|
+
Built as an independent reimplementation. See [CLEANROOM.md](https://github.com/wardby/wardby/blob/main/CLEANROOM.md).
|
|
@@ -2,15 +2,15 @@ services:
|
|
|
2
2
|
postgres:
|
|
3
3
|
image: postgres:16-alpine
|
|
4
4
|
environment:
|
|
5
|
-
POSTGRES_USER: wardby
|
|
6
|
-
POSTGRES_PASSWORD: wardby
|
|
7
|
-
POSTGRES_DB: wardby
|
|
5
|
+
POSTGRES_USER: ${WARDBY_POSTGRES_USER:-wardby}
|
|
6
|
+
POSTGRES_PASSWORD: ${WARDBY_POSTGRES_PASSWORD:-wardby}
|
|
7
|
+
POSTGRES_DB: ${WARDBY_POSTGRES_DB:-wardby}
|
|
8
8
|
ports:
|
|
9
|
-
- "55432:5432"
|
|
9
|
+
- "${WARDBY_POSTGRES_PORT:-55432}:5432"
|
|
10
10
|
volumes:
|
|
11
11
|
- wardby-postgres-data:/var/lib/postgresql/data
|
|
12
12
|
healthcheck:
|
|
13
|
-
test: ["CMD-SHELL", "pg_isready -U
|
|
13
|
+
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
|
14
14
|
interval: 5s
|
|
15
15
|
timeout: 5s
|
|
16
16
|
retries: 12
|
package/dist/cli-help.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const CLI_USAGE = "usage:\n wardby agent create --name <n> --model <m> --prompt <p> --budget <usd> [--schedule \"<cron>\"] [--timezone <tz>] [--max-turns <n>]\n wardby agent list\n wardby agent schedule <name> --cron \"<expr>\" [--timezone <tz>] [--disable]\n wardby tool create --name <n> --description <d> --params <file> --code <file>\n wardby tool attach <tool-name> <agent-name>\n wardby tool detach <tool-name> <agent-name>\n wardby tool list [--agent <name>]\n wardby run <name>\n wardby runs [--agent <name>] [--limit N] [--status <s>]\n wardby coding preflight (JOB_LAUNCHER=docker or kubernetes)\n wardby coding cleanup --run-id <id>\n wardby scheduler [--scope default]\n wardby mcp (MCP_TRANSPORT=stdio|http selects the transport)\n wardby serve [--scope default] (mcp + scheduler + reconciler in one process; http only)\n wardby import <bundle-dir> --owner <subject> [--public] [--include-secrets --transfer-key <pem>] [--default-budget <usd>] [--dry-run] [--prefix <p>] [--on-conflict fail|skip|rename] [--allow-open-fetch]\n\noptions:\n -h, --help Show this help\n -v, --version Show the installed Wardby version";
|
|
1
|
+
export declare const CLI_USAGE = "usage:\n wardby quickstart [--provider openai|anthropic] [--model <m>] [--budget <usd>] [--client none|codex|claude|both] [--skip-demo] [--non-interactive --yes]\n wardby doctor\n wardby status\n wardby logs [--tail N] [--follow]\n wardby down [--volumes]\n wardby agent create --name <n> --model <m> --prompt <p> --budget <usd> [--schedule \"<cron>\"] [--timezone <tz>] [--max-turns <n>]\n wardby agent list\n wardby agent schedule <name> --cron \"<expr>\" [--timezone <tz>] [--disable]\n wardby tool create --name <n> --description <d> --params <file> --code <file>\n wardby tool attach <tool-name> <agent-name>\n wardby tool detach <tool-name> <agent-name>\n wardby tool list [--agent <name>]\n wardby run <name>\n wardby runs [--agent <name>] [--limit N] [--status <s>]\n wardby coding preflight (JOB_LAUNCHER=docker or kubernetes)\n wardby coding cleanup --run-id <id>\n wardby scheduler [--scope default]\n wardby mcp (MCP_TRANSPORT=stdio|http selects the transport)\n wardby serve [--scope default] (mcp + scheduler + reconciler in one process; http only)\n wardby import <bundle-dir> --owner <subject> [--public] [--include-secrets --transfer-key <pem>] [--default-budget <usd>] [--dry-run] [--prefix <p>] [--on-conflict fail|skip|rename] [--allow-open-fetch]\n\noptions:\n -h, --help Show this help\n -v, --version Show the installed Wardby version";
|
package/dist/cli-help.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
export const CLI_USAGE = `usage:
|
|
2
|
+
wardby quickstart [--provider openai|anthropic] [--model <m>] [--budget <usd>] [--client none|codex|claude|both] [--skip-demo] [--non-interactive --yes]
|
|
3
|
+
wardby doctor
|
|
4
|
+
wardby status
|
|
5
|
+
wardby logs [--tail N] [--follow]
|
|
6
|
+
wardby down [--volumes]
|
|
2
7
|
wardby agent create --name <n> --model <m> --prompt <p> --budget <usd> [--schedule "<cron>"] [--timezone <tz>] [--max-turns <n>]
|
|
3
8
|
wardby agent list
|
|
4
9
|
wardby agent schedule <name> --cron "<expr>" [--timezone <tz>] [--disable]
|
package/dist/cli.js
CHANGED
|
File without changes
|
package/dist/env.js
CHANGED
|
@@ -10,4 +10,11 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import "./core/http-runtime.js";
|
|
12
12
|
import dotenvFlow from "dotenv-flow";
|
|
13
|
-
|
|
13
|
+
import { join, resolve } from "node:path";
|
|
14
|
+
const projectDir = resolve(process.env.WARDBY_PROJECT_DIR || process.cwd());
|
|
15
|
+
// A quickstart installation owns this directory, so load it before an
|
|
16
|
+
// application's root env files. dotenv-flow never overwrites an existing
|
|
17
|
+
// process variable, preserving explicit shell/container configuration as the
|
|
18
|
+
// highest-priority source in either mode.
|
|
19
|
+
dotenvFlow.config({ path: join(projectDir, ".wardby"), silent: true });
|
|
20
|
+
dotenvFlow.config({ path: projectDir });
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type QuickstartProvider = "openai" | "anthropic";
|
|
2
|
+
export type McpClient = "none" | "codex" | "claude" | "both";
|
|
3
|
+
export interface QuickstartState {
|
|
4
|
+
version: 1;
|
|
5
|
+
projectDir: string;
|
|
6
|
+
composeProject: string;
|
|
7
|
+
postgresPort: number;
|
|
8
|
+
provider: QuickstartProvider;
|
|
9
|
+
model: string;
|
|
10
|
+
packageVersion: string;
|
|
11
|
+
createdAt: string;
|
|
12
|
+
}
|
|
13
|
+
export interface QuickstartPaths {
|
|
14
|
+
projectDir: string;
|
|
15
|
+
wardbyDir: string;
|
|
16
|
+
envFile: string;
|
|
17
|
+
stateFile: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function resolveProjectDir(cwd?: string, env?: NodeJS.ProcessEnv): string;
|
|
20
|
+
export declare function quickstartPaths(projectDir: string): QuickstartPaths;
|
|
21
|
+
export declare function composeProjectName(projectDir: string): string;
|
|
22
|
+
export declare function parseEnvFile(contents: string): Record<string, string>;
|
|
23
|
+
export declare function readQuickstartEnv(paths: QuickstartPaths): Record<string, string>;
|
|
24
|
+
export declare function writeQuickstartEnv(paths: QuickstartPaths, values: Record<string, string>): void;
|
|
25
|
+
export declare function readQuickstartState(paths: QuickstartPaths): QuickstartState | undefined;
|
|
26
|
+
export declare function writeQuickstartState(paths: QuickstartPaths, state: QuickstartState): void;
|
|
27
|
+
export declare function generatedSecret(): string;
|
|
28
|
+
export declare function ensureWardbyIgnored(projectDir: string): void;
|
|
29
|
+
export declare function defaultModel(provider: QuickstartProvider): string;
|
|
30
|
+
export declare function providerKeyName(provider: QuickstartProvider): "OPENAI_API_KEY" | "ANTHROPIC_API_KEY";
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, join, resolve } from "node:path";
|
|
4
|
+
export function resolveProjectDir(cwd = process.cwd(), env = process.env) {
|
|
5
|
+
return resolve(env.WARDBY_PROJECT_DIR || cwd);
|
|
6
|
+
}
|
|
7
|
+
export function quickstartPaths(projectDir) {
|
|
8
|
+
const wardbyDir = join(projectDir, ".wardby");
|
|
9
|
+
return {
|
|
10
|
+
projectDir,
|
|
11
|
+
wardbyDir,
|
|
12
|
+
envFile: join(wardbyDir, ".env"),
|
|
13
|
+
stateFile: join(wardbyDir, "state.json"),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function composeProjectName(projectDir) {
|
|
17
|
+
const slug = basename(projectDir)
|
|
18
|
+
.toLowerCase()
|
|
19
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
20
|
+
.replace(/^-+|-+$/g, "")
|
|
21
|
+
.slice(0, 30);
|
|
22
|
+
const digest = createHash("sha256").update(resolve(projectDir)).digest("hex").slice(0, 10);
|
|
23
|
+
return `wardby-${slug || "project"}-${digest}`;
|
|
24
|
+
}
|
|
25
|
+
export function parseEnvFile(contents) {
|
|
26
|
+
const values = {};
|
|
27
|
+
for (const rawLine of contents.split(/\r?\n/)) {
|
|
28
|
+
const line = rawLine.trim();
|
|
29
|
+
if (!line || line.startsWith("#"))
|
|
30
|
+
continue;
|
|
31
|
+
const equals = line.indexOf("=");
|
|
32
|
+
if (equals <= 0)
|
|
33
|
+
continue;
|
|
34
|
+
const key = line.slice(0, equals).trim();
|
|
35
|
+
const rawValue = line.slice(equals + 1).trim();
|
|
36
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
37
|
+
continue;
|
|
38
|
+
if (rawValue.startsWith('"') && rawValue.endsWith('"')) {
|
|
39
|
+
try {
|
|
40
|
+
values[key] = JSON.parse(rawValue);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Preserve malformed quoted values verbatim so setup never destroys them.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
values[key] = rawValue.startsWith("'") && rawValue.endsWith("'") ? rawValue.slice(1, -1) : rawValue;
|
|
48
|
+
}
|
|
49
|
+
return values;
|
|
50
|
+
}
|
|
51
|
+
export function readQuickstartEnv(paths) {
|
|
52
|
+
if (!existsSync(paths.envFile))
|
|
53
|
+
return {};
|
|
54
|
+
return parseEnvFile(readFileSync(paths.envFile, "utf8"));
|
|
55
|
+
}
|
|
56
|
+
export function writeQuickstartEnv(paths, values) {
|
|
57
|
+
mkdirSync(paths.wardbyDir, { recursive: true, mode: 0o700 });
|
|
58
|
+
const heading = [
|
|
59
|
+
"# Managed by `wardby quickstart`.",
|
|
60
|
+
"# Keep this file private; it contains local credentials and encryption keys.",
|
|
61
|
+
];
|
|
62
|
+
const lines = Object.entries(values)
|
|
63
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
64
|
+
.map(([key, value]) => `${key}=${JSON.stringify(value)}`);
|
|
65
|
+
writeFileSync(paths.envFile, `${[...heading, ...lines].join("\n")}\n`, { mode: 0o600 });
|
|
66
|
+
chmodSync(paths.wardbyDir, 0o700);
|
|
67
|
+
chmodSync(paths.envFile, 0o600);
|
|
68
|
+
}
|
|
69
|
+
export function readQuickstartState(paths) {
|
|
70
|
+
if (!existsSync(paths.stateFile))
|
|
71
|
+
return undefined;
|
|
72
|
+
const value = JSON.parse(readFileSync(paths.stateFile, "utf8"));
|
|
73
|
+
if (value.version !== 1 ||
|
|
74
|
+
typeof value.projectDir !== "string" ||
|
|
75
|
+
typeof value.composeProject !== "string" ||
|
|
76
|
+
typeof value.postgresPort !== "number" ||
|
|
77
|
+
(value.provider !== "openai" && value.provider !== "anthropic") ||
|
|
78
|
+
typeof value.model !== "string" ||
|
|
79
|
+
typeof value.packageVersion !== "string" ||
|
|
80
|
+
typeof value.createdAt !== "string") {
|
|
81
|
+
throw new Error(`invalid Wardby quickstart state: ${paths.stateFile}`);
|
|
82
|
+
}
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
export function writeQuickstartState(paths, state) {
|
|
86
|
+
mkdirSync(paths.wardbyDir, { recursive: true, mode: 0o700 });
|
|
87
|
+
writeFileSync(paths.stateFile, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
88
|
+
chmodSync(paths.stateFile, 0o600);
|
|
89
|
+
}
|
|
90
|
+
export function generatedSecret() {
|
|
91
|
+
return randomBytes(32).toString("hex");
|
|
92
|
+
}
|
|
93
|
+
export function ensureWardbyIgnored(projectDir) {
|
|
94
|
+
const gitignore = join(projectDir, ".gitignore");
|
|
95
|
+
const existing = existsSync(gitignore) ? readFileSync(gitignore, "utf8") : "";
|
|
96
|
+
if (existing.split(/\r?\n/).some((line) => line.trim() === ".wardby/"))
|
|
97
|
+
return;
|
|
98
|
+
const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
|
|
99
|
+
writeFileSync(gitignore, `${existing}${prefix}\n# Wardby local runtime and credentials\n.wardby/\n`);
|
|
100
|
+
}
|
|
101
|
+
export function defaultModel(provider) {
|
|
102
|
+
return provider === "openai" ? "gpt-5.6-luna" : "claude-haiku-4-5";
|
|
103
|
+
}
|
|
104
|
+
export function providerKeyName(provider) {
|
|
105
|
+
return provider === "openai" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY";
|
|
106
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function quickstartCommand(args: string[]): Promise<void>;
|
|
2
|
+
export declare function doctorCommand(args: string[]): Promise<void>;
|
|
3
|
+
export declare function statusCommand(args: string[]): Promise<void>;
|
|
4
|
+
export declare function logsCommand(args: string[]): Promise<void>;
|
|
5
|
+
export declare function downCommand(args: string[]): Promise<void>;
|
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { createServer } from "node:net";
|
|
4
|
+
import { readFileSync, statSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { parseArgs } from "node:util";
|
|
8
|
+
import { createInterface } from "node:readline/promises";
|
|
9
|
+
import { composeProjectName, defaultModel, ensureWardbyIgnored, generatedSecret, providerKeyName, quickstartPaths, readQuickstartEnv, readQuickstartState, resolveProjectDir, writeQuickstartEnv, writeQuickstartState, } from "./config.js";
|
|
10
|
+
const packageRoot = fileURLToPath(new URL("../../", import.meta.url));
|
|
11
|
+
const composeFile = join(packageRoot, "deploy/local/docker-compose.yml");
|
|
12
|
+
const schemaFile = join(packageRoot, "prisma/schema.prisma");
|
|
13
|
+
const wardbyBin = join(packageRoot, "bin/wardby.js");
|
|
14
|
+
const packageJson = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const DEMO_AGENT = "hello-wardby";
|
|
17
|
+
const DEMO_PROMPT = "You are the Wardby quickstart agent. Reply with a short greeting, then explain in one sentence that Wardby admitted this run against an explicit maximum budget. Do not call tools.";
|
|
18
|
+
function runCommand(command, args, options = {}) {
|
|
19
|
+
const spawnOptions = {
|
|
20
|
+
cwd: options.cwd,
|
|
21
|
+
env: options.env,
|
|
22
|
+
encoding: "utf8",
|
|
23
|
+
stdio: options.inherit ? "inherit" : "pipe",
|
|
24
|
+
};
|
|
25
|
+
const result = spawnSync(command, args, spawnOptions);
|
|
26
|
+
if (result.error) {
|
|
27
|
+
return { status: 1, stdout: result.stdout ?? "", stderr: result.error.message };
|
|
28
|
+
}
|
|
29
|
+
return { status: result.status ?? 1, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
|
30
|
+
}
|
|
31
|
+
function commandFailure(label, result) {
|
|
32
|
+
const detail = (result.stderr || result.stdout).trim();
|
|
33
|
+
throw new Error(`${label} failed${detail ? `: ${detail}` : ""}`);
|
|
34
|
+
}
|
|
35
|
+
function runtimeEnv(paths, values = readQuickstartEnv(paths)) {
|
|
36
|
+
return { ...process.env, ...values, WARDBY_PROJECT_DIR: paths.projectDir };
|
|
37
|
+
}
|
|
38
|
+
function composeArgs(paths, state, args) {
|
|
39
|
+
return [
|
|
40
|
+
"compose",
|
|
41
|
+
"--project-name",
|
|
42
|
+
state.composeProject,
|
|
43
|
+
"--env-file",
|
|
44
|
+
paths.envFile,
|
|
45
|
+
"--file",
|
|
46
|
+
composeFile,
|
|
47
|
+
...args,
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
function runCompose(paths, state, args, options = {}) {
|
|
51
|
+
return runCommand("docker", composeArgs(paths, state, args), {
|
|
52
|
+
cwd: paths.projectDir,
|
|
53
|
+
env: runtimeEnv(paths),
|
|
54
|
+
inherit: options.inherit,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function nodeMajor() {
|
|
58
|
+
return Number(process.versions.node.split(".")[0]);
|
|
59
|
+
}
|
|
60
|
+
function assertPrerequisites() {
|
|
61
|
+
if (nodeMajor() < 24)
|
|
62
|
+
throw new Error(`Node.js 24 or newer is required; found ${process.versions.node}.`);
|
|
63
|
+
const docker = runCommand("docker", ["--version"]);
|
|
64
|
+
if (docker.status !== 0)
|
|
65
|
+
throw new Error("Docker is not installed or is not on PATH.");
|
|
66
|
+
const compose = runCommand("docker", ["compose", "version"]);
|
|
67
|
+
if (compose.status !== 0)
|
|
68
|
+
throw new Error("Docker Compose v2 is required (`docker compose`).");
|
|
69
|
+
const daemon = runCommand("docker", ["info"]);
|
|
70
|
+
if (daemon.status !== 0)
|
|
71
|
+
throw new Error("Docker is installed but its daemon is not available.");
|
|
72
|
+
}
|
|
73
|
+
async function portAvailable(port) {
|
|
74
|
+
return await new Promise((resolve) => {
|
|
75
|
+
const server = createServer();
|
|
76
|
+
server.unref();
|
|
77
|
+
server.once("error", () => resolve(false));
|
|
78
|
+
server.listen({ host: "0.0.0.0", port }, () => server.close(() => resolve(true)));
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
async function selectPostgresPort(firstPort = 55432) {
|
|
82
|
+
for (let port = firstPort; port <= 55532; port += 1) {
|
|
83
|
+
if (await portAvailable(port))
|
|
84
|
+
return port;
|
|
85
|
+
}
|
|
86
|
+
throw new Error("No available local port was found between 55432 and 55532.");
|
|
87
|
+
}
|
|
88
|
+
async function startPostgres(paths, state, config) {
|
|
89
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
90
|
+
const up = runCompose(paths, state, ["up", "--detach", "--wait", "--wait-timeout", "90"]);
|
|
91
|
+
if (up.status === 0)
|
|
92
|
+
return;
|
|
93
|
+
const output = `${up.stderr}\n${up.stdout}`;
|
|
94
|
+
if (!/port is already allocated|address already in use|bind for .* failed/i.test(output)) {
|
|
95
|
+
commandFailure("PostgreSQL startup", up);
|
|
96
|
+
}
|
|
97
|
+
// Docker Desktop's port forwarder is not always visible to a host socket
|
|
98
|
+
// probe. Clean up the failed container and converge on the next port.
|
|
99
|
+
runCompose(paths, state, ["down"]);
|
|
100
|
+
state.postgresPort = await selectPostgresPort(state.postgresPort + 1);
|
|
101
|
+
config.WARDBY_POSTGRES_PORT = String(state.postgresPort);
|
|
102
|
+
config.DATABASE_URL = databaseUrl(config, state.postgresPort);
|
|
103
|
+
writeQuickstartEnv(paths, config);
|
|
104
|
+
writeQuickstartState(paths, state);
|
|
105
|
+
console.log(`! Port was occupied; retrying PostgreSQL on ${state.postgresPort}`);
|
|
106
|
+
}
|
|
107
|
+
throw new Error("PostgreSQL startup could not find a usable port after 10 attempts.");
|
|
108
|
+
}
|
|
109
|
+
function databaseUrl(config, port) {
|
|
110
|
+
const user = encodeURIComponent(config.WARDBY_POSTGRES_USER);
|
|
111
|
+
const password = encodeURIComponent(config.WARDBY_POSTGRES_PASSWORD);
|
|
112
|
+
const database = encodeURIComponent(config.WARDBY_POSTGRES_DB);
|
|
113
|
+
return `postgresql://${user}:${password}@127.0.0.1:${port}/${database}`;
|
|
114
|
+
}
|
|
115
|
+
async function promptLine(question, fallback) {
|
|
116
|
+
const terminal = createInterface({ input: process.stdin, output: process.stdout });
|
|
117
|
+
try {
|
|
118
|
+
const answer = (await terminal.question(question)).trim();
|
|
119
|
+
return answer || fallback || "";
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
terminal.close();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async function promptYesNo(question, defaultYes) {
|
|
126
|
+
const suffix = defaultYes ? " [Y/n] " : " [y/N] ";
|
|
127
|
+
const answer = (await promptLine(`${question}${suffix}`)).toLowerCase();
|
|
128
|
+
if (!answer)
|
|
129
|
+
return defaultYes;
|
|
130
|
+
return answer === "y" || answer === "yes";
|
|
131
|
+
}
|
|
132
|
+
async function promptSecret(question) {
|
|
133
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY || !process.stdin.setRawMode) {
|
|
134
|
+
throw new Error("A provider key is required in the environment when stdin is not an interactive terminal.");
|
|
135
|
+
}
|
|
136
|
+
process.stdout.write(question);
|
|
137
|
+
process.stdin.setRawMode(true);
|
|
138
|
+
process.stdin.resume();
|
|
139
|
+
process.stdin.setEncoding("utf8");
|
|
140
|
+
return await new Promise((resolve, reject) => {
|
|
141
|
+
let value = "";
|
|
142
|
+
const finish = (error) => {
|
|
143
|
+
process.stdin.off("data", onData);
|
|
144
|
+
process.stdin.setRawMode(false);
|
|
145
|
+
process.stdin.pause();
|
|
146
|
+
process.stdout.write("\n");
|
|
147
|
+
if (error)
|
|
148
|
+
reject(error);
|
|
149
|
+
else
|
|
150
|
+
resolve(value);
|
|
151
|
+
};
|
|
152
|
+
const onData = (chunk) => {
|
|
153
|
+
for (const character of chunk) {
|
|
154
|
+
if (character === "\u0003")
|
|
155
|
+
return finish(new Error("cancelled"));
|
|
156
|
+
if (character === "\r" || character === "\n")
|
|
157
|
+
return finish();
|
|
158
|
+
if (character === "\u007f" || character === "\b")
|
|
159
|
+
value = value.slice(0, -1);
|
|
160
|
+
else if (character >= " ")
|
|
161
|
+
value += character;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
process.stdin.on("data", onData);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
function parseProvider(value) {
|
|
168
|
+
if (value === undefined)
|
|
169
|
+
return undefined;
|
|
170
|
+
if (value !== "openai" && value !== "anthropic") {
|
|
171
|
+
throw new Error(`--provider must be openai or anthropic; got "${value}".`);
|
|
172
|
+
}
|
|
173
|
+
return value;
|
|
174
|
+
}
|
|
175
|
+
function parseClient(value) {
|
|
176
|
+
if (value === undefined)
|
|
177
|
+
return undefined;
|
|
178
|
+
if (!["none", "codex", "claude", "both"].includes(value)) {
|
|
179
|
+
throw new Error(`--client must be none, codex, claude, or both; got "${value}".`);
|
|
180
|
+
}
|
|
181
|
+
return value;
|
|
182
|
+
}
|
|
183
|
+
async function chooseProvider(requested, previous, existing, nonInteractive) {
|
|
184
|
+
if (requested)
|
|
185
|
+
return requested;
|
|
186
|
+
if (previous)
|
|
187
|
+
return previous.provider;
|
|
188
|
+
if (existing.LLM_PROVIDER === "openai" || existing.LLM_PROVIDER === "anthropic")
|
|
189
|
+
return existing.LLM_PROVIDER;
|
|
190
|
+
if (process.env.OPENAI_API_KEY || existing.OPENAI_API_KEY)
|
|
191
|
+
return "openai";
|
|
192
|
+
if (process.env.ANTHROPIC_API_KEY || existing.ANTHROPIC_API_KEY)
|
|
193
|
+
return "anthropic";
|
|
194
|
+
if (nonInteractive)
|
|
195
|
+
throw new Error("--provider is required in non-interactive mode.");
|
|
196
|
+
const answer = (await promptLine("Model provider: [1] OpenAI, [2] Anthropic [1] ", "1")).toLowerCase();
|
|
197
|
+
if (answer === "1" || answer === "openai")
|
|
198
|
+
return "openai";
|
|
199
|
+
if (answer === "2" || answer === "anthropic")
|
|
200
|
+
return "anthropic";
|
|
201
|
+
throw new Error(`Unknown provider selection "${answer}".`);
|
|
202
|
+
}
|
|
203
|
+
function prismaCliPath() {
|
|
204
|
+
return require.resolve("prisma/build/index.js");
|
|
205
|
+
}
|
|
206
|
+
function applyMigrations(paths) {
|
|
207
|
+
const result = runCommand(process.execPath, [prismaCliPath(), "migrate", "deploy", "--schema", schemaFile], {
|
|
208
|
+
cwd: paths.projectDir,
|
|
209
|
+
env: runtimeEnv(paths),
|
|
210
|
+
});
|
|
211
|
+
if (result.status !== 0)
|
|
212
|
+
commandFailure("Database migration", result);
|
|
213
|
+
}
|
|
214
|
+
async function seedDemo(paths, state, budget) {
|
|
215
|
+
const previousDatabaseUrl = process.env.DATABASE_URL;
|
|
216
|
+
process.env.DATABASE_URL = readQuickstartEnv(paths).DATABASE_URL;
|
|
217
|
+
const { PrismaClient } = await import("@prisma/client");
|
|
218
|
+
const db = new PrismaClient();
|
|
219
|
+
try {
|
|
220
|
+
const existing = await db.agent.findUnique({ where: { name: DEMO_AGENT } });
|
|
221
|
+
if (existing && existing.systemPrompt !== DEMO_PROMPT) {
|
|
222
|
+
throw new Error(`An agent named "${DEMO_AGENT}" already exists and was not created by quickstart; rename it or use --skip-demo.`);
|
|
223
|
+
}
|
|
224
|
+
if (existing) {
|
|
225
|
+
await db.agent.update({
|
|
226
|
+
where: { name: DEMO_AGENT },
|
|
227
|
+
data: { model: state.model, budgetUsd: budget, maxTurns: 2 },
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
await db.agent.create({
|
|
232
|
+
data: {
|
|
233
|
+
name: DEMO_AGENT,
|
|
234
|
+
model: state.model,
|
|
235
|
+
budgetUsd: budget,
|
|
236
|
+
maxTurns: 2,
|
|
237
|
+
systemPrompt: DEMO_PROMPT,
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
await db.$disconnect();
|
|
244
|
+
if (previousDatabaseUrl === undefined)
|
|
245
|
+
delete process.env.DATABASE_URL;
|
|
246
|
+
else
|
|
247
|
+
process.env.DATABASE_URL = previousDatabaseUrl;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function runDemo(paths) {
|
|
251
|
+
const result = runCommand(process.execPath, [wardbyBin, "run", DEMO_AGENT], {
|
|
252
|
+
cwd: paths.projectDir,
|
|
253
|
+
env: runtimeEnv(paths),
|
|
254
|
+
inherit: true,
|
|
255
|
+
});
|
|
256
|
+
if (result.status !== 0)
|
|
257
|
+
throw new Error("The sample agent run failed; run `wardby doctor` for diagnostics.");
|
|
258
|
+
}
|
|
259
|
+
function clientInstalled(command) {
|
|
260
|
+
return runCommand(command, ["--version"]).status === 0;
|
|
261
|
+
}
|
|
262
|
+
function configureOneMcpClient(client, paths) {
|
|
263
|
+
if (!clientInstalled(client)) {
|
|
264
|
+
console.warn(`! ${client} is not installed; skipped its MCP configuration.`);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const existing = runCommand(client, ["mcp", "get", "wardby"], { cwd: paths.projectDir });
|
|
268
|
+
if (existing.status === 0) {
|
|
269
|
+
console.log(`✓ ${client} already has an MCP server named wardby.`);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const packageSpec = `@wardby/cli@${packageJson.version}`;
|
|
273
|
+
const args = client === "codex"
|
|
274
|
+
? [
|
|
275
|
+
"mcp",
|
|
276
|
+
"add",
|
|
277
|
+
"--env",
|
|
278
|
+
`WARDBY_PROJECT_DIR=${paths.projectDir}`,
|
|
279
|
+
"wardby",
|
|
280
|
+
"--",
|
|
281
|
+
"npx",
|
|
282
|
+
"--yes",
|
|
283
|
+
packageSpec,
|
|
284
|
+
"mcp",
|
|
285
|
+
]
|
|
286
|
+
: [
|
|
287
|
+
"mcp",
|
|
288
|
+
"add",
|
|
289
|
+
"--scope",
|
|
290
|
+
"local",
|
|
291
|
+
"-e",
|
|
292
|
+
`WARDBY_PROJECT_DIR=${paths.projectDir}`,
|
|
293
|
+
"wardby",
|
|
294
|
+
"--",
|
|
295
|
+
"npx",
|
|
296
|
+
"--yes",
|
|
297
|
+
packageSpec,
|
|
298
|
+
"mcp",
|
|
299
|
+
];
|
|
300
|
+
const result = runCommand(client, args, { cwd: paths.projectDir });
|
|
301
|
+
if (result.status !== 0)
|
|
302
|
+
commandFailure(`${client} MCP configuration`, result);
|
|
303
|
+
console.log(`✓ Configured Wardby MCP for ${client}.`);
|
|
304
|
+
}
|
|
305
|
+
function configureMcpClients(client, paths) {
|
|
306
|
+
if (client === "codex" || client === "both")
|
|
307
|
+
configureOneMcpClient("codex", paths);
|
|
308
|
+
if (client === "claude" || client === "both")
|
|
309
|
+
configureOneMcpClient("claude", paths);
|
|
310
|
+
}
|
|
311
|
+
async function chooseClient(nonInteractive) {
|
|
312
|
+
if (nonInteractive)
|
|
313
|
+
return "none";
|
|
314
|
+
const answer = (await promptLine("Configure MCP: [n]one, [c]odex, c[l]aude, [b]oth [n] ", "n")).toLowerCase();
|
|
315
|
+
if (answer === "" || answer === "n" || answer === "none")
|
|
316
|
+
return "none";
|
|
317
|
+
if (answer === "c" || answer === "codex")
|
|
318
|
+
return "codex";
|
|
319
|
+
if (answer === "l" || answer === "claude")
|
|
320
|
+
return "claude";
|
|
321
|
+
if (answer === "b" || answer === "both")
|
|
322
|
+
return "both";
|
|
323
|
+
throw new Error(`Unknown MCP client selection "${answer}".`);
|
|
324
|
+
}
|
|
325
|
+
export async function quickstartCommand(args) {
|
|
326
|
+
const { values } = parseArgs({
|
|
327
|
+
args,
|
|
328
|
+
options: {
|
|
329
|
+
provider: { type: "string" },
|
|
330
|
+
model: { type: "string" },
|
|
331
|
+
budget: { type: "string" },
|
|
332
|
+
client: { type: "string" },
|
|
333
|
+
"non-interactive": { type: "boolean" },
|
|
334
|
+
"skip-demo": { type: "boolean" },
|
|
335
|
+
yes: { type: "boolean", short: "y" },
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
const nonInteractive = values["non-interactive"] ?? false;
|
|
339
|
+
const skipDemo = values["skip-demo"] ?? false;
|
|
340
|
+
const budget = values.budget === undefined ? 1 : Number(values.budget);
|
|
341
|
+
if (!Number.isFinite(budget) || budget <= 0)
|
|
342
|
+
throw new Error(`--budget must be positive; got "${values.budget}".`);
|
|
343
|
+
if (nonInteractive && !skipDemo && !values.yes) {
|
|
344
|
+
throw new Error("Non-interactive demo execution requires --yes because it may incur provider charges.");
|
|
345
|
+
}
|
|
346
|
+
console.log("Wardby quickstart\n");
|
|
347
|
+
assertPrerequisites();
|
|
348
|
+
console.log(`✓ Node.js ${process.versions.node}`);
|
|
349
|
+
console.log("✓ Docker and Docker Compose are available");
|
|
350
|
+
const projectDir = resolveProjectDir();
|
|
351
|
+
const paths = quickstartPaths(projectDir);
|
|
352
|
+
const previous = readQuickstartState(paths);
|
|
353
|
+
const existing = readQuickstartEnv(paths);
|
|
354
|
+
const provider = await chooseProvider(parseProvider(values.provider), previous, existing, nonInteractive);
|
|
355
|
+
const model = values.model ?? (previous?.provider === provider ? previous.model : defaultModel(provider));
|
|
356
|
+
const keyName = providerKeyName(provider);
|
|
357
|
+
let providerKey = process.env[keyName] || existing[keyName];
|
|
358
|
+
if (!providerKey && !skipDemo) {
|
|
359
|
+
if (nonInteractive)
|
|
360
|
+
throw new Error(`${keyName} is required in non-interactive mode unless --skip-demo is used.`);
|
|
361
|
+
providerKey = await promptSecret(`${keyName}: `);
|
|
362
|
+
if (!providerKey)
|
|
363
|
+
throw new Error(`${keyName} cannot be empty.`);
|
|
364
|
+
}
|
|
365
|
+
const postgresPort = previous?.postgresPort ?? (await selectPostgresPort());
|
|
366
|
+
const postgresUser = existing.WARDBY_POSTGRES_USER || "wardby";
|
|
367
|
+
const postgresDatabase = existing.WARDBY_POSTGRES_DB || "wardby";
|
|
368
|
+
const postgresPassword = existing.WARDBY_POSTGRES_PASSWORD || generatedSecret();
|
|
369
|
+
const config = {
|
|
370
|
+
...existing,
|
|
371
|
+
LLM_PROVIDER: provider,
|
|
372
|
+
LOCAL_PRINCIPAL: existing.LOCAL_PRINCIPAL || "local",
|
|
373
|
+
MCP_TRANSPORT: "stdio",
|
|
374
|
+
SECRET_APP_KEY: existing.SECRET_APP_KEY || generatedSecret(),
|
|
375
|
+
WARDBY_POSTGRES_DB: postgresDatabase,
|
|
376
|
+
WARDBY_POSTGRES_PASSWORD: postgresPassword,
|
|
377
|
+
WARDBY_POSTGRES_PORT: String(postgresPort),
|
|
378
|
+
WARDBY_POSTGRES_USER: postgresUser,
|
|
379
|
+
};
|
|
380
|
+
config.DATABASE_URL = databaseUrl(config, postgresPort);
|
|
381
|
+
if (providerKey)
|
|
382
|
+
config[keyName] = providerKey;
|
|
383
|
+
ensureWardbyIgnored(projectDir);
|
|
384
|
+
writeQuickstartEnv(paths, config);
|
|
385
|
+
const state = {
|
|
386
|
+
version: 1,
|
|
387
|
+
projectDir,
|
|
388
|
+
composeProject: previous?.composeProject ?? composeProjectName(projectDir),
|
|
389
|
+
postgresPort,
|
|
390
|
+
provider,
|
|
391
|
+
model,
|
|
392
|
+
packageVersion: packageJson.version,
|
|
393
|
+
createdAt: previous?.createdAt ?? new Date().toISOString(),
|
|
394
|
+
};
|
|
395
|
+
writeQuickstartState(paths, state);
|
|
396
|
+
console.log(`✓ Configuration ready in ${paths.wardbyDir}`);
|
|
397
|
+
await startPostgres(paths, state, config);
|
|
398
|
+
console.log(`✓ PostgreSQL is healthy on 127.0.0.1:${state.postgresPort}`);
|
|
399
|
+
applyMigrations(paths);
|
|
400
|
+
console.log("✓ Database migrations applied");
|
|
401
|
+
if (!skipDemo) {
|
|
402
|
+
await seedDemo(paths, state, budget);
|
|
403
|
+
console.log(`✓ Sample agent "${DEMO_AGENT}" is ready`);
|
|
404
|
+
const approved = values.yes || (await promptYesNo(`Run it now with a maximum budget of $${budget.toFixed(2)}?`, true));
|
|
405
|
+
if (approved)
|
|
406
|
+
runDemo(paths);
|
|
407
|
+
else
|
|
408
|
+
console.log(`Skipped the billed run. Start it later with: npx @wardby/cli@latest run ${DEMO_AGENT}`);
|
|
409
|
+
}
|
|
410
|
+
const client = parseClient(values.client) ?? (await chooseClient(nonInteractive));
|
|
411
|
+
if (client !== "none")
|
|
412
|
+
configureMcpClients(client, paths);
|
|
413
|
+
console.log("\nWardby is ready.");
|
|
414
|
+
console.log(" npx @wardby/cli@latest status");
|
|
415
|
+
console.log(" npx @wardby/cli@latest doctor");
|
|
416
|
+
console.log(" npx @wardby/cli@latest down");
|
|
417
|
+
}
|
|
418
|
+
async function databaseHealthy(paths) {
|
|
419
|
+
const env = readQuickstartEnv(paths);
|
|
420
|
+
if (!env.DATABASE_URL)
|
|
421
|
+
return false;
|
|
422
|
+
const previous = process.env.DATABASE_URL;
|
|
423
|
+
process.env.DATABASE_URL = env.DATABASE_URL;
|
|
424
|
+
const { PrismaClient } = await import("@prisma/client");
|
|
425
|
+
const db = new PrismaClient();
|
|
426
|
+
try {
|
|
427
|
+
await db.$queryRawUnsafe("SELECT 1");
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
finally {
|
|
434
|
+
await db.$disconnect();
|
|
435
|
+
if (previous === undefined)
|
|
436
|
+
delete process.env.DATABASE_URL;
|
|
437
|
+
else
|
|
438
|
+
process.env.DATABASE_URL = previous;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
export async function doctorCommand(args) {
|
|
442
|
+
parseArgs({ args, options: {} });
|
|
443
|
+
const paths = quickstartPaths(resolveProjectDir());
|
|
444
|
+
const checks = [];
|
|
445
|
+
checks.push(["Node.js 24 or newer", nodeMajor() >= 24, process.versions.node]);
|
|
446
|
+
checks.push(["Docker CLI", runCommand("docker", ["--version"]).status === 0]);
|
|
447
|
+
checks.push(["Docker Compose v2", runCommand("docker", ["compose", "version"]).status === 0]);
|
|
448
|
+
checks.push(["Docker daemon", runCommand("docker", ["info"]).status === 0]);
|
|
449
|
+
let state;
|
|
450
|
+
try {
|
|
451
|
+
state = readQuickstartState(paths);
|
|
452
|
+
checks.push(["Quickstart state", state !== undefined, paths.stateFile]);
|
|
453
|
+
}
|
|
454
|
+
catch (error) {
|
|
455
|
+
checks.push(["Quickstart state", false, error instanceof Error ? error.message : String(error)]);
|
|
456
|
+
}
|
|
457
|
+
const env = readQuickstartEnv(paths);
|
|
458
|
+
const key = state ? providerKeyName(state.provider) : undefined;
|
|
459
|
+
checks.push(["Private configuration", Object.keys(env).length > 0, paths.envFile]);
|
|
460
|
+
if (Object.keys(env).length > 0) {
|
|
461
|
+
const mode = statSync(paths.envFile).mode & 0o777;
|
|
462
|
+
checks.push(["Configuration permissions", (mode & 0o077) === 0, mode.toString(8)]);
|
|
463
|
+
checks.push(["Secret encryption key", /^[a-f0-9]{64}$/i.test(env.SECRET_APP_KEY ?? "")]);
|
|
464
|
+
if (key)
|
|
465
|
+
checks.push([`${key} configured`, Boolean(env[key] || process.env[key])]);
|
|
466
|
+
}
|
|
467
|
+
if (state) {
|
|
468
|
+
const ps = runCompose(paths, state, ["ps", "--status", "running", "--quiet"]);
|
|
469
|
+
checks.push(["PostgreSQL container", ps.status === 0 && ps.stdout.trim().length > 0]);
|
|
470
|
+
checks.push(["Database connection", await databaseHealthy(paths)]);
|
|
471
|
+
const migrations = runCommand(process.execPath, [prismaCliPath(), "migrate", "status", "--schema", schemaFile], {
|
|
472
|
+
cwd: paths.projectDir,
|
|
473
|
+
env: runtimeEnv(paths),
|
|
474
|
+
});
|
|
475
|
+
checks.push(["Database migrations", migrations.status === 0]);
|
|
476
|
+
}
|
|
477
|
+
let failed = false;
|
|
478
|
+
for (const [name, ok, detail] of checks) {
|
|
479
|
+
failed ||= !ok;
|
|
480
|
+
console.log(`${ok ? "✓" : "✗"} ${name}${detail ? ` (${detail})` : ""}`);
|
|
481
|
+
}
|
|
482
|
+
if (failed)
|
|
483
|
+
process.exitCode = 1;
|
|
484
|
+
}
|
|
485
|
+
export async function statusCommand(args) {
|
|
486
|
+
parseArgs({ args, options: {} });
|
|
487
|
+
const paths = quickstartPaths(resolveProjectDir());
|
|
488
|
+
const state = readQuickstartState(paths);
|
|
489
|
+
if (!state)
|
|
490
|
+
throw new Error(`Wardby is not initialized in ${paths.projectDir}; run \`wardby quickstart\`.`);
|
|
491
|
+
console.log(`project: ${state.projectDir}`);
|
|
492
|
+
console.log(`version: ${state.packageVersion}`);
|
|
493
|
+
console.log(`provider/model: ${state.provider} / ${state.model}`);
|
|
494
|
+
console.log(`postgres: 127.0.0.1:${state.postgresPort}`);
|
|
495
|
+
const ps = runCompose(paths, state, ["ps"]);
|
|
496
|
+
if (ps.status !== 0)
|
|
497
|
+
commandFailure("Docker Compose status", ps);
|
|
498
|
+
process.stdout.write(ps.stdout);
|
|
499
|
+
const healthy = await databaseHealthy(paths);
|
|
500
|
+
console.log(`database: ${healthy ? "healthy" : "unavailable"}`);
|
|
501
|
+
if (!healthy)
|
|
502
|
+
process.exitCode = 1;
|
|
503
|
+
}
|
|
504
|
+
export async function logsCommand(args) {
|
|
505
|
+
const { values } = parseArgs({
|
|
506
|
+
args,
|
|
507
|
+
options: {
|
|
508
|
+
follow: { type: "boolean", short: "f" },
|
|
509
|
+
tail: { type: "string" },
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
const paths = quickstartPaths(resolveProjectDir());
|
|
513
|
+
const state = readQuickstartState(paths);
|
|
514
|
+
if (!state)
|
|
515
|
+
throw new Error(`Wardby is not initialized in ${paths.projectDir}; run \`wardby quickstart\`.`);
|
|
516
|
+
const composeLogArgs = ["logs", "--tail", values.tail ?? "100"];
|
|
517
|
+
if (values.follow)
|
|
518
|
+
composeLogArgs.push("--follow");
|
|
519
|
+
const result = runCompose(paths, state, composeLogArgs, { inherit: true });
|
|
520
|
+
if (result.status !== 0)
|
|
521
|
+
throw new Error("Docker Compose logs failed.");
|
|
522
|
+
}
|
|
523
|
+
export async function downCommand(args) {
|
|
524
|
+
const { values } = parseArgs({ args, options: { volumes: { type: "boolean" } } });
|
|
525
|
+
const paths = quickstartPaths(resolveProjectDir());
|
|
526
|
+
const state = readQuickstartState(paths);
|
|
527
|
+
if (!state)
|
|
528
|
+
throw new Error(`Wardby is not initialized in ${paths.projectDir}; nothing to stop.`);
|
|
529
|
+
const composeDownArgs = ["down"];
|
|
530
|
+
if (values.volumes)
|
|
531
|
+
composeDownArgs.push("--volumes");
|
|
532
|
+
const result = runCompose(paths, state, composeDownArgs, { inherit: true });
|
|
533
|
+
if (result.status !== 0)
|
|
534
|
+
throw new Error("Docker Compose shutdown failed.");
|
|
535
|
+
console.log(values.volumes ? "Wardby stopped and its local database volume was removed." : "Wardby stopped; data retained.");
|
|
536
|
+
}
|
package/dist/wardby-bin.js
CHANGED
|
@@ -8,13 +8,39 @@ function packageVersion() {
|
|
|
8
8
|
}
|
|
9
9
|
return packageJson.version;
|
|
10
10
|
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
|
|
11
|
+
async function main() {
|
|
12
|
+
const command = process.argv[2];
|
|
13
|
+
if (command === undefined || command === "--help" || command === "-h") {
|
|
14
|
+
process.stdout.write(`${CLI_USAGE}\n`);
|
|
15
|
+
}
|
|
16
|
+
else if (command === "--version" || command === "-v") {
|
|
17
|
+
process.stdout.write(`${packageVersion()}\n`);
|
|
18
|
+
}
|
|
19
|
+
else if (command === "quickstart") {
|
|
20
|
+
const { quickstartCommand } = await import("./quickstart/index.js");
|
|
21
|
+
await quickstartCommand(process.argv.slice(3));
|
|
22
|
+
}
|
|
23
|
+
else if (command === "doctor") {
|
|
24
|
+
const { doctorCommand } = await import("./quickstart/index.js");
|
|
25
|
+
await doctorCommand(process.argv.slice(3));
|
|
26
|
+
}
|
|
27
|
+
else if (command === "status") {
|
|
28
|
+
const { statusCommand } = await import("./quickstart/index.js");
|
|
29
|
+
await statusCommand(process.argv.slice(3));
|
|
30
|
+
}
|
|
31
|
+
else if (command === "logs") {
|
|
32
|
+
const { logsCommand } = await import("./quickstart/index.js");
|
|
33
|
+
await logsCommand(process.argv.slice(3));
|
|
34
|
+
}
|
|
35
|
+
else if (command === "down") {
|
|
36
|
+
const { downCommand } = await import("./quickstart/index.js");
|
|
37
|
+
await downCommand(process.argv.slice(3));
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
await import("./cli.js");
|
|
41
|
+
}
|
|
20
42
|
}
|
|
43
|
+
main().catch((error) => {
|
|
44
|
+
console.error(`error: ${error instanceof Error ? error.message : String(error)}`);
|
|
45
|
+
process.exitCode = 1;
|
|
46
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wardby/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Self-hosted control plane for budget-guarded AI agents.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://github.com/wardby/wardby#readme",
|
|
@@ -119,5 +119,8 @@
|
|
|
119
119
|
"typescript": "^5.6.0",
|
|
120
120
|
"typescript-eslint": "^8.69.0",
|
|
121
121
|
"vitest": "^4.1.11"
|
|
122
|
+
},
|
|
123
|
+
"overrides": {
|
|
124
|
+
"deepmerge-ts": "8.0.2"
|
|
122
125
|
}
|
|
123
126
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare function assertSelfHostedReleased(): void;
|