warpmetal 0.1.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 +75 -0
- package/bin/warpmetal.js +6 -0
- package/package.json +43 -0
- package/skills/warpmetal/SKILL.md +111 -0
- package/skills/warpmetal/agents/openai.yaml +4 -0
- package/skills/warpmetal/references/cli-reference.md +100 -0
- package/skills/warpmetal/references/safety.md +55 -0
- package/src/api.js +170 -0
- package/src/args.js +78 -0
- package/src/cli.js +609 -0
- package/src/errors.js +35 -0
- package/src/install-skill.js +65 -0
- package/src/ssh.js +57 -0
- package/src/state.js +200 -0
package/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# WarpMetal Agent Kit
|
|
2
|
+
|
|
3
|
+
The official command-line client and portable Agent Skill for WarpMetal.
|
|
4
|
+
|
|
5
|
+
The CLI uses the public API at `https://api.warpmetal.com`, stores generated
|
|
6
|
+
WarpMetal credentials in a user-private state file, and never reads or stores
|
|
7
|
+
wallet private keys or SSH private-key contents.
|
|
8
|
+
|
|
9
|
+
## Distribution
|
|
10
|
+
|
|
11
|
+
- Source and releases: `https://github.com/warpmetal/agent-kit`
|
|
12
|
+
- CLI: the unscoped public npm package `warpmetal`, exposing the `warpmetal`
|
|
13
|
+
executable
|
|
14
|
+
- Skill: `skills/warpmetal` in the GitHub repository and bundled inside the
|
|
15
|
+
npm package
|
|
16
|
+
|
|
17
|
+
Keeping the skill beside the CLI gives Codex, Claude, and other Agent
|
|
18
|
+
Skills-compatible tools one canonical set of safety instructions while the CLI
|
|
19
|
+
remains the stable executable API.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npm install --global warpmetal
|
|
25
|
+
warpmetal --help
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Install the bundled skill for supported coding agents:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
warpmetal agent install --target codex
|
|
32
|
+
warpmetal agent install --target claude
|
|
33
|
+
warpmetal agent install --target all
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Use `--scope project` to install into the current repository instead of the
|
|
37
|
+
user-level agent directory.
|
|
38
|
+
|
|
39
|
+
The bundled `skills/warpmetal` directory follows the portable Agent Skills
|
|
40
|
+
layout. Agents that support that layout but use another installation path can
|
|
41
|
+
consume that directory directly; they do not need a different WarpMetal API
|
|
42
|
+
integration.
|
|
43
|
+
|
|
44
|
+
## First commands
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
warpmetal health
|
|
48
|
+
warpmetal catalog
|
|
49
|
+
warpmetal order prepare \
|
|
50
|
+
--plan agent \
|
|
51
|
+
--hostname codex-workspace \
|
|
52
|
+
--os '<exact name from warpmetal catalog>' \
|
|
53
|
+
--ssh-public-key-file ~/.ssh/id_ed25519.pub
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Pass `--json` for structured, secret-redacted output. Use
|
|
57
|
+
`WARPMETAL_API_URL` for an alternate API origin and `WARPMETAL_HOME` for an
|
|
58
|
+
alternate state directory.
|
|
59
|
+
|
|
60
|
+
## Security boundary
|
|
61
|
+
|
|
62
|
+
- Order and access tokens are never printed; they are written to
|
|
63
|
+
`${WARPMETAL_HOME:-~/.config/warpmetal}/state.json` with user-only
|
|
64
|
+
permissions where the platform supports POSIX modes.
|
|
65
|
+
- The CLI passes an SSH private-key path directly to `ssh-keygen`; it never
|
|
66
|
+
reads the private key.
|
|
67
|
+
- The CLI accepts an externally produced x402 `PAYMENT-SIGNATURE` from a file.
|
|
68
|
+
Wallet key management and signing remain outside this package.
|
|
69
|
+
- Destructive or state-changing commands require explicit confirmations and
|
|
70
|
+
generate idempotency keys by default.
|
|
71
|
+
|
|
72
|
+
## Publishing status
|
|
73
|
+
|
|
74
|
+
This package is an initial development release. Choose the public-source
|
|
75
|
+
license and replace `UNLICENSED` before publishing it to npm.
|
package/bin/warpmetal.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "warpmetal",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Agent-safe CLI and skill for purchasing and managing WarpMetal VPS servers",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"warpmetal": "./bin/warpmetal.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"skills/warpmetal/",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20.0.0"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"check": "node --check bin/warpmetal.js && node --check src/api.js && node --check src/args.js && node --check src/cli.js && node --check src/errors.js && node --check src/install-skill.js && node --check src/ssh.js && node --check src/state.js",
|
|
20
|
+
"test": "node --test",
|
|
21
|
+
"prepack": "npm run check && npm test"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/warpmetal/agent-kit.git"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/warpmetal/agent-kit#readme",
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/warpmetal/agent-kit/issues"
|
|
33
|
+
},
|
|
34
|
+
"license": "UNLICENSED",
|
|
35
|
+
"keywords": [
|
|
36
|
+
"warpmetal",
|
|
37
|
+
"vps",
|
|
38
|
+
"agent-skills",
|
|
39
|
+
"codex",
|
|
40
|
+
"claude",
|
|
41
|
+
"x402"
|
|
42
|
+
]
|
|
43
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: warpmetal
|
|
3
|
+
description: Safely purchase and manage WarpMetal VPS servers with the warpmetal CLI. Use when Codex, Claude Code, Cursor, Windsurf, or another shell-capable agent needs to inspect live VPS plans and operating systems, prepare or pay for an x402 order, poll provisioning, prove ownership with an SSH key, inspect a server, or run supported lifecycle operations.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# WarpMetal
|
|
7
|
+
|
|
8
|
+
Use the `warpmetal` CLI as the executable interface. Do not reconstruct its
|
|
9
|
+
credential, idempotency, exact-body retry, SSH signing, or polling behavior
|
|
10
|
+
with ad hoc HTTP commands.
|
|
11
|
+
|
|
12
|
+
## Start safely
|
|
13
|
+
|
|
14
|
+
1. Run `warpmetal --version`.
|
|
15
|
+
2. If it is missing, ask before installing software, then install the official
|
|
16
|
+
npm package only from `https://www.npmjs.com/package/warpmetal`.
|
|
17
|
+
3. Use `--json` for every agent-driven command.
|
|
18
|
+
4. Read [references/safety.md](references/safety.md) before preparing an order,
|
|
19
|
+
authorizing payment, using an SSH identity, or changing a server.
|
|
20
|
+
5. Read [references/cli-reference.md](references/cli-reference.md) when choosing
|
|
21
|
+
a command or interpreting an exit code.
|
|
22
|
+
|
|
23
|
+
Never read, print, summarize, upload, or commit the WarpMetal state file. Never
|
|
24
|
+
read an SSH private-key file. Pass its path only to a command designed to use
|
|
25
|
+
it. Never request or handle a wallet seed phrase or private key.
|
|
26
|
+
|
|
27
|
+
## Discover before acting
|
|
28
|
+
|
|
29
|
+
Run:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
warpmetal health --json
|
|
33
|
+
warpmetal catalog --json
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Stop if `purchasingReady` is false. Select `planId` and the exact OS `name`
|
|
37
|
+
from the live catalog. Do not reuse an OS name or price from documentation or
|
|
38
|
+
a previous session.
|
|
39
|
+
|
|
40
|
+
## Prepare an order
|
|
41
|
+
|
|
42
|
+
Confirm the intended plan, exact OS, hostname, and existing SSH public-key file
|
|
43
|
+
with the user. Ask before generating a new SSH key pair.
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
warpmetal order prepare \
|
|
47
|
+
--plan <planId> \
|
|
48
|
+
--hostname <hostname> \
|
|
49
|
+
--os '<exact live OS name>' \
|
|
50
|
+
--ssh-public-key-file <public-key-path> \
|
|
51
|
+
--json
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The CLI saves the generated recovery credential privately and does not print
|
|
55
|
+
it. Preserve the reported task and server IDs in the conversation, but do not
|
|
56
|
+
open the state file to retrieve the credential.
|
|
57
|
+
|
|
58
|
+
## Authorize payment
|
|
59
|
+
|
|
60
|
+
Run `warpmetal checkout challenge --task <taskId> --json` to obtain the live
|
|
61
|
+
x402 terms. Before any wallet creation, funding, or signature, show the user
|
|
62
|
+
the exact amount, asset, network, recipient, and expiration derived from the
|
|
63
|
+
live challenge and obtain explicit approval.
|
|
64
|
+
|
|
65
|
+
Use a compatible external wallet signer to produce one `PAYMENT-SIGNATURE`
|
|
66
|
+
header value in a file. Do not pass wallet secrets to WarpMetal or the CLI.
|
|
67
|
+
After approval, submit it with:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
warpmetal checkout submit \
|
|
71
|
+
--task <taskId> \
|
|
72
|
+
--payment-signature-file <path> \
|
|
73
|
+
--wait \
|
|
74
|
+
--json
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
If the command reports a rejected signature, inspect the replacement live
|
|
78
|
+
challenge and request new approval where its terms changed. If it reports
|
|
79
|
+
`manual_review`, stop immediately and never create another payment.
|
|
80
|
+
|
|
81
|
+
## Provision and manage
|
|
82
|
+
|
|
83
|
+
Poll a prepared or paid order with:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
warpmetal order status --task <taskId> --wait --json
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
For routine management, prove possession of the installed SSH key without
|
|
90
|
+
reading it:
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
warpmetal server login --server <serverId> --identity <private-key-path> --json
|
|
94
|
+
warpmetal server get --server <serverId> --json
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
For power changes, state the intended effect and obtain explicit approval,
|
|
98
|
+
then pass the same action as the confirmation:
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
warpmetal server power \
|
|
102
|
+
--server <serverId> \
|
|
103
|
+
--action reboot \
|
|
104
|
+
--confirm reboot \
|
|
105
|
+
--wait \
|
|
106
|
+
--json
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Do not fall back to raw API calls for reload, deletion, networking, renewal,
|
|
110
|
+
or another unsupported mutation. Explain that the installed CLI version does
|
|
111
|
+
not yet expose that guarded operation.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# WarpMetal CLI reference
|
|
2
|
+
|
|
3
|
+
## Contents
|
|
4
|
+
|
|
5
|
+
- Discovery
|
|
6
|
+
- Purchase and provisioning
|
|
7
|
+
- Server management
|
|
8
|
+
- Skill installation and state
|
|
9
|
+
- Exit codes
|
|
10
|
+
|
|
11
|
+
## Discovery
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
warpmetal health --json
|
|
15
|
+
warpmetal catalog [--plan <planId>] --json
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`health` exits with code 3 when the service responds but purchasing is paused.
|
|
19
|
+
The catalog remains useful for read-only discovery.
|
|
20
|
+
|
|
21
|
+
## Purchase and provisioning
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
warpmetal order prepare \
|
|
25
|
+
--plan <live planId> \
|
|
26
|
+
--hostname <dns-label> \
|
|
27
|
+
--os '<exact live OS name>' \
|
|
28
|
+
--ssh-public-key-file <path> \
|
|
29
|
+
[--email <address>] \
|
|
30
|
+
[--idempotency-key <key>] \
|
|
31
|
+
--json
|
|
32
|
+
|
|
33
|
+
warpmetal checkout challenge --task <taskId> --json
|
|
34
|
+
|
|
35
|
+
warpmetal checkout submit \
|
|
36
|
+
--task <taskId> \
|
|
37
|
+
--payment-signature-file <path> \
|
|
38
|
+
[--wait] [--timeout-seconds <n>] \
|
|
39
|
+
--json
|
|
40
|
+
|
|
41
|
+
warpmetal order status \
|
|
42
|
+
--task <taskId> \
|
|
43
|
+
[--wait] [--timeout-seconds <n>] \
|
|
44
|
+
--json
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The payment signature file must contain one HTTP header value. The CLI does
|
|
48
|
+
not create or store wallet keys and does not sign x402 challenges.
|
|
49
|
+
|
|
50
|
+
## Server management
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
warpmetal server login \
|
|
54
|
+
--server <serverId> \
|
|
55
|
+
--identity <private-key-path> \
|
|
56
|
+
--json
|
|
57
|
+
|
|
58
|
+
warpmetal server get --server <serverId> --json
|
|
59
|
+
|
|
60
|
+
warpmetal server power \
|
|
61
|
+
--server <serverId> \
|
|
62
|
+
--action <boot|reboot|shutdown> \
|
|
63
|
+
--confirm <same-action> \
|
|
64
|
+
[--idempotency-key <key>] \
|
|
65
|
+
[--wait] [--timeout-seconds <n>] \
|
|
66
|
+
--json
|
|
67
|
+
|
|
68
|
+
warpmetal operation get \
|
|
69
|
+
--operation <operationId> \
|
|
70
|
+
[--server <serverId>] \
|
|
71
|
+
[--wait] [--timeout-seconds <n>] \
|
|
72
|
+
--json
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Use `--token-file` only for recovery when local state is unavailable. Prefer
|
|
76
|
+
`WARPMETAL_OWNER_TOKEN` or `WARPMETAL_ACCESS_TOKEN` for a single command over a
|
|
77
|
+
shell argument, because command-line arguments can be recorded in history and
|
|
78
|
+
process listings.
|
|
79
|
+
|
|
80
|
+
## Skill installation and state
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
warpmetal agent install --target <codex|claude|all> [--scope user|project]
|
|
84
|
+
warpmetal state list --json
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`state list` returns identifiers and credential-presence booleans only. Never
|
|
88
|
+
open the underlying state file from an agent session.
|
|
89
|
+
|
|
90
|
+
## Exit codes
|
|
91
|
+
|
|
92
|
+
- `0`: command completed or reached its requested safe stopping point.
|
|
93
|
+
- `1`: unexpected local or API failure.
|
|
94
|
+
- `2`: invalid command, option, input, or local state.
|
|
95
|
+
- `3`: purchasing unavailable, rate limited, or API temporarily unavailable.
|
|
96
|
+
- `4`: missing or rejected credential or SSH proof.
|
|
97
|
+
- `5`: API conflict, including an idempotency conflict.
|
|
98
|
+
- `6`: manual review; stop and do not retry the consequential action.
|
|
99
|
+
- `7`: payment authorization rejected or required; inspect the live challenge.
|
|
100
|
+
- `8`: operation still pending or wait timeout reached.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# WarpMetal safety rules
|
|
2
|
+
|
|
3
|
+
## Contents
|
|
4
|
+
|
|
5
|
+
- Authority and freshness
|
|
6
|
+
- Credential boundaries
|
|
7
|
+
- Required confirmations
|
|
8
|
+
- Retry and terminal-state rules
|
|
9
|
+
|
|
10
|
+
## Authority and freshness
|
|
11
|
+
|
|
12
|
+
- Treat `https://warpmetal.com/llms.txt`, the live catalog, and the live HTTP
|
|
13
|
+
402 challenge as authoritative in that order.
|
|
14
|
+
- Stop new purchases when `warpmetal health --json` reports
|
|
15
|
+
`purchasingReady: false`.
|
|
16
|
+
- Select an exact OS name from the chosen plan's current
|
|
17
|
+
`operatingSystems[]`. Never guess or hard-code an image version.
|
|
18
|
+
|
|
19
|
+
## Credential boundaries
|
|
20
|
+
|
|
21
|
+
- Treat the generated `ownerToken` as an offline recovery credential. Let the
|
|
22
|
+
CLI store it; never inspect the state file or include it in output.
|
|
23
|
+
- Treat an SSH-derived access token as a short-lived bearer credential. Let
|
|
24
|
+
the CLI store and refresh it.
|
|
25
|
+
- Never read or transmit an SSH private key. Pass only its filesystem path to
|
|
26
|
+
`warpmetal server login` or `ssh-keygen`.
|
|
27
|
+
- Never request, read, transmit, or store a wallet seed phrase or private key.
|
|
28
|
+
- Never put a token, payment signature, private key, or state-file content in
|
|
29
|
+
a prompt, URL, log, screenshot, source file, or shell argument.
|
|
30
|
+
|
|
31
|
+
## Required confirmations
|
|
32
|
+
|
|
33
|
+
Obtain explicit user approval immediately before:
|
|
34
|
+
|
|
35
|
+
- installing the CLI or skill;
|
|
36
|
+
- generating a new SSH key pair;
|
|
37
|
+
- creating or funding a wallet;
|
|
38
|
+
- signing or submitting an x402 payment authorization;
|
|
39
|
+
- booting, rebooting, or shutting down a server; and
|
|
40
|
+
- any destructive reload, deletion, or replacement-key operation.
|
|
41
|
+
|
|
42
|
+
An order preparation is unpaid but consumes a limited prepared-order slot.
|
|
43
|
+
Confirm the plan, hostname, OS, and public key before preparing it.
|
|
44
|
+
|
|
45
|
+
## Retry and terminal-state rules
|
|
46
|
+
|
|
47
|
+
- Reuse the same idempotency key only for the exact same logical request.
|
|
48
|
+
- For `payment_pending`, retry the exact checkout body and the exact same
|
|
49
|
+
payment signature. Do not create a replacement payment.
|
|
50
|
+
- A signed HTTP 402 rejects that signature. Use the new live challenge for one
|
|
51
|
+
replacement authorization.
|
|
52
|
+
- Treat `manual_review` as terminal. Do not pay again and do not repeat the
|
|
53
|
+
mutation.
|
|
54
|
+
- Treat HTTP 202 as accepted or pending, never as proof of success. Poll the
|
|
55
|
+
returned task or operation until a documented terminal state.
|
package/src/api.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { ApiError, CliError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_API_URL = "https://api.warpmetal.com";
|
|
4
|
+
|
|
5
|
+
function validateBaseUrl(value) {
|
|
6
|
+
let url;
|
|
7
|
+
try {
|
|
8
|
+
url = new URL(value);
|
|
9
|
+
} catch {
|
|
10
|
+
throw new CliError(`Invalid WarpMetal API URL: ${value}`, { exitCode: 2 });
|
|
11
|
+
}
|
|
12
|
+
const local = ["localhost", "127.0.0.1", "::1"].includes(url.hostname);
|
|
13
|
+
if (url.protocol !== "https:" && !(local && url.protocol === "http:")) {
|
|
14
|
+
throw new CliError("WarpMetal API URLs must use HTTPS (HTTP is allowed for localhost).", {
|
|
15
|
+
exitCode: 2,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
19
|
+
throw new CliError("WarpMetal API URLs cannot contain credentials, query, or fragment data.", {
|
|
20
|
+
exitCode: 2,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
return url.toString().replace(/\/$/, "");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseResponseBody(text) {
|
|
27
|
+
if (!text) return null;
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(text);
|
|
30
|
+
} catch {
|
|
31
|
+
return { message: text };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function headerMap(headers) {
|
|
36
|
+
return Object.fromEntries(headers.entries());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class WarpMetalClient {
|
|
40
|
+
constructor({ baseUrl, fetchImpl = globalThis.fetch, timeoutMs = 30_000 } = {}) {
|
|
41
|
+
if (typeof fetchImpl !== "function") {
|
|
42
|
+
throw new CliError("This Node.js runtime does not provide fetch().", { exitCode: 2 });
|
|
43
|
+
}
|
|
44
|
+
this.baseUrl = validateBaseUrl(baseUrl || process.env.WARPMETAL_API_URL || DEFAULT_API_URL);
|
|
45
|
+
this.fetchImpl = fetchImpl;
|
|
46
|
+
this.timeoutMs = timeoutMs;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async request(
|
|
50
|
+
method,
|
|
51
|
+
path,
|
|
52
|
+
{
|
|
53
|
+
body,
|
|
54
|
+
bodyText,
|
|
55
|
+
token,
|
|
56
|
+
idempotencyKey,
|
|
57
|
+
paymentSignature,
|
|
58
|
+
acceptStatuses = [],
|
|
59
|
+
} = {},
|
|
60
|
+
) {
|
|
61
|
+
const url = new URL(path, `${this.baseUrl}/`);
|
|
62
|
+
const headers = {
|
|
63
|
+
Accept: "application/json",
|
|
64
|
+
"User-Agent": "warpmetal-cli/0.1.0",
|
|
65
|
+
};
|
|
66
|
+
let requestBody;
|
|
67
|
+
if (bodyText !== undefined) {
|
|
68
|
+
requestBody = bodyText;
|
|
69
|
+
headers["Content-Type"] = "application/json";
|
|
70
|
+
} else if (body !== undefined) {
|
|
71
|
+
requestBody = JSON.stringify(body);
|
|
72
|
+
headers["Content-Type"] = "application/json";
|
|
73
|
+
}
|
|
74
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
75
|
+
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
|
|
76
|
+
if (paymentSignature) headers["PAYMENT-SIGNATURE"] = paymentSignature;
|
|
77
|
+
|
|
78
|
+
let response;
|
|
79
|
+
try {
|
|
80
|
+
response = await this.fetchImpl(url, {
|
|
81
|
+
method,
|
|
82
|
+
headers,
|
|
83
|
+
body: requestBody,
|
|
84
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
85
|
+
});
|
|
86
|
+
} catch (error) {
|
|
87
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
88
|
+
throw new CliError(`Could not reach ${url.origin}: ${message}`, { exitCode: 3 });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const text = await response.text();
|
|
92
|
+
const data = parseResponseBody(text);
|
|
93
|
+
const result = {
|
|
94
|
+
status: response.status,
|
|
95
|
+
data,
|
|
96
|
+
headers: headerMap(response.headers),
|
|
97
|
+
bodyText: text,
|
|
98
|
+
};
|
|
99
|
+
if (response.ok || acceptStatuses.includes(response.status)) return result;
|
|
100
|
+
|
|
101
|
+
const apiError = data?.error;
|
|
102
|
+
throw new ApiError(apiError?.message || `Request failed with HTTP ${response.status}.`, {
|
|
103
|
+
status: response.status,
|
|
104
|
+
code: apiError?.code,
|
|
105
|
+
retryAfter: response.headers.get("retry-after") || undefined,
|
|
106
|
+
body: data,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
health() {
|
|
111
|
+
return this.request("GET", "/api/health");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
catalog() {
|
|
115
|
+
return this.request("GET", "/api/catalog");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
prepareOrder(body, idempotencyKey) {
|
|
119
|
+
return this.request("POST", "/api/orders", { body, idempotencyKey });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
getTask(taskId, token) {
|
|
123
|
+
return this.request("GET", `/api/tasks/${encodeURIComponent(taskId)}`, { token });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
checkout(path, { bodyText, token, paymentSignature }) {
|
|
127
|
+
return this.request("POST", path, {
|
|
128
|
+
bodyText,
|
|
129
|
+
token,
|
|
130
|
+
paymentSignature,
|
|
131
|
+
acceptStatuses: [402, 409],
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
issueSshChallenge(serverId) {
|
|
136
|
+
return this.request(
|
|
137
|
+
"POST",
|
|
138
|
+
`/api/servers/${encodeURIComponent(serverId)}/auth/challenges`,
|
|
139
|
+
{ body: {} },
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
exchangeSshChallenge(serverId, challengeId, signature) {
|
|
144
|
+
return this.request(
|
|
145
|
+
"POST",
|
|
146
|
+
`/api/servers/${encodeURIComponent(serverId)}/auth/tokens`,
|
|
147
|
+
{ body: { challengeId, signature } },
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
getServer(serverId, token) {
|
|
152
|
+
return this.request("GET", `/api/servers/${encodeURIComponent(serverId)}`, { token });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
powerServer(serverId, action, token, idempotencyKey) {
|
|
156
|
+
return this.request("POST", `/api/servers/${encodeURIComponent(serverId)}/power`, {
|
|
157
|
+
body: { action },
|
|
158
|
+
token,
|
|
159
|
+
idempotencyKey,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
getOperation(operationId, token) {
|
|
164
|
+
return this.request("GET", `/api/operations/${encodeURIComponent(operationId)}`, {
|
|
165
|
+
token,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export { DEFAULT_API_URL };
|
package/src/args.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { CliError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
export function parseArguments(argv) {
|
|
4
|
+
const positionals = [];
|
|
5
|
+
const options = {};
|
|
6
|
+
|
|
7
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
8
|
+
const value = argv[index];
|
|
9
|
+
if (value === "--") {
|
|
10
|
+
positionals.push(...argv.slice(index + 1));
|
|
11
|
+
break;
|
|
12
|
+
}
|
|
13
|
+
if (!value.startsWith("--")) {
|
|
14
|
+
positionals.push(value);
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (value.startsWith("--no-")) {
|
|
19
|
+
options[value.slice(5)] = false;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const equals = value.indexOf("=");
|
|
24
|
+
if (equals !== -1) {
|
|
25
|
+
options[value.slice(2, equals)] = value.slice(equals + 1);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const name = value.slice(2);
|
|
30
|
+
const next = argv[index + 1];
|
|
31
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
32
|
+
options[name] = next;
|
|
33
|
+
index += 1;
|
|
34
|
+
} else {
|
|
35
|
+
options[name] = true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { positionals, options };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function stringOption(options, name, { required = false } = {}) {
|
|
43
|
+
const value = options[name];
|
|
44
|
+
if (value === undefined && !required) return undefined;
|
|
45
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
46
|
+
throw new CliError(`--${name} requires a value.`, { exitCode: 2 });
|
|
47
|
+
}
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function booleanOption(options, name) {
|
|
52
|
+
const value = options[name];
|
|
53
|
+
if (value === undefined) return false;
|
|
54
|
+
if (typeof value !== "boolean") {
|
|
55
|
+
throw new CliError(`--${name} does not take a value.`, { exitCode: 2 });
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function integerOption(options, name, fallback) {
|
|
61
|
+
const value = options[name];
|
|
62
|
+
if (value === undefined) return fallback;
|
|
63
|
+
if (typeof value !== "string" || !/^[1-9]\d*$/.test(value)) {
|
|
64
|
+
throw new CliError(`--${name} must be a positive integer.`, { exitCode: 2 });
|
|
65
|
+
}
|
|
66
|
+
const number = Number(value);
|
|
67
|
+
if (!Number.isSafeInteger(number)) {
|
|
68
|
+
throw new CliError(`--${name} is too large.`, { exitCode: 2 });
|
|
69
|
+
}
|
|
70
|
+
return number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function rejectUnknownOptions(options, allowed) {
|
|
74
|
+
const unknown = Object.keys(options).filter((name) => !allowed.includes(name));
|
|
75
|
+
if (unknown.length > 0) {
|
|
76
|
+
throw new CliError(`Unknown option: --${unknown[0]}`, { exitCode: 2 });
|
|
77
|
+
}
|
|
78
|
+
}
|