@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 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.1",
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 (their config formats
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(