@devmarketplacenpm/devmp 0.1.1-beta.5
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 +208 -0
- package/bin/devmp.js +245 -0
- package/lib/api.js +231 -0
- package/lib/browser.js +21 -0
- package/lib/checkpoints.js +292 -0
- package/lib/command-runner.js +368 -0
- package/lib/commands.js +597 -0
- package/lib/completion.js +190 -0
- package/lib/config.js +172 -0
- package/lib/diff.js +216 -0
- package/lib/executor.js +208 -0
- package/lib/git.js +39 -0
- package/lib/instructions.js +69 -0
- package/lib/interactive-tunnel.js +420 -0
- package/lib/interactive.js +1269 -0
- package/lib/markdown.js +201 -0
- package/lib/mentions.js +68 -0
- package/lib/prompt.js +140 -0
- package/lib/routes.js +27 -0
- package/lib/session.js +107 -0
- package/lib/status.js +249 -0
- package/lib/tty/ansi.js +171 -0
- package/lib/tty/composer.js +680 -0
- package/lib/tty/screen.js +167 -0
- package/lib/ui.js +174 -0
- package/lib/version.js +134 -0
- package/lib/workspace.js +608 -0
- package/lib/ws-run.js +142 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# devmp — DevMarketplace CLI
|
|
2
|
+
|
|
3
|
+
A terminal coding agent, like Claude Code / Codex — but the **agent brain runs on
|
|
4
|
+
the DevMarketplace server**, and the **files land on your machine**.
|
|
5
|
+
|
|
6
|
+
- The server runs the agent over a WebSocket tunnel and streams back what to do.
|
|
7
|
+
Prompts, model provider keys, and agent logic never leave the backend.
|
|
8
|
+
- The CLI is the "hands": it snapshots your folder, sends the request, and writes
|
|
9
|
+
the files the server produces to your local disk (sandboxed to the workspace).
|
|
10
|
+
- Login reuses the existing `cli-auth` device flow (browser approval).
|
|
11
|
+
|
|
12
|
+
No build step, no dependencies — just Node 22+.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm i -g @devmarketplacenpm/devmp@beta # testers, current builds
|
|
18
|
+
npm i -g @devmarketplacenpm/devmp # once a stable release is out
|
|
19
|
+
|
|
20
|
+
devmp login # opens the browser approval page
|
|
21
|
+
devmp doctor # confirms Node, API and session
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Node 22 or newer — the tunnel uses the global `WebSocket`. On an older Node the
|
|
25
|
+
CLI says so and stops rather than failing part-way through a turn.
|
|
26
|
+
|
|
27
|
+
Working on the CLI itself instead? Clone the repo and run `node bin/devmp.js
|
|
28
|
+
<command>`, or `npm link` to put `devmp` on your PATH.
|
|
29
|
+
|
|
30
|
+
## Project instructions
|
|
31
|
+
|
|
32
|
+
Drop an `AGENTS.md` in your workspace and the agent follows it on every turn —
|
|
33
|
+
conventions, build steps, the things newcomers get wrong:
|
|
34
|
+
|
|
35
|
+
```markdown
|
|
36
|
+
# Project conventions
|
|
37
|
+
|
|
38
|
+
1. Every JavaScript file starts with `// devmp-conventions-v1`.
|
|
39
|
+
2. Use 4-space indentation.
|
|
40
|
+
3. Export with `module.exports` at the bottom of the file.
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`DEVMP.md`, `CLAUDE.md` and `.devmp.md` are accepted too, in that order, so a
|
|
44
|
+
repo that already has one does not need a new file. The first 8 KB are sent, and
|
|
45
|
+
the CLI prints which file it picked up. Without one nothing changes.
|
|
46
|
+
|
|
47
|
+
Don't have one? Run `/init` in the session and the agent reads the project and
|
|
48
|
+
writes it for you. It refuses to overwrite a file you already wrote — use
|
|
49
|
+
`/init update` when you want it revised against the current code.
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# 1. Log in (opens the browser approval page).
|
|
55
|
+
devmp login
|
|
56
|
+
|
|
57
|
+
# 2. Open a persistent coding session in the current folder.
|
|
58
|
+
devmp
|
|
59
|
+
|
|
60
|
+
# Then ask questions, plan, implement, and follow up:
|
|
61
|
+
devmp [auto] › explain this repository
|
|
62
|
+
devmp [auto] › add input validation and run the tests
|
|
63
|
+
|
|
64
|
+
# Or run one script-friendly task without the interactive shell.
|
|
65
|
+
mkdir demo && cd demo
|
|
66
|
+
devmp run "build a tiny express hello world api"
|
|
67
|
+
|
|
68
|
+
# 3. Extend existing code — the agent reads your files first.
|
|
69
|
+
devmp run "add a /health route that returns { ok: true }"
|
|
70
|
+
|
|
71
|
+
devmp status # account + token balance
|
|
72
|
+
devmp doctor # environment, session and reachability
|
|
73
|
+
devmp --version # the installed version (works in pipes and CI)
|
|
74
|
+
|
|
75
|
+
# The prompt can be piped in, for scripts and heredocs:
|
|
76
|
+
echo "add a /health route" | devmp run
|
|
77
|
+
devmp logout
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Inside an interactive session:
|
|
81
|
+
|
|
82
|
+
```text
|
|
83
|
+
/status
|
|
84
|
+
/mode auto|chat|plan|act
|
|
85
|
+
/plan [optional prompt]
|
|
86
|
+
/model [provider] [model]
|
|
87
|
+
/permissions ask|edits|commands|auto
|
|
88
|
+
/diff
|
|
89
|
+
/undo
|
|
90
|
+
/new
|
|
91
|
+
/resume
|
|
92
|
+
/help
|
|
93
|
+
/quit
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`chat` and `plan` are read-only. Ctrl-C cancels the active model turn and local
|
|
97
|
+
command, then returns to the prompt instead of exiting the session.
|
|
98
|
+
|
|
99
|
+
Interactive changes are diff-reviewed by default. An approval can apply once or
|
|
100
|
+
allow ordinary edits for the session; command approval can also allow one exact
|
|
101
|
+
command for the session. Delete and move always ask, even in `auto` permissions.
|
|
102
|
+
Each turn is checkpointed before accepted mutations: `/diff` reviews the latest
|
|
103
|
+
agent change and `/undo` restores it only when the files have not been edited
|
|
104
|
+
again afterward. `/status` includes the current Git branch/change count when the
|
|
105
|
+
workspace is a Git worktree.
|
|
106
|
+
|
|
107
|
+
## Environments
|
|
108
|
+
|
|
109
|
+
An environment is a **pair** of URLs — API and approval page — and they are always
|
|
110
|
+
resolved together, so you can never log in against one and approve on the other:
|
|
111
|
+
|
|
112
|
+
| | API | Approval page |
|
|
113
|
+
|---|---|---|
|
|
114
|
+
| *(default)* | `api-prod.devmarketplace.com` | `beta.devmarketplace.com` |
|
|
115
|
+
| `--env dev` | `api-dev.devmarketplace.com` | `dev.devmarketplace.com` |
|
|
116
|
+
| `--local` (= `--env local`) | `localhost:8000` | `localhost:3000` |
|
|
117
|
+
|
|
118
|
+
An unrecognised name falls back to production rather than failing, so a typo
|
|
119
|
+
cannot strand you with no usable target. `--api-base-url` / `DEVMP_API_BASE_URL`
|
|
120
|
+
still override a single URL when you need that.
|
|
121
|
+
|
|
122
|
+
## In the session
|
|
123
|
+
|
|
124
|
+
The input box is a real editor, not a `readline` prompt.
|
|
125
|
+
|
|
126
|
+
| | |
|
|
127
|
+
|---|---|
|
|
128
|
+
| `Tab` | Completes a `/command` or an `@path` |
|
|
129
|
+
| `Enter` | Sends — or queues a follow-up while the agent is working |
|
|
130
|
+
| `Ctrl-J` | Newline without sending |
|
|
131
|
+
| `↑` / `↓` | Earlier prompts, including from previous sessions |
|
|
132
|
+
| `Esc` | Clears the input, or the queue |
|
|
133
|
+
| `Ctrl-C` | Cancels the turn; twice on an empty line exits |
|
|
134
|
+
|
|
135
|
+
Paste freely. A stack trace pasted straight into the box stays **one** prompt —
|
|
136
|
+
its newlines are text, not twelve separate questions billed one at a time. Large
|
|
137
|
+
pastes scroll inside the box instead of pushing the conversation off screen.
|
|
138
|
+
|
|
139
|
+
`/help` lists the commands. The ones worth knowing first are `/init` (teach the
|
|
140
|
+
agent about this project), `/diff` (what the last turn changed) and `/undo`.
|
|
141
|
+
|
|
142
|
+
## Settings
|
|
143
|
+
|
|
144
|
+
Stop retyping flags. A `.devmp.json` next to your code, or
|
|
145
|
+
`~/.devmarketplace/config.json` for every project:
|
|
146
|
+
|
|
147
|
+
```json
|
|
148
|
+
{
|
|
149
|
+
"local": true,
|
|
150
|
+
"provider": "anthropic",
|
|
151
|
+
"yes": true,
|
|
152
|
+
"allowCommands": true
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
A flag typed now always beats a file written earlier, and the environment sits
|
|
157
|
+
between the two. An unreadable file is reported and skipped, never fatal.
|
|
158
|
+
|
|
159
|
+
## Pointing at files
|
|
160
|
+
|
|
161
|
+
Name a file with `@` and it goes into the turn immediately, instead of the agent
|
|
162
|
+
spending a round trip deciding it needs to look:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
devmp run "why does @src/cart.js round twice?"
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Only real files in the workspace expand, so `x@example.com`, `@scope/package`
|
|
169
|
+
and `@Injectable` are left exactly as written. Secrets are never attached.
|
|
170
|
+
|
|
171
|
+
## Updates
|
|
172
|
+
|
|
173
|
+
The agent runs on the server, so most changes reach you the moment the backend
|
|
174
|
+
deploys — there is nothing to install. Only this client ships through npm, and
|
|
175
|
+
prereleases go to the `beta` dist-tag so `npm i -g @devmarketplacenpm/devmp`
|
|
176
|
+
stays on the last stable build.
|
|
177
|
+
|
|
178
|
+
The server reports the newest published client and the oldest one it still
|
|
179
|
+
speaks to. A newer version shows a one-line notice at the end of a session; a
|
|
180
|
+
client below the minimum refuses to start and says so, rather than failing
|
|
181
|
+
part-way through a turn.
|
|
182
|
+
|
|
183
|
+
## How it works
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
devmp ─────────► WebSocket /api/platform/v1/agent/ws
|
|
187
|
+
│ │
|
|
188
|
+
│ repeated turns │ server-side agent + conversation history
|
|
189
|
+
│◄────────────────►│
|
|
190
|
+
│ fs/cmd requests
|
|
191
|
+
▼
|
|
192
|
+
local workspace + shell
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
The login session is stored at `~/.devmarketplace/cli-auth.json`. Visible
|
|
196
|
+
conversation history is stored per API + workspace at
|
|
197
|
+
`~/.devmarketplace/cli-conversations.json` so the backend session can be
|
|
198
|
+
rehydrated after a restart. Agent prompts, tool design, provider keys, and hidden
|
|
199
|
+
logic remain on the backend.
|
|
200
|
+
|
|
201
|
+
File recovery checkpoints are stored locally at
|
|
202
|
+
`~/.devmarketplace/cli-checkpoints.json` with mode `0600`; checkpoint contents
|
|
203
|
+
are not sent to the backend. `/undo` covers regular text-file changes, not side
|
|
204
|
+
effects caused by an approved shell command such as package installs or database
|
|
205
|
+
updates.
|
|
206
|
+
|
|
207
|
+
`devmp run "..."` remains available. It uses the live WebSocket tunnel by
|
|
208
|
+
default and `--snapshot` for the older HTTP snapshot fallback.
|
package/bin/devmp.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const { resolveConfig } = require("../lib/config");
|
|
5
|
+
const { warn: warnUser } = require("../lib/ui");
|
|
6
|
+
const commands = require("../lib/commands");
|
|
7
|
+
const { color, banner, fail } = require("../lib/ui");
|
|
8
|
+
|
|
9
|
+
// Node 22 is the floor: the tunnel uses the global WebSocket, and `engines` only
|
|
10
|
+
// warns during `npm i -g` — it does not stop the install. Without this check a
|
|
11
|
+
// user on Node 20 gets "WebSocket is not defined" from deep inside a run and
|
|
12
|
+
// concludes the product is broken. `--version`, `--help` and `doctor` still work,
|
|
13
|
+
// because those are what someone reaches for when reporting the problem.
|
|
14
|
+
const NODE_MAJOR = Number(process.versions.node.split(".")[0]);
|
|
15
|
+
if (NODE_MAJOR < 22) {
|
|
16
|
+
const args = process.argv.slice(2);
|
|
17
|
+
const diagnostic =
|
|
18
|
+
args[0] === "doctor" ||
|
|
19
|
+
args.some((a) => ["--version", "-v", "--help", "-h"].includes(a));
|
|
20
|
+
if (!diagnostic) {
|
|
21
|
+
fail(`devmp needs Node 22 or newer — this is ${process.version}.`);
|
|
22
|
+
console.error(" Upgrade Node, then run `devmp doctor` to confirm.");
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Flags that never take a value — so `--yes "prompt"` can't swallow the prompt.
|
|
28
|
+
const BOOLEAN_FLAGS = new Set([
|
|
29
|
+
"version",
|
|
30
|
+
"local",
|
|
31
|
+
"yes",
|
|
32
|
+
"help",
|
|
33
|
+
"snapshot",
|
|
34
|
+
"allow-commands",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
// Parse `command [positional...] [--flag value | --flag=value | --flag | -y]`.
|
|
38
|
+
function parseArgs(argv) {
|
|
39
|
+
const flags = {};
|
|
40
|
+
const positional = [];
|
|
41
|
+
let command = null;
|
|
42
|
+
|
|
43
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
44
|
+
const arg = argv[i];
|
|
45
|
+
if (arg === "-y") {
|
|
46
|
+
flags.yes = true;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (arg === "-v") {
|
|
50
|
+
flags.version = true;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (!arg.startsWith("--")) {
|
|
54
|
+
if (!command) command = arg;
|
|
55
|
+
else positional.push(arg);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const eq = arg.indexOf("=");
|
|
59
|
+
const name = arg.slice(2, eq >= 0 ? eq : undefined);
|
|
60
|
+
if (eq >= 0) {
|
|
61
|
+
flags[name] = arg.slice(eq + 1);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const next = argv[i + 1];
|
|
65
|
+
if (!BOOLEAN_FLAGS.has(name) && next && !next.startsWith("--")) {
|
|
66
|
+
flags[name] = next;
|
|
67
|
+
i += 1;
|
|
68
|
+
} else {
|
|
69
|
+
flags[name] = true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { command, positional, flags };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function printHelp() {
|
|
76
|
+
banner();
|
|
77
|
+
console.log("");
|
|
78
|
+
console.log("The agent runs on the server; the files land in your folder.");
|
|
79
|
+
console.log("");
|
|
80
|
+
console.log(color.bold("Usage"));
|
|
81
|
+
console.log(
|
|
82
|
+
" devmp [options] Open an interactive coding session"
|
|
83
|
+
);
|
|
84
|
+
console.log(" devmp <command> [options]");
|
|
85
|
+
console.log("");
|
|
86
|
+
console.log(color.bold("Commands"));
|
|
87
|
+
console.log(
|
|
88
|
+
` ${color.cyan("(no command)")} Open the interactive workspace`
|
|
89
|
+
);
|
|
90
|
+
console.log(
|
|
91
|
+
` ${color.cyan("login")} Connect your DevMarketplace account`
|
|
92
|
+
);
|
|
93
|
+
console.log(
|
|
94
|
+
` ${color.cyan("signup")} Create an account and connect it`
|
|
95
|
+
);
|
|
96
|
+
console.log(
|
|
97
|
+
` ${color.cyan(
|
|
98
|
+
"status"
|
|
99
|
+
)} Show the logged-in account and token balance`
|
|
100
|
+
);
|
|
101
|
+
console.log(
|
|
102
|
+
` ${color.cyan("doctor")} Diagnose environment and API connectivity`
|
|
103
|
+
);
|
|
104
|
+
console.log(
|
|
105
|
+
` ${color.cyan(
|
|
106
|
+
"run"
|
|
107
|
+
)} "<prompt>" Build/change code in the current folder`
|
|
108
|
+
);
|
|
109
|
+
console.log(
|
|
110
|
+
` ${color.cyan("logout")} Remove the saved CLI session`
|
|
111
|
+
);
|
|
112
|
+
console.log("");
|
|
113
|
+
console.log(color.bold("Options"));
|
|
114
|
+
console.log(" --version, -v Print the installed devmp version");
|
|
115
|
+
console.log(" --env <name> production (default), dev, or local");
|
|
116
|
+
console.log(" --local Shorthand for --env local");
|
|
117
|
+
console.log(" --api-base-url <url> Override the API base URL");
|
|
118
|
+
console.log(
|
|
119
|
+
" --cwd <path> Workspace directory for `run` (default: .)"
|
|
120
|
+
);
|
|
121
|
+
console.log(
|
|
122
|
+
" --yes, -y Auto-approve ordinary file edits (delete/move still ask)"
|
|
123
|
+
);
|
|
124
|
+
console.log(
|
|
125
|
+
" --allow-commands Let the agent run commands (npm/tests) without asking each time"
|
|
126
|
+
);
|
|
127
|
+
console.log(
|
|
128
|
+
" --snapshot Use HTTP snapshot mode instead of the live tunnel"
|
|
129
|
+
);
|
|
130
|
+
console.log(" --provider <name> openai | anthropic | ollama");
|
|
131
|
+
console.log(" --model <id> Model id for the provider");
|
|
132
|
+
console.log(" --max-files <n> Max files the agent may create");
|
|
133
|
+
console.log("");
|
|
134
|
+
console.log(color.bold("Examples"));
|
|
135
|
+
// Examples are copy-pasted verbatim. `--local` here sent every new installer
|
|
136
|
+
// to a localhost backend that is not running on their machine.
|
|
137
|
+
console.log(" devmp login");
|
|
138
|
+
console.log(' devmp run "build a tiny express hello world api"');
|
|
139
|
+
console.log(' devmp run "add a /health route" --cwd ./my-app');
|
|
140
|
+
console.log(" devmp doctor --env dev");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function toInt(value) {
|
|
144
|
+
const n = Number.parseInt(value, 10);
|
|
145
|
+
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function main() {
|
|
149
|
+
const { command, positional, flags } = parseArgs(process.argv.slice(2));
|
|
150
|
+
const config = resolveConfig(flags, {
|
|
151
|
+
cwd: typeof flags.cwd === "string" ? flags.cwd : undefined,
|
|
152
|
+
onWarn: (message) => warnUser(message),
|
|
153
|
+
});
|
|
154
|
+
// A flag typed now beats a file written earlier; the file only fills the gaps.
|
|
155
|
+
const d = config.defaults;
|
|
156
|
+
const pick = (flagValue, fallback) =>
|
|
157
|
+
flagValue !== undefined ? flagValue : fallback;
|
|
158
|
+
|
|
159
|
+
// Before anything else, and before the TTY check the bare `devmp` path runs:
|
|
160
|
+
// asking a program its version must work everywhere, including in a pipe or a
|
|
161
|
+
// CI log. It is also the first thing anyone is asked for in a bug report.
|
|
162
|
+
if (flags.version || command === "version") {
|
|
163
|
+
console.log(require("../package.json").version);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (flags.help || command === "help") {
|
|
168
|
+
printHelp();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const provider =
|
|
173
|
+
typeof flags.provider === "string" ? flags.provider : undefined;
|
|
174
|
+
|
|
175
|
+
if (!command) {
|
|
176
|
+
await commands.shell(config, {
|
|
177
|
+
cwd: typeof flags.cwd === "string" ? flags.cwd : undefined,
|
|
178
|
+
provider: pick(provider, d.provider),
|
|
179
|
+
model: pick(
|
|
180
|
+
typeof flags.model === "string" ? flags.model : undefined,
|
|
181
|
+
d.model,
|
|
182
|
+
),
|
|
183
|
+
maxFiles: pick(
|
|
184
|
+
typeof flags["max-files"] === "string"
|
|
185
|
+
? toInt(flags["max-files"])
|
|
186
|
+
: undefined,
|
|
187
|
+
d.maxFiles,
|
|
188
|
+
),
|
|
189
|
+
yes: flags.yes === true || d.yes,
|
|
190
|
+
allowCommands: flags["allow-commands"] === true || d.allowCommands,
|
|
191
|
+
permissions: d.permissions,
|
|
192
|
+
mode: d.mode,
|
|
193
|
+
});
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
switch (command) {
|
|
198
|
+
case "login":
|
|
199
|
+
await commands.login(config);
|
|
200
|
+
return;
|
|
201
|
+
case "signup":
|
|
202
|
+
case "register":
|
|
203
|
+
await commands.signup(config);
|
|
204
|
+
return;
|
|
205
|
+
case "status":
|
|
206
|
+
await commands.status(config);
|
|
207
|
+
return;
|
|
208
|
+
case "doctor":
|
|
209
|
+
await commands.doctor(config);
|
|
210
|
+
return;
|
|
211
|
+
case "logout":
|
|
212
|
+
await commands.logout();
|
|
213
|
+
return;
|
|
214
|
+
case "run":
|
|
215
|
+
await commands.run(config, {
|
|
216
|
+
prompt: positional.join(" ").trim(),
|
|
217
|
+
cwd: typeof flags.cwd === "string" ? flags.cwd : undefined,
|
|
218
|
+
provider: pick(provider, d.provider),
|
|
219
|
+
model: pick(
|
|
220
|
+
typeof flags.model === "string" ? flags.model : undefined,
|
|
221
|
+
d.model,
|
|
222
|
+
),
|
|
223
|
+
maxFiles: pick(
|
|
224
|
+
typeof flags["max-files"] === "string"
|
|
225
|
+
? toInt(flags["max-files"])
|
|
226
|
+
: undefined,
|
|
227
|
+
d.maxFiles,
|
|
228
|
+
),
|
|
229
|
+
yes: flags.yes === true || d.yes,
|
|
230
|
+
allowCommands: flags["allow-commands"] === true || d.allowCommands,
|
|
231
|
+
snapshot: flags.snapshot === true,
|
|
232
|
+
});
|
|
233
|
+
return;
|
|
234
|
+
default:
|
|
235
|
+
fail(`Unknown command "${command}".`);
|
|
236
|
+
console.log("");
|
|
237
|
+
printHelp();
|
|
238
|
+
process.exitCode = 1;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
main().catch((error) => {
|
|
243
|
+
fail(error && error.message ? error.message : String(error));
|
|
244
|
+
process.exitCode = 1;
|
|
245
|
+
});
|
package/lib/api.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const { saveSession } = require('./session');
|
|
5
|
+
const { agentRunUrl, usageUrl } = require('./routes');
|
|
6
|
+
const { record: recordClientVersion } = require('./version');
|
|
7
|
+
|
|
8
|
+
// ── low-level HTTP ─────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
async function fetchOrThrow(url, init) {
|
|
11
|
+
try {
|
|
12
|
+
return await fetch(url, init);
|
|
13
|
+
} catch (error) {
|
|
14
|
+
const message = error && error.message ? error.message : String(error);
|
|
15
|
+
throw new Error(
|
|
16
|
+
`Could not reach the DevMarketplace API at ${url}. ` +
|
|
17
|
+
`Is the backend running and the URL correct? (${message})`,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function readJson(response) {
|
|
23
|
+
const text = await response.text();
|
|
24
|
+
if (!text) return {};
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(text);
|
|
27
|
+
} catch {
|
|
28
|
+
return { message: text };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function errorMessage(data) {
|
|
33
|
+
if (typeof data.message === 'string') return data.message;
|
|
34
|
+
if (Array.isArray(data.message)) return data.message.join(', ');
|
|
35
|
+
if (typeof data.error === 'string') return data.error;
|
|
36
|
+
return 'Unknown error';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A 404 on the CLI's own routes has exactly one meaning worth saying out loud:
|
|
41
|
+
* this server has no CLI support deployed. "Cannot POST /api/cli-auth/start" is
|
|
42
|
+
* accurate and useless — nobody reading it knows what to do next.
|
|
43
|
+
*/
|
|
44
|
+
function describeFailure(url, status, data) {
|
|
45
|
+
if (status === 404 && /\/(cli-auth|platform\/v1)\b/.test(String(url))) {
|
|
46
|
+
let origin = String(url);
|
|
47
|
+
try {
|
|
48
|
+
origin = new URL(url).origin;
|
|
49
|
+
} catch {
|
|
50
|
+
/* keep the raw URL if it will not parse */
|
|
51
|
+
}
|
|
52
|
+
return (
|
|
53
|
+
`This server has no CLI support enabled (${origin} returned 404). ` +
|
|
54
|
+
'It may need updating, or --api-base-url may be pointing somewhere else.'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return `${status}: ${errorMessage(data)}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function requestJson(url, init) {
|
|
61
|
+
const response = await fetchOrThrow(url, init);
|
|
62
|
+
const data = await readJson(response);
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
const err = new Error(describeFailure(url, response.status, data));
|
|
65
|
+
err.status = response.status;
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
return data;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
72
|
+
|
|
73
|
+
// ── device authorization (reuses the backend's /cli-auth flow) ─────────────
|
|
74
|
+
|
|
75
|
+
async function startDeviceAuth(config) {
|
|
76
|
+
const data = await requestJson(`${config.apiBaseUrl}/cli-auth/start`, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: { 'content-type': 'application/json' },
|
|
79
|
+
body: JSON.stringify({ clientName: `${os.hostname()} devmp CLI` }),
|
|
80
|
+
});
|
|
81
|
+
if (!data.deviceCode || !data.userCode) {
|
|
82
|
+
throw new Error('CLI auth start response was missing deviceCode/userCode.');
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
deviceCode: data.deviceCode,
|
|
86
|
+
userCode: data.userCode,
|
|
87
|
+
expiresIn: Number(data.expiresIn) || 600,
|
|
88
|
+
interval: Number(data.interval) || 3,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// `signal` lets an interactive host abandon the wait (Ctrl-C) without leaving
|
|
93
|
+
// a polling loop running behind the shell.
|
|
94
|
+
async function pollDeviceAuth(config, start, { signal } = {}) {
|
|
95
|
+
const deadline = Date.now() + start.expiresIn * 1000;
|
|
96
|
+
let intervalMs = Math.max(1, start.interval) * 1000;
|
|
97
|
+
|
|
98
|
+
while (Date.now() < deadline) {
|
|
99
|
+
if (signal?.aborted) throw new Error('Login cancelled.');
|
|
100
|
+
await delay(intervalMs);
|
|
101
|
+
if (signal?.aborted) throw new Error('Login cancelled.');
|
|
102
|
+
const response = await fetchOrThrow(`${config.apiBaseUrl}/cli-auth/token`, {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: { 'content-type': 'application/json' },
|
|
105
|
+
body: JSON.stringify({ deviceCode: start.deviceCode }),
|
|
106
|
+
});
|
|
107
|
+
const data = await readJson(response);
|
|
108
|
+
|
|
109
|
+
if (response.ok && data.accessToken) {
|
|
110
|
+
const session = {
|
|
111
|
+
apiBaseUrl: config.apiBaseUrl,
|
|
112
|
+
frontendBaseUrl: config.frontendBaseUrl,
|
|
113
|
+
accessToken: data.accessToken,
|
|
114
|
+
...(data.refreshToken ? { refreshToken: data.refreshToken } : {}),
|
|
115
|
+
...(data.user ? { user: data.user } : {}),
|
|
116
|
+
savedAt: new Date().toISOString(),
|
|
117
|
+
};
|
|
118
|
+
await saveSession(session);
|
|
119
|
+
return session;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 428 = authorization pending; keep waiting at the server's cadence.
|
|
123
|
+
if (response.status === 428 || data.error === 'authorization_pending') {
|
|
124
|
+
if (data.interval) intervalMs = Math.max(1, data.interval) * 1000;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
throw new Error(errorMessage(data) || 'CLI login failed.');
|
|
128
|
+
}
|
|
129
|
+
throw new Error('CLI login code expired. Run `devmp login` again.');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function refreshSession(session) {
|
|
133
|
+
if (!session.refreshToken) {
|
|
134
|
+
throw new Error('No refresh token saved. Run `devmp login` again.');
|
|
135
|
+
}
|
|
136
|
+
const data = await requestJson(`${session.apiBaseUrl}/user/refresh`, {
|
|
137
|
+
method: 'POST',
|
|
138
|
+
headers: { 'content-type': 'application/json' },
|
|
139
|
+
body: JSON.stringify({ refreshToken: session.refreshToken }),
|
|
140
|
+
});
|
|
141
|
+
const next = {
|
|
142
|
+
...session,
|
|
143
|
+
accessToken: data.accessToken,
|
|
144
|
+
refreshToken: data.refreshToken || session.refreshToken,
|
|
145
|
+
savedAt: new Date().toISOString(),
|
|
146
|
+
};
|
|
147
|
+
await saveSession(next);
|
|
148
|
+
return next;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Token allowance + rate-limit headroom from the platform surface.
|
|
153
|
+
*
|
|
154
|
+
* Returns the balance shape the status card renders, with `limits` attached
|
|
155
|
+
* when the server reports them.
|
|
156
|
+
*/
|
|
157
|
+
async function getTokenBalance(session) {
|
|
158
|
+
const data = await requestJson(usageUrl(session.apiBaseUrl), {
|
|
159
|
+
headers: { authorization: `Bearer ${session.accessToken}` },
|
|
160
|
+
});
|
|
161
|
+
// Every authenticated call is a chance to learn which client the server
|
|
162
|
+
// expects; the notice and the version gate both read what this records.
|
|
163
|
+
recordClientVersion(data?.client);
|
|
164
|
+
const tokens = data?.tokens ?? data;
|
|
165
|
+
return { ...tokens, limits: data?.rateLimits ?? null };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── the agent run: stream NDJSON events from the platform run endpoint ──────
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* POST the run and yield parsed stream events. Retries once through a token
|
|
172
|
+
* refresh on 401/403 so an expired access token doesn't interrupt a demo.
|
|
173
|
+
*/
|
|
174
|
+
async function* streamRun(session, body, { refreshed = false } = {}) {
|
|
175
|
+
const response = await fetchOrThrow(agentRunUrl(session.apiBaseUrl), {
|
|
176
|
+
method: 'POST',
|
|
177
|
+
headers: {
|
|
178
|
+
'content-type': 'application/json',
|
|
179
|
+
authorization: `Bearer ${session.accessToken}`,
|
|
180
|
+
},
|
|
181
|
+
body: JSON.stringify(body),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
if (!response.ok) {
|
|
185
|
+
if ((response.status === 401 || response.status === 403) && !refreshed) {
|
|
186
|
+
const next = await refreshSession(session);
|
|
187
|
+
yield* streamRun(next, body, { refreshed: true });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const data = await readJson(response);
|
|
191
|
+
const err = new Error(errorMessage(data));
|
|
192
|
+
err.status = response.status;
|
|
193
|
+
throw err;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Parse the NDJSON stream line by line across chunk boundaries.
|
|
197
|
+
const decoder = new TextDecoder();
|
|
198
|
+
let buffer = '';
|
|
199
|
+
for await (const chunk of response.body) {
|
|
200
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
201
|
+
let nl;
|
|
202
|
+
while ((nl = buffer.indexOf('\n')) >= 0) {
|
|
203
|
+
const rawLine = buffer.slice(0, nl);
|
|
204
|
+
buffer = buffer.slice(nl + 1);
|
|
205
|
+
const line = rawLine.trim();
|
|
206
|
+
if (!line) continue;
|
|
207
|
+
try {
|
|
208
|
+
yield JSON.parse(line);
|
|
209
|
+
} catch {
|
|
210
|
+
// ignore malformed keep-alive/partial lines
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const rest = buffer.trim();
|
|
215
|
+
if (rest) {
|
|
216
|
+
try {
|
|
217
|
+
yield JSON.parse(rest);
|
|
218
|
+
} catch {
|
|
219
|
+
/* ignore trailing noise */
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
module.exports = {
|
|
225
|
+
requestJson,
|
|
226
|
+
startDeviceAuth,
|
|
227
|
+
pollDeviceAuth,
|
|
228
|
+
refreshSession,
|
|
229
|
+
getTokenBalance,
|
|
230
|
+
streamRun,
|
|
231
|
+
};
|
package/lib/browser.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
|
+
|
|
5
|
+
// Best-effort "open this URL in the default browser". Returns false if it could
|
|
6
|
+
// not be launched, so the caller can fall back to printing the URL.
|
|
7
|
+
function openBrowser(url) {
|
|
8
|
+
const platform = process.platform;
|
|
9
|
+
const command =
|
|
10
|
+
platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
11
|
+
const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
12
|
+
try {
|
|
13
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' });
|
|
14
|
+
child.unref();
|
|
15
|
+
return true;
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { openBrowser };
|