@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/CHANGELOG.md +19 -0
- package/README.md +118 -0
- package/bin/gmc.mjs +50 -0
- package/package.json +42 -0
- package/scripts/check-release-readiness.mjs +99 -0
- package/scripts/smoke-beta.mjs +220 -0
- package/scripts/smoke-pack.mjs +75 -0
- package/src/args.js +99 -0
- package/src/browser-login.js +255 -0
- package/src/config.js +320 -0
- package/src/constants.js +66 -0
- package/src/help.js +60 -0
- package/src/main.js +141 -0
- package/src/output.js +3 -0
- package/src/projection.js +163 -0
- package/src/requests.js +600 -0
- package/src/workflows.js +1862 -0
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
|
+
}
|