@witchpot/gmc 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/src/args.js ADDED
@@ -0,0 +1,99 @@
1
+ import { EXIT_USAGE } from "./constants.js";
2
+ import { writeJsonError } from "./output.js";
3
+
4
+ export const BOOLEAN_OPTIONS = new Set(["json", "help", "version", "no-open"]);
5
+
6
+ export function parseArgs(args) {
7
+ const command = [];
8
+ const positional = [];
9
+ const options = {};
10
+
11
+ for (let index = 0; index < args.length; index += 1) {
12
+ const arg = args[index];
13
+ if (arg.startsWith("--")) {
14
+ const raw = arg.slice(2);
15
+ const separatorIndex = raw.indexOf("=");
16
+ const key = separatorIndex >= 0 ? raw.slice(0, separatorIndex) : raw;
17
+ const inlineValue = separatorIndex >= 0 ? raw.slice(separatorIndex + 1) : undefined;
18
+ if (BOOLEAN_OPTIONS.has(key)) {
19
+ options[key] = true;
20
+ continue;
21
+ }
22
+
23
+ const value = inlineValue ?? args[index + 1];
24
+ if (value == null || value === "" || value.startsWith("--")) {
25
+ writeJsonError({
26
+ code: "CLI_USAGE_ERROR",
27
+ message: `Missing value for --${key}`
28
+ });
29
+ process.exit(EXIT_USAGE);
30
+ }
31
+
32
+ if (inlineValue == null) index += 1;
33
+ options[key] = value;
34
+ continue;
35
+ }
36
+
37
+ if (command.length < 2 && isCommandToken(command, arg)) {
38
+ command.push(arg);
39
+ } else {
40
+ positional.push(arg);
41
+ }
42
+ }
43
+
44
+ return { command, options, positional };
45
+ }
46
+
47
+ function isCommandToken(command, token) {
48
+ if (command.length === 0) {
49
+ return [
50
+ "status",
51
+ "schema",
52
+ "games",
53
+ "reviews",
54
+ "campaigns",
55
+ "marketing",
56
+ "creators",
57
+ "cohort",
58
+ "showcases",
59
+ "auth",
60
+ "login",
61
+ "logout",
62
+ "config",
63
+ "update",
64
+ "version"
65
+ ].includes(token);
66
+ }
67
+ if (command[0] === "auth") {
68
+ return token === "status";
69
+ }
70
+ if (command[0] === "config") {
71
+ return ["get", "set"].includes(token);
72
+ }
73
+ if (command[0] === "games") {
74
+ return ["search", "resolve", "benchmark", "detail", "analysis", "compare", "topics"].includes(token);
75
+ }
76
+ if (command[0] === "reviews") {
77
+ return token === "patterns";
78
+ }
79
+ if (command[0] === "campaigns") {
80
+ return token === "signals";
81
+ }
82
+ if (command[0] === "marketing") {
83
+ return ["patterns", "timing"].includes(token);
84
+ }
85
+ if (command[0] === "creators") {
86
+ return token === "patterns";
87
+ }
88
+ if (command[0] === "cohort") {
89
+ return ["trends", "pricing", "brief", "compare"].includes(token);
90
+ }
91
+ if (command[0] === "showcases") {
92
+ return ["search", "fit", "patterns"].includes(token);
93
+ }
94
+ return false;
95
+ }
96
+
97
+ export function matches(actual, expected) {
98
+ return actual.length === expected.length && expected.every((part, index) => actual[index] === part);
99
+ }
@@ -0,0 +1,255 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import { platform } from "node:os";
4
+ import { spawn } from "node:child_process";
5
+ import { EXIT_API, EXIT_CONFIG } from "./constants.js";
6
+ import { writeJsonError } from "./output.js";
7
+
8
+ const DEFAULT_LOGIN_TIMEOUT_MS = 120000;
9
+
10
+ export async function runBrowserLogin({
11
+ apiBaseUrl,
12
+ label = "GMC CLI",
13
+ noOpen = false,
14
+ timeoutMs = DEFAULT_LOGIN_TIMEOUT_MS,
15
+ openBrowser = openSystemBrowser,
16
+ fetchImpl = fetch
17
+ }) {
18
+ const state = randomToken();
19
+ const codeVerifier = randomToken();
20
+ const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
21
+ const callback = await createLoopbackCallback({ state, timeoutMs });
22
+
23
+ try {
24
+ const loginUrl = buildLoginUrl({
25
+ apiBaseUrl,
26
+ redirectUri: callback.redirectUri,
27
+ state,
28
+ codeChallenge,
29
+ label
30
+ });
31
+
32
+ process.stderr.write(`Open this URL to sign in to GMC:\n${loginUrl.toString()}\n`);
33
+ if (!noOpen) openBrowser(loginUrl.toString());
34
+
35
+ const authorization = await callback.wait();
36
+ const credential = await exchangeAuthorizationCode({
37
+ apiBaseUrl,
38
+ code: authorization.code,
39
+ codeVerifier,
40
+ redirectUri: callback.redirectUri,
41
+ fetchImpl
42
+ });
43
+
44
+ return {
45
+ credential,
46
+ loginUrl: loginUrl.toString()
47
+ };
48
+ } finally {
49
+ await callback.close();
50
+ }
51
+ }
52
+
53
+ export function buildLoginUrl({ apiBaseUrl, redirectUri, state, codeChallenge, label }) {
54
+ const url = new URL("/cli/login", apiBaseUrl);
55
+ url.searchParams.set("redirect_uri", redirectUri);
56
+ url.searchParams.set("state", state);
57
+ url.searchParams.set("code_challenge", codeChallenge);
58
+ url.searchParams.set("code_challenge_method", "S256");
59
+ if (label) url.searchParams.set("label", label);
60
+ return url;
61
+ }
62
+
63
+ export async function exchangeAuthorizationCode({
64
+ apiBaseUrl,
65
+ code,
66
+ codeVerifier,
67
+ redirectUri,
68
+ fetchImpl = fetch
69
+ }) {
70
+ const response = await fetchImpl(new URL("/api/v1/cli/oauth/token", apiBaseUrl), {
71
+ method: "POST",
72
+ headers: {
73
+ accept: "application/json",
74
+ "content-type": "application/json"
75
+ },
76
+ body: JSON.stringify({
77
+ code,
78
+ codeVerifier,
79
+ redirectUri
80
+ })
81
+ }).catch((error) => {
82
+ writeJsonError({
83
+ code: "CLI_API_ERROR",
84
+ message: `Failed to exchange GMC CLI authorization code at ${new URL(apiBaseUrl).origin}`,
85
+ cause: error instanceof Error ? error.message : "Unknown network failure"
86
+ });
87
+ process.exit(EXIT_API);
88
+ });
89
+
90
+ const contentType = response.headers.get("content-type") ?? "";
91
+ const payload = contentType.includes("application/json")
92
+ ? await response.json().catch(() => null)
93
+ : null;
94
+ if (!contentType.includes("application/json")) {
95
+ writeJsonError({
96
+ code: "CLI_API_ERROR",
97
+ message: "GMC CLI authorization exchange returned a non-JSON response. If production is behind Cloudflare Access, allow CLI/API routes or use a Cloudflare service-token path for the CLI.",
98
+ response: summarizeTokenExchangeResponse(response)
99
+ });
100
+ process.exit(EXIT_API);
101
+ }
102
+ if (!response.ok || payload?.error) {
103
+ process.stdout.write(`${JSON.stringify(payload ?? {
104
+ error: {
105
+ code: "CLI_API_ERROR",
106
+ message: `GMC CLI authorization exchange failed with HTTP ${response.status}`
107
+ }
108
+ }, null, 2)}\n`);
109
+ process.exit(EXIT_API);
110
+ }
111
+ const credential = extractCredentialFromTokenPayload(payload);
112
+ if (!credential?.secret) {
113
+ writeJsonError({
114
+ code: "CLI_API_ERROR",
115
+ message: "GMC CLI authorization exchange did not return a credential",
116
+ response: summarizeTokenPayloadShape(payload, response)
117
+ });
118
+ process.exit(EXIT_API);
119
+ }
120
+ return credential;
121
+ }
122
+
123
+ export function extractCredentialFromTokenPayload(payload) {
124
+ const candidates = [
125
+ payload?.data,
126
+ payload?.data?.credential,
127
+ payload?.data?.cliCredential
128
+ ];
129
+
130
+ return candidates.find((candidate) => candidate && typeof candidate.secret === "string") ?? null;
131
+ }
132
+
133
+ function summarizeTokenPayloadShape(payload, response) {
134
+ return {
135
+ status: response.status,
136
+ contentType: response.headers.get("content-type") ?? undefined,
137
+ path: "/api/v1/cli/oauth/token",
138
+ requestId: payload?.meta?.requestId ?? payload?.error?.requestId,
139
+ topLevelKeys: getObjectKeys(payload),
140
+ dataKeys: getObjectKeys(payload?.data),
141
+ credentialKeys: getObjectKeys(payload?.data?.credential),
142
+ cliCredentialKeys: getObjectKeys(payload?.data?.cliCredential)
143
+ };
144
+ }
145
+
146
+ function summarizeTokenExchangeResponse(response) {
147
+ return {
148
+ status: response.status,
149
+ contentType: response.headers.get("content-type") ?? undefined,
150
+ path: "/api/v1/cli/oauth/token"
151
+ };
152
+ }
153
+
154
+ function getObjectKeys(value) {
155
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
156
+ return Object.keys(value).filter((key) => key !== "secret");
157
+ }
158
+
159
+ function createLoopbackCallback({ state, timeoutMs }) {
160
+ let server;
161
+ let settled = false;
162
+ let timeout;
163
+
164
+ const waitPromise = new Promise((resolve, reject) => {
165
+ server = createServer((request, response) => {
166
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
167
+ if (url.pathname !== "/callback") {
168
+ response.writeHead(404, { "content-type": "text/plain" });
169
+ response.end("Not found");
170
+ return;
171
+ }
172
+
173
+ const receivedState = url.searchParams.get("state");
174
+ const code = url.searchParams.get("code");
175
+ const error = url.searchParams.get("error");
176
+
177
+ if (receivedState !== state) {
178
+ response.writeHead(400, { "content-type": "text/plain" });
179
+ response.end("Invalid state. You can close this tab.");
180
+ if (!settled) reject(new Error("Invalid login state returned by browser"));
181
+ settled = true;
182
+ return;
183
+ }
184
+
185
+ if (error || !code) {
186
+ response.writeHead(400, { "content-type": "text/plain" });
187
+ response.end("GMC CLI login failed. You can close this tab.");
188
+ if (!settled) reject(new Error(error || "Missing authorization code"));
189
+ settled = true;
190
+ return;
191
+ }
192
+
193
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
194
+ response.end("<!doctype html><title>GMC CLI login complete</title><p>GMC CLI login complete. You can close this tab.</p>");
195
+ if (!settled) resolve({ code });
196
+ settled = true;
197
+ });
198
+
199
+ server.on("error", reject);
200
+ server.listen(0, "127.0.0.1", () => {
201
+ timeout = setTimeout(() => {
202
+ if (!settled) reject(new Error("Timed out waiting for browser login"));
203
+ settled = true;
204
+ }, timeoutMs);
205
+ });
206
+ });
207
+
208
+ return new Promise((resolve, reject) => {
209
+ server.on("error", reject);
210
+ server.on("listening", () => {
211
+ const address = server.address();
212
+ if (!address || typeof address !== "object") {
213
+ reject(new Error("Unable to allocate localhost callback port"));
214
+ return;
215
+ }
216
+ resolve({
217
+ redirectUri: `http://127.0.0.1:${address.port}/callback`,
218
+ wait: () => waitPromise,
219
+ close: () => new Promise((closeResolve) => {
220
+ if (timeout) clearTimeout(timeout);
221
+ server.close(() => closeResolve());
222
+ })
223
+ });
224
+ });
225
+ });
226
+ }
227
+
228
+ function openSystemBrowser(url) {
229
+ const currentPlatform = platform();
230
+ const command = currentPlatform === "darwin"
231
+ ? "open"
232
+ : currentPlatform === "win32"
233
+ ? "cmd"
234
+ : "xdg-open";
235
+ const args = currentPlatform === "win32"
236
+ ? ["/c", "start", "", url]
237
+ : [url];
238
+
239
+ const child = spawn(command, args, {
240
+ detached: true,
241
+ stdio: "ignore"
242
+ });
243
+ child.on("error", () => {
244
+ writeJsonError({
245
+ code: "CLI_CONFIG_ERROR",
246
+ message: "Unable to open the browser. Re-run with --no-open and open the URL manually."
247
+ });
248
+ process.exit(EXIT_CONFIG);
249
+ });
250
+ child.unref();
251
+ }
252
+
253
+ function randomToken() {
254
+ return randomBytes(32).toString("base64url");
255
+ }
package/src/config.js ADDED
@@ -0,0 +1,320 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ import {
5
+ CLI_BETA_PACKAGE_NAME,
6
+ CLI_PACKAGE_NAME,
7
+ CLI_VERSION,
8
+ DEFAULT_API_BASE_URL,
9
+ DEFAULT_CONFIG_PATH,
10
+ EXIT_CONFIG,
11
+ EXIT_USAGE
12
+ } from "./constants.js";
13
+ import { matches } from "./args.js";
14
+ import { writeJsonError } from "./output.js";
15
+ import { runBrowserLogin } from "./browser-login.js";
16
+
17
+ export function loadConfig() {
18
+ const configPath = resolveConfigPath(process.env.GMC_CONFIG_PATH);
19
+ const fileConfig = readConfigFile(configPath);
20
+ const authMode = resolveAuthMode({
21
+ envApiKey: process.env.GMC_API_KEY,
22
+ envCliCredential: process.env.GMC_CLI_CREDENTIAL,
23
+ fileConfig
24
+ });
25
+ return {
26
+ apiBaseUrl: process.env.GMC_API_BASE_URL || fileConfig.apiBaseUrl || DEFAULT_API_BASE_URL,
27
+ apiKey: process.env.GMC_API_KEY || process.env.GMC_CLI_CREDENTIAL || fileConfig.apiKey,
28
+ authMode,
29
+ configPath,
30
+ sources: {
31
+ apiBaseUrl: process.env.GMC_API_BASE_URL ? "env" : fileConfig.apiBaseUrl ? "config" : "default",
32
+ apiKey: process.env.GMC_API_KEY ? "env" : process.env.GMC_CLI_CREDENTIAL ? "env" : fileConfig.apiKey ? "config" : "none"
33
+ },
34
+ fileConfig
35
+ };
36
+ }
37
+
38
+ function readConfigFile(configPath) {
39
+ if (!configPath) return {};
40
+
41
+ try {
42
+ const raw = readFileSync(configPath, "utf8");
43
+ const parsed = JSON.parse(raw);
44
+ return {
45
+ apiBaseUrl: typeof parsed.apiBaseUrl === "string" ? parsed.apiBaseUrl : undefined,
46
+ apiKey: typeof parsed.apiKey === "string" ? parsed.apiKey : undefined,
47
+ authMode: parsed.authMode === "cli_credential" || parsed.authMode === "api_key"
48
+ ? parsed.authMode
49
+ : undefined
50
+ };
51
+ } catch (error) {
52
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
53
+ return {};
54
+ }
55
+ writeJsonError({
56
+ code: "CLI_CONFIG_ERROR",
57
+ message: `Failed to read GMC CLI config at ${configPath}`
58
+ });
59
+ process.exit(EXIT_CONFIG);
60
+ }
61
+ }
62
+
63
+ function writeConfig(configPath, config) {
64
+ try {
65
+ mkdirSync(path.dirname(configPath), { recursive: true, mode: 0o700 });
66
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
67
+ } catch {
68
+ writeJsonError({
69
+ code: "CLI_CONFIG_ERROR",
70
+ message: `Failed to write GMC CLI config at ${configPath}`
71
+ });
72
+ process.exit(EXIT_CONFIG);
73
+ }
74
+ }
75
+
76
+ function resolveConfigPath(configPath) {
77
+ return path.resolve((configPath || DEFAULT_CONFIG_PATH).replace(/^~(?=$|\/)/, homedir()));
78
+ }
79
+
80
+ function resolveAuthMode({ envApiKey, envCliCredential, fileConfig }) {
81
+ if (envApiKey) return "api_key";
82
+ if (envCliCredential) return "cli_credential";
83
+ if (fileConfig.authMode === "api_key" || fileConfig.authMode === "cli_credential") {
84
+ return fileConfig.authMode;
85
+ }
86
+ if (fileConfig.apiKey && String(fileConfig.apiKey).startsWith("gmcc_")) return "cli_credential";
87
+ if (fileConfig.apiKey) return "api_key";
88
+ return "anonymous";
89
+ }
90
+
91
+ export async function handleLocalCommand(command, options, positional, config) {
92
+ if (matches(command, ["login"])) {
93
+ const apiKey = options["api-key"];
94
+ const apiBaseUrl = options["api-base-url"] || options["base-url"] || config.apiBaseUrl;
95
+ parseBaseUrl(apiBaseUrl);
96
+
97
+ if (!apiKey) {
98
+ const { credential } = await runBrowserLogin({
99
+ apiBaseUrl,
100
+ label: options.label ? String(options.label) : "GMC CLI",
101
+ noOpen: Boolean(options["no-open"]),
102
+ ...(options.timeout ? { timeoutMs: Number(options.timeout) * 1000 } : {})
103
+ });
104
+ const nextConfig = {
105
+ ...config.fileConfig,
106
+ apiBaseUrl,
107
+ apiKey: credential.secret,
108
+ authMode: "cli_credential"
109
+ };
110
+ writeConfig(config.configPath, nextConfig);
111
+ return {
112
+ data: {
113
+ ok: true,
114
+ authMode: "cli_credential",
115
+ configPath: config.configPath,
116
+ apiBaseUrl,
117
+ cliCredential: {
118
+ id: credential.id,
119
+ label: credential.label,
120
+ prefix: credential.prefix,
121
+ expiresAt: credential.expiresAt
122
+ }
123
+ }
124
+ };
125
+ }
126
+
127
+ const nextConfig = {
128
+ ...config.fileConfig,
129
+ apiBaseUrl,
130
+ apiKey,
131
+ authMode: "api_key"
132
+ };
133
+ writeConfig(config.configPath, nextConfig);
134
+ return {
135
+ data: {
136
+ ok: true,
137
+ authMode: "api_key",
138
+ configPath: config.configPath,
139
+ apiBaseUrl,
140
+ apiKey: {
141
+ prefix: getApiKeyPrefix(apiKey)
142
+ }
143
+ }
144
+ };
145
+ }
146
+
147
+ if (matches(command, ["logout"])) {
148
+ const remoteRevoke = await revokeCurrentCliCredentialIfNeeded(config);
149
+ const nextConfig = {
150
+ ...config.fileConfig
151
+ };
152
+ delete nextConfig.apiKey;
153
+ delete nextConfig.authMode;
154
+ writeConfig(config.configPath, nextConfig);
155
+ return {
156
+ data: {
157
+ ok: true,
158
+ configPath: config.configPath,
159
+ ...(remoteRevoke ? { remoteRevoke } : {}),
160
+ apiKey: {
161
+ configured: false
162
+ }
163
+ },
164
+ ...(remoteRevoke?.warning ? { warnings: [remoteRevoke.warning] } : {})
165
+ };
166
+ }
167
+
168
+ if (matches(command, ["update"])) {
169
+ return {
170
+ data: {
171
+ currentVersion: CLI_VERSION,
172
+ packageName: CLI_PACKAGE_NAME,
173
+ betaPackageName: CLI_BETA_PACKAGE_NAME,
174
+ status: "manual_update_required",
175
+ updateCheck: {
176
+ available: null,
177
+ reason: "The CLI does not query the npm registry yet. Use the install command for the closed-beta package once it is published."
178
+ },
179
+ commands: {
180
+ npmGlobal: `npm install -g ${CLI_BETA_PACKAGE_NAME}@beta`,
181
+ npmExec: `npx ${CLI_BETA_PACKAGE_NAME}@beta --version`
182
+ },
183
+ notes: [
184
+ "This command is intentionally non-mutating in the closed-beta contract.",
185
+ "A future gmc update --apply command can run the package-manager update after the npm distribution channel is finalized."
186
+ ]
187
+ }
188
+ };
189
+ }
190
+
191
+ if (matches(command, ["config", "get"])) {
192
+ return {
193
+ data: redactConfig(config)
194
+ };
195
+ }
196
+
197
+ if (matches(command, ["config", "set"])) {
198
+ const key = positional[0];
199
+ const value = positional[1];
200
+ if (key !== "apiBaseUrl") {
201
+ writeJsonError({
202
+ code: "CLI_USAGE_ERROR",
203
+ message: "Usage: gmc config set apiBaseUrl <url>"
204
+ });
205
+ process.exit(EXIT_USAGE);
206
+ }
207
+ if (!value) {
208
+ writeJsonError({
209
+ code: "CLI_USAGE_ERROR",
210
+ message: "Missing value. Usage: gmc config set apiBaseUrl <url>"
211
+ });
212
+ process.exit(EXIT_USAGE);
213
+ }
214
+
215
+ parseBaseUrl(value);
216
+ const nextConfig = {
217
+ ...config.fileConfig,
218
+ apiBaseUrl: value
219
+ };
220
+ writeConfig(config.configPath, nextConfig);
221
+ return {
222
+ data: {
223
+ ok: true,
224
+ configPath: config.configPath,
225
+ apiBaseUrl: value
226
+ }
227
+ };
228
+ }
229
+
230
+ return null;
231
+ }
232
+
233
+ function redactConfig(config) {
234
+ return {
235
+ configPath: config.configPath,
236
+ apiBaseUrl: config.apiBaseUrl,
237
+ authMode: config.authMode,
238
+ apiKey: config.apiKey
239
+ ? {
240
+ configured: true,
241
+ prefix: getApiKeyPrefix(config.apiKey)
242
+ }
243
+ : {
244
+ configured: false
245
+ },
246
+ sources: config.sources
247
+ };
248
+ }
249
+
250
+ function getApiKeyPrefix(apiKey) {
251
+ return String(apiKey).slice(0, 12);
252
+ }
253
+
254
+ async function revokeCurrentCliCredentialIfNeeded(config) {
255
+ if (!config.apiKey || config.authMode !== "cli_credential") return null;
256
+
257
+ try {
258
+ const baseUrl = parseBaseUrl(config.apiBaseUrl);
259
+ const url = new URL("/api/v1/cli/credentials/current", baseUrl);
260
+ const response = await fetch(url, {
261
+ method: "DELETE",
262
+ headers: {
263
+ accept: "application/json",
264
+ authorization: `Bearer ${config.apiKey}`
265
+ }
266
+ });
267
+ const payload = await readJsonResponse(response);
268
+ if (!response.ok) {
269
+ return {
270
+ status: "failed",
271
+ warning: {
272
+ code: "CLI_REMOTE_LOGOUT_FAILED",
273
+ message: payload?.error?.message || `Remote CLI credential revoke failed with HTTP ${response.status}`
274
+ }
275
+ };
276
+ }
277
+
278
+ return {
279
+ status: "revoked",
280
+ credential: payload?.data
281
+ ? {
282
+ id: payload.data.id,
283
+ prefix: payload.data.prefix,
284
+ revokedAt: payload.data.revokedAt
285
+ }
286
+ : undefined
287
+ };
288
+ } catch (error) {
289
+ return {
290
+ status: "failed",
291
+ warning: {
292
+ code: "CLI_REMOTE_LOGOUT_FAILED",
293
+ message: error instanceof Error ? error.message : "Remote CLI credential revoke failed"
294
+ }
295
+ };
296
+ }
297
+ }
298
+
299
+ async function readJsonResponse(response) {
300
+ const text = await response.text();
301
+ if (!text.trim()) return null;
302
+ try {
303
+ return JSON.parse(text);
304
+ } catch {
305
+ return null;
306
+ }
307
+ }
308
+
309
+ export function parseBaseUrl(value) {
310
+ try {
311
+ const normalized = value.endsWith("/") ? value : `${value}/`;
312
+ return new URL(normalized);
313
+ } catch {
314
+ writeJsonError({
315
+ code: "CLI_CONFIG_ERROR",
316
+ message: "GMC_API_BASE_URL must be a valid URL"
317
+ });
318
+ process.exit(EXIT_CONFIG);
319
+ }
320
+ }