@upturtle/wizard 0.2.1 → 0.2.3
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 +2 -2
- package/package.json +3 -2
- package/src/agents.mjs +11 -0
- package/src/index.mjs +117 -2
- package/src/writers.mjs +56 -0
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ npx @upturtle/wizard
|
|
|
8
8
|
|
|
9
9
|
The installer:
|
|
10
10
|
|
|
11
|
-
1. Detects which coding agent(s) you have installed (Claude Code, Cursor, Antigravity, OpenCode, Zed, Claude Desktop).
|
|
11
|
+
1. Detects which coding agent(s) you have installed (Claude Code, Cursor, OpenAI Codex CLI, Antigravity, OpenCode, Zed, Claude Desktop).
|
|
12
12
|
2. Shows a checkbox list — `↑`/`↓` to move, space to toggle, `a` to toggle all, enter to confirm.
|
|
13
13
|
3. Opens UpTurtle in your browser to authenticate and mint a scoped personal access token.
|
|
14
14
|
4. Writes the MCP server entry into each selected agent's config.
|
|
@@ -23,7 +23,7 @@ By default the wizard writes to your **user-wide** config — the MCP server is
|
|
|
23
23
|
npx @upturtle/wizard --project
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
Project scope is supported for `claude-code`, `cursor`, `opencode`, and `zed`. Antigravity and Claude Desktop don't have a project-level config format.
|
|
26
|
+
Project scope is supported for `claude-code`, `cursor`, `opencode`, and `zed`. Antigravity, Codex CLI, and Claude Desktop don't have a project-level config format.
|
|
27
27
|
|
|
28
28
|
## Flags
|
|
29
29
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@upturtle/wizard",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Connects your coding agent to UpTurtle's MCP server. Detects the agent, mints a scoped PAT via browser handshake, and writes the right MCP config.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"README.md"
|
|
13
13
|
],
|
|
14
14
|
"scripts": {
|
|
15
|
-
"start": "node src/cli.mjs"
|
|
15
|
+
"start": "node src/cli.mjs",
|
|
16
|
+
"test": "node --test test/*.test.mjs"
|
|
16
17
|
},
|
|
17
18
|
"engines": {
|
|
18
19
|
"node": ">=18"
|
package/src/agents.mjs
CHANGED
|
@@ -14,6 +14,13 @@ export const AGENTS = [
|
|
|
14
14
|
writer: "claude-code",
|
|
15
15
|
scopes: ["user", "project"],
|
|
16
16
|
},
|
|
17
|
+
{
|
|
18
|
+
id: "codex",
|
|
19
|
+
label: "OpenAI Codex CLI",
|
|
20
|
+
detect: () => isOnPath("codex") || existsSync(codexConfigPath()),
|
|
21
|
+
writer: "codex",
|
|
22
|
+
scopes: ["user"],
|
|
23
|
+
},
|
|
17
24
|
{
|
|
18
25
|
id: "cursor",
|
|
19
26
|
label: "Cursor",
|
|
@@ -78,6 +85,10 @@ export function antigravityConfigPath() {
|
|
|
78
85
|
return join(homedir(), ".gemini", "antigravity", "mcp_config.json");
|
|
79
86
|
}
|
|
80
87
|
|
|
88
|
+
export function codexConfigPath() {
|
|
89
|
+
return join(homedir(), ".codex", "config.toml");
|
|
90
|
+
}
|
|
91
|
+
|
|
81
92
|
export function opencodeConfigPath() {
|
|
82
93
|
return join(homedir(), ".config", "opencode", "opencode.json");
|
|
83
94
|
}
|
package/src/index.mjs
CHANGED
|
@@ -100,10 +100,12 @@ function parseFlags(argv) {
|
|
|
100
100
|
scope: "user",
|
|
101
101
|
yes: false,
|
|
102
102
|
help: false,
|
|
103
|
+
unsafeHost: false,
|
|
103
104
|
};
|
|
104
105
|
for (const a of argv) {
|
|
105
106
|
if (a === "--help" || a === "-h") flags.help = true;
|
|
106
107
|
else if (a === "--yes" || a === "-y") flags.yes = true;
|
|
108
|
+
else if (a === "--unsafe-host") flags.unsafeHost = true;
|
|
107
109
|
else if (a.startsWith("--host=")) flags.host = a.slice("--host=".length);
|
|
108
110
|
else if (a.startsWith("--agent=")) flags.agent = a.slice("--agent=".length);
|
|
109
111
|
else if (a.startsWith("--scope=")) flags.scope = a.slice("--scope=".length);
|
|
@@ -114,6 +116,16 @@ function parseFlags(argv) {
|
|
|
114
116
|
throw new Error(`--scope must be "user" or "project" (got "${flags.scope}")`);
|
|
115
117
|
}
|
|
116
118
|
flags.host = stripTrailingSlash(flags.host);
|
|
119
|
+
// Skip host validation when the user just wants --help — otherwise a bad
|
|
120
|
+
// UPTURTLE_HOST in the environment would prevent them from even seeing the
|
|
121
|
+
// usage that explains the rules.
|
|
122
|
+
if (!flags.help) {
|
|
123
|
+
if (flags.unsafeHost) {
|
|
124
|
+
warnUnsafeHost(flags.host);
|
|
125
|
+
} else {
|
|
126
|
+
validateHost(flags.host);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
117
129
|
return flags;
|
|
118
130
|
}
|
|
119
131
|
|
|
@@ -121,6 +133,103 @@ function stripTrailingSlash(s) {
|
|
|
121
133
|
return s.endsWith("/") ? s.slice(0, -1) : s;
|
|
122
134
|
}
|
|
123
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Rejects any host that isn't on the trusted allowlist. The wizard mints a
|
|
138
|
+
* bearer token that grants the holder full access to the user's UpTurtle
|
|
139
|
+
* account, so accepting an attacker-controlled --host (or UPTURTLE_HOST) lets
|
|
140
|
+
* a malicious blog post or social-engineered command exfiltrate that token.
|
|
141
|
+
*
|
|
142
|
+
* Allowed:
|
|
143
|
+
* - https://upturtle.com (apex)
|
|
144
|
+
* - https://<anything>.upturtle.com (any subdomain — prod, staging, etc.)
|
|
145
|
+
* - http://localhost[:port] (local dev)
|
|
146
|
+
* - http://127.0.0.1[:port] (local dev)
|
|
147
|
+
*
|
|
148
|
+
* Anything else throws. Use --unsafe-host to opt into a non-allowlisted host
|
|
149
|
+
* for legitimate dev/test scenarios; that path prints a loud warning.
|
|
150
|
+
*/
|
|
151
|
+
export function validateHost(raw) {
|
|
152
|
+
const allowedShapes = [
|
|
153
|
+
"https://upturtle.com",
|
|
154
|
+
"https://<subdomain>.upturtle.com",
|
|
155
|
+
"http://localhost[:<port>]",
|
|
156
|
+
"http://127.0.0.1[:<port>]",
|
|
157
|
+
];
|
|
158
|
+
const reject = (reason) => {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`Refusing to use host "${raw}": ${reason}.\n` +
|
|
161
|
+
`Allowed hosts:\n` +
|
|
162
|
+
allowedShapes.map((s) => ` - ${s}`).join("\n") +
|
|
163
|
+
`\nIf you really need a different host (e.g. a personal dev preview), ` +
|
|
164
|
+
`re-run with --unsafe-host. Only do this if you trust the host — it ` +
|
|
165
|
+
`will receive an access token tied to your UpTurtle account.`,
|
|
166
|
+
);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
let url;
|
|
170
|
+
try {
|
|
171
|
+
url = new URL(raw);
|
|
172
|
+
} catch {
|
|
173
|
+
return reject("not a valid URL");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Reject embedded credentials, query strings, fragments, and non-root paths.
|
|
177
|
+
// These don't need to be on the allowlist itself, but accepting them would
|
|
178
|
+
// smuggle data into the request the user can't easily see.
|
|
179
|
+
if (url.username || url.password) return reject("URL must not contain credentials");
|
|
180
|
+
if (url.search) return reject("URL must not contain a query string");
|
|
181
|
+
if (url.hash) return reject("URL must not contain a fragment");
|
|
182
|
+
if (url.pathname && url.pathname !== "/" && url.pathname !== "") {
|
|
183
|
+
return reject("URL must not contain a path");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const host = url.hostname;
|
|
187
|
+
const port = url.port;
|
|
188
|
+
|
|
189
|
+
if (url.protocol === "https:") {
|
|
190
|
+
if (port !== "" && port !== "443") return reject("https host must use the default port");
|
|
191
|
+
if (host === "upturtle.com") return;
|
|
192
|
+
if (host.endsWith(".upturtle.com") && host.length > ".upturtle.com".length) return;
|
|
193
|
+
return reject("https hosts must be upturtle.com or a subdomain of upturtle.com");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (url.protocol === "http:") {
|
|
197
|
+
if (host !== "localhost" && host !== "127.0.0.1") {
|
|
198
|
+
return reject("http is only allowed for localhost / 127.0.0.1");
|
|
199
|
+
}
|
|
200
|
+
// Port is optional. If present, must be numeric (URL parse already
|
|
201
|
+
// guarantees that) and in the valid range.
|
|
202
|
+
if (port !== "") {
|
|
203
|
+
const n = Number(port);
|
|
204
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) return reject("invalid port");
|
|
205
|
+
}
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return reject(`unsupported protocol "${url.protocol.replace(/:$/, "")}"`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function warnUnsafeHost(host) {
|
|
213
|
+
const bar = "!".repeat(72);
|
|
214
|
+
const lines = [
|
|
215
|
+
"",
|
|
216
|
+
bar,
|
|
217
|
+
"!! WARNING: --unsafe-host is set. Skipping the UpTurtle host allowlist.",
|
|
218
|
+
"!!",
|
|
219
|
+
`!! Host: ${host}`,
|
|
220
|
+
"!!",
|
|
221
|
+
"!! This wizard is about to mint an access token for your UpTurtle account",
|
|
222
|
+
"!! and hand it to the host above. If that host is not run by you or by",
|
|
223
|
+
"!! UpTurtle, the token can be used to deploy, redeploy, or delete every",
|
|
224
|
+
"!! application in your account.",
|
|
225
|
+
"!!",
|
|
226
|
+
"!! Only proceed if you typed this URL yourself and trust the operator.",
|
|
227
|
+
bar,
|
|
228
|
+
"",
|
|
229
|
+
];
|
|
230
|
+
for (const line of lines) console.error(line);
|
|
231
|
+
}
|
|
232
|
+
|
|
124
233
|
// Decides which agents to install for. Order of precedence:
|
|
125
234
|
// 1. Explicit --agent=id,id (skips prompt; honored even if not in detected)
|
|
126
235
|
// 2. Non-interactive (--yes or non-TTY): every detected agent
|
|
@@ -259,6 +368,12 @@ function printHelp() {
|
|
|
259
368
|
Options:
|
|
260
369
|
--host=<url> Override the UpTurtle host. Defaults to ${DEFAULT_HOST}.
|
|
261
370
|
Also: UPTURTLE_HOST=<url>.
|
|
371
|
+
Must be upturtle.com (or a subdomain) over https, or
|
|
372
|
+
http://localhost / 127.0.0.1 for local dev. Other hosts
|
|
373
|
+
are rejected to keep your access token from being sent
|
|
374
|
+
to a third party.
|
|
375
|
+
--unsafe-host Bypass the --host allowlist. Prints a loud warning and
|
|
376
|
+
proceeds. Only use this if you genuinely trust the host.
|
|
262
377
|
--scope=<scope> Where to write the MCP config:
|
|
263
378
|
user — your home dir; available in every project (default).
|
|
264
379
|
project — the current working directory; checked into git,
|
|
@@ -271,7 +386,7 @@ Options:
|
|
|
271
386
|
--help, -h Show this message.
|
|
272
387
|
|
|
273
388
|
Project scope is supported for: claude-code, cursor, opencode, zed.
|
|
274
|
-
Antigravity and Claude Desktop only support user scope
|
|
275
|
-
don't have a project-level alternative).
|
|
389
|
+
Antigravity, Codex CLI, and Claude Desktop only support user scope
|
|
390
|
+
(their config formats don't have a project-level alternative).
|
|
276
391
|
`);
|
|
277
392
|
}
|
package/src/writers.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { spawnSync } from "node:child_process";
|
|
|
5
5
|
import {
|
|
6
6
|
antigravityConfigPath,
|
|
7
7
|
claudeDesktopConfigPath,
|
|
8
|
+
codexConfigPath,
|
|
8
9
|
opencodeConfigPath,
|
|
9
10
|
zedConfigPath,
|
|
10
11
|
} from "./agents.mjs";
|
|
@@ -14,6 +15,7 @@ const SERVER_KEY = "upturtle";
|
|
|
14
15
|
export const WRITERS = {
|
|
15
16
|
"claude-code": writeClaudeCode,
|
|
16
17
|
"cursor": writeCursor,
|
|
18
|
+
"codex": writeCodex,
|
|
17
19
|
"antigravity": writeAntigravity,
|
|
18
20
|
"opencode": writeOpenCode,
|
|
19
21
|
"zed": writeZed,
|
|
@@ -135,6 +137,60 @@ function writeZed({ host, secret, scope }) {
|
|
|
135
137
|
});
|
|
136
138
|
}
|
|
137
139
|
|
|
140
|
+
function writeCodex({ host, secret, scope }) {
|
|
141
|
+
if (scope === "project") {
|
|
142
|
+
throw new Error(
|
|
143
|
+
"Codex CLI's MCP config is user-wide only (`~/.codex/config.toml`). " +
|
|
144
|
+
"Re-run without --scope=project for this agent.");
|
|
145
|
+
}
|
|
146
|
+
const path = codexConfigPath();
|
|
147
|
+
const block =
|
|
148
|
+
`[mcp_servers.${SERVER_KEY}]\n` +
|
|
149
|
+
`command = "npx"\n` +
|
|
150
|
+
`args = ["-y", "mcp-remote", "${host}/mcp", "--header", "Authorization: Bearer ${secret}"]\n`;
|
|
151
|
+
|
|
152
|
+
let existing = "";
|
|
153
|
+
if (existsSync(path)) {
|
|
154
|
+
existing = readFileSync(path, "utf8");
|
|
155
|
+
}
|
|
156
|
+
const updated = upsertTomlTable(existing, `mcp_servers.${SERVER_KEY}`, block);
|
|
157
|
+
|
|
158
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
159
|
+
writeFileSync(path, updated, "utf8");
|
|
160
|
+
return { configPath: path };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Naive TOML table upsert: replaces the `[<header>]` block (if present),
|
|
164
|
+
// otherwise appends. The block runs from its `[header]` line to the next
|
|
165
|
+
// top-level `[` or end-of-file. Good enough for our single-block writes —
|
|
166
|
+
// avoids pulling in a TOML parser dependency.
|
|
167
|
+
function upsertTomlTable(source, header, block) {
|
|
168
|
+
const headerLine = `[${header}]`;
|
|
169
|
+
const lines = source.split("\n");
|
|
170
|
+
const startIdx = lines.findIndex((line) => line.trim() === headerLine);
|
|
171
|
+
|
|
172
|
+
const trimmedBlock = block.endsWith("\n") ? block : block + "\n";
|
|
173
|
+
|
|
174
|
+
if (startIdx === -1) {
|
|
175
|
+
const prefix = source.length === 0 || source.endsWith("\n") ? source : source + "\n";
|
|
176
|
+
const separator = prefix.endsWith("\n\n") || prefix.length === 0 ? "" : "\n";
|
|
177
|
+
return prefix + separator + trimmedBlock;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let endIdx = lines.length;
|
|
181
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
182
|
+
if (/^\s*\[/.test(lines[i])) {
|
|
183
|
+
endIdx = i;
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const before = lines.slice(0, startIdx).join("\n");
|
|
188
|
+
const after = lines.slice(endIdx).join("\n");
|
|
189
|
+
const beforePart = before.length === 0 ? "" : before.endsWith("\n") ? before : before + "\n";
|
|
190
|
+
const afterPart = after.length === 0 ? "" : after.startsWith("\n") ? after : "\n" + after;
|
|
191
|
+
return beforePart + trimmedBlock + afterPart;
|
|
192
|
+
}
|
|
193
|
+
|
|
138
194
|
function writeClaudeDesktop({ host, secret, scope }) {
|
|
139
195
|
if (scope === "project") {
|
|
140
196
|
throw new Error(
|