metergraph-cli 0.1.0 → 0.2.0-preview.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/README.md +488 -22
- package/assets/skill/SKILL.md +17 -1
- package/assets/skill/manifest.json +3 -3
- package/package.json +2 -2
- package/src/args.js +323 -17
- package/src/auth-binding.js +202 -0
- package/src/auth-browser.js +74 -0
- package/src/auth-callback.js +177 -0
- package/src/auth-login.js +410 -0
- package/src/auth-oauth.js +462 -0
- package/src/auth-session.js +174 -0
- package/src/auth-store.js +397 -0
- package/src/cli.js +78 -2
- package/src/constants.js +99 -4
- package/src/deployment-credential.js +207 -0
- package/src/deployment-route.js +449 -0
- package/src/doctor.js +10 -0
- package/src/http.js +1 -1
- package/src/output.js +454 -2
- package/src/read-contract.js +601 -0
- package/src/read-output.js +216 -0
- package/src/read.js +325 -0
- package/src/setup-deployment.js +178 -0
- package/src/setup-env-acl.js +99 -0
- package/src/setup-env-git.js +103 -0
- package/src/setup-env-parse.js +169 -0
- package/src/setup-env.js +685 -0
- package/src/setup-state.js +141 -0
- package/src/setup.js +317 -0
- package/src/skill-bundle.js +1 -1
- package/src/trace-contract.js +129 -0
- package/src/trace-open.js +36 -0
- package/src/transport.js +171 -0
- package/src/verify-output.js +36 -0
- package/src/verify.js +120 -0
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "metergraph",
|
|
4
4
|
"file": "SKILL.md",
|
|
5
5
|
"source_url": "https://www.metergraph.dev/SKILL.md",
|
|
6
|
-
"sha256": "
|
|
7
|
-
"size":
|
|
8
|
-
"revision": "sha256-
|
|
6
|
+
"sha256": "57b920677adf62759c7221629327192a2d16b7e6034f7948ffd96cee402d4891",
|
|
7
|
+
"size": 10426,
|
|
8
|
+
"revision": "sha256-57b920677adf"
|
|
9
9
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metergraph-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Preview Metergraph command line tool with a read-only connection probe
|
|
3
|
+
"version": "0.2.0-preview.0",
|
|
4
|
+
"description": "Preview Metergraph command line tool with a read-only connection probe, a project skill installer, Metadata-only sign in and bounded workspace Metadata reads",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"metergraph": "bin/metergraph.js"
|
package/src/args.js
CHANGED
|
@@ -3,27 +3,96 @@ import {
|
|
|
3
3
|
DEFAULT_TIMEOUT_MS,
|
|
4
4
|
HANDOFF_SKILL_CLIENTS,
|
|
5
5
|
HANDOFF_SKILL_RUNTIMES,
|
|
6
|
+
HANDOFF_LOGIN_RUNTIMES,
|
|
7
|
+
LOGIN_DEFAULT_TIMEOUT_MS,
|
|
8
|
+
LOGIN_MAX_TIMEOUT_MS,
|
|
9
|
+
LOGIN_MIN_TIMEOUT_MS,
|
|
10
|
+
LOGIN_RUNTIMES,
|
|
6
11
|
MAX_TIMEOUT_MS,
|
|
7
12
|
MIN_TIMEOUT_MS,
|
|
13
|
+
READ_DEFAULT_DAYS,
|
|
14
|
+
READ_DEFAULT_LIMIT,
|
|
15
|
+
READ_DEFAULT_TIMEOUT_MS,
|
|
16
|
+
READ_MAX_DAYS,
|
|
17
|
+
READ_MAX_LIMIT,
|
|
18
|
+
READ_MAX_TIMEOUT_MS,
|
|
19
|
+
READ_MIN_TIMEOUT_MS,
|
|
8
20
|
SKILL_CLIENTS,
|
|
9
21
|
SKILL_RUNTIMES,
|
|
22
|
+
TRACES_DEFAULT_LIMIT,
|
|
10
23
|
} from "./constants.js";
|
|
11
24
|
import { parseOrigin } from "./origin.js";
|
|
25
|
+
import { isCursor, isSafeFilter } from "./read-contract.js";
|
|
12
26
|
|
|
13
|
-
const
|
|
14
|
-
const
|
|
27
|
+
export const READ_COMMANDS = Object.freeze(["status", "context", "capabilities", "usage", "routes", "traces"]);
|
|
28
|
+
const COMMANDS = new Set(["doctor", "help", "skill", "login", "logout", "setup", "verify", ...READ_COMMANDS]);
|
|
29
|
+
const HELP_TOPICS = new Set(["doctor", "skill", "login", "logout", "setup", "verify", ...READ_COMMANDS]);
|
|
15
30
|
const SKILL_ACTIONS = new Set(["install", "update"]);
|
|
31
|
+
const READ_BASE = ["--project", "--config-dir", "--timeout-ms"];
|
|
16
32
|
const OPTIONS = {
|
|
17
33
|
doctor: new Set(["--url", "--timeout-ms"]),
|
|
18
34
|
skill: new Set(["--client", "--runtime", "--project"]),
|
|
35
|
+
login: new Set(["--runtime", "--url", "--workspace", "--project", "--config-dir", "--timeout-ms"]),
|
|
36
|
+
setup: new Set(["--runtime", "--url", "--workspace", "--project", "--config-dir", "--env-file", "--client", "--timeout-ms", "--deployment", "--agent-token-file"]),
|
|
37
|
+
logout: new Set(["--project", "--config-dir"]),
|
|
38
|
+
status: new Set(READ_BASE),
|
|
39
|
+
context: new Set(READ_BASE),
|
|
40
|
+
capabilities: new Set(READ_BASE),
|
|
41
|
+
usage: new Set([...READ_BASE, "--days", "--limit"]),
|
|
42
|
+
routes: new Set([...READ_BASE, "--limit"]),
|
|
43
|
+
traces: new Set([...READ_BASE, "--days", "--limit", "--route", "--status", "--cursor"]),
|
|
44
|
+
verify: new Set([...READ_BASE, "--trace-id", "--request-id", "--since", "--until", "--source", "--days", "--poll-ms", "--max-attempts"]),
|
|
19
45
|
};
|
|
46
|
+
// Requests the read commands recognize and refuse, so a script gets an
|
|
47
|
+
// explicit unsupported result instead of an unknown argument or, worse, a
|
|
48
|
+
// broader query than it asked for. Nothing is sent for them.
|
|
49
|
+
const REFUSED = {
|
|
50
|
+
"--environment": { value: true, reason: "environment_selector_unsupported" },
|
|
51
|
+
"--workload": { value: true, reason: "workload_filter_unsupported" },
|
|
52
|
+
"--since": { value: true, reason: "time_range_unsupported" },
|
|
53
|
+
"--until": { value: true, reason: "time_range_unsupported" },
|
|
54
|
+
"--sql": { value: true, reason: "query_unsupported" },
|
|
55
|
+
"--query": { value: true, reason: "query_unsupported" },
|
|
56
|
+
"--content": { value: false, reason: "content_access_unsupported" },
|
|
57
|
+
"--include-content": { value: false, reason: "content_access_unsupported" },
|
|
58
|
+
"--debug": { value: false, reason: "content_access_unsupported" },
|
|
59
|
+
"--replay": { value: false, reason: "content_access_unsupported" },
|
|
60
|
+
};
|
|
61
|
+
const REFUSED_MESSAGES = {
|
|
62
|
+
environment_selector_unsupported:
|
|
63
|
+
"--environment is not supported: the service's agent access contract has no environment selector, " +
|
|
64
|
+
"so the CLI never sends one. Results cover the bound workspace. No request was made.",
|
|
65
|
+
workload_filter_unsupported:
|
|
66
|
+
"--workload is not supported by this CLI version: it cannot verify from the returned traces that a " +
|
|
67
|
+
"workload filter was applied, so it never sends one. No request was made.",
|
|
68
|
+
time_range_unsupported:
|
|
69
|
+
"--since and --until are not supported. Use --days N (1 to 90) for a window relative to now. No request was made.",
|
|
70
|
+
query_unsupported:
|
|
71
|
+
"Free-form queries are not supported. Use the fixed options of this command. No request was made.",
|
|
72
|
+
content_access_unsupported:
|
|
73
|
+
"Read commands return Metadata only. They never read retained content, debug data or replays. No request was made.",
|
|
74
|
+
};
|
|
75
|
+
// Options that take no value.
|
|
76
|
+
const FLAGS = {
|
|
77
|
+
login: new Set(["--signup", "--no-browser", "--reconnect"]),
|
|
78
|
+
setup: new Set(["--no-browser", "--repair", "--signup", "--reconnect", "--skip-skill", "--confirm-prerequisites"]),
|
|
79
|
+
verify: new Set(["--open", "--no-browser"]),
|
|
80
|
+
};
|
|
81
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
20
82
|
|
|
21
83
|
// Parses argv into one of:
|
|
22
84
|
// { ok: true, command: "help", topic, json }
|
|
23
85
|
// { ok: true, command: "version", json }
|
|
24
86
|
// { ok: true, command: "doctor", origin, timeoutMs, json }
|
|
25
87
|
// { ok: true, command: "skill", action, client, runtime, project, json }
|
|
26
|
-
// { ok:
|
|
88
|
+
// { ok: true, command: "login", runtime, origin, workspace, project,
|
|
89
|
+
// configDir, timeoutMs, signup, noBrowser, reconnect, json }
|
|
90
|
+
// { ok: true, command: "logout", project, configDir, json }
|
|
91
|
+
// { ok: true, command: one of READ_COMMANDS, project, configDir, timeoutMs,
|
|
92
|
+
// days, limit, route, status, cursor, json }
|
|
93
|
+
// { ok: false, command, json, code, message, outcome }
|
|
94
|
+
// outcome is invalid_input, or unsupported for a recognized request the read
|
|
95
|
+
// commands refuse (such as --environment).
|
|
27
96
|
// Error messages are fixed strings. They never contain an argument value,
|
|
28
97
|
// because a mistyped argument can be a credential.
|
|
29
98
|
export function parseArgs(argv) {
|
|
@@ -39,13 +108,15 @@ export function parseArgs(argv) {
|
|
|
39
108
|
let topic = null;
|
|
40
109
|
let version = false;
|
|
41
110
|
const values = {};
|
|
111
|
+
const flags = new Set();
|
|
42
112
|
|
|
43
|
-
const fail = (code, message) => ({
|
|
113
|
+
const fail = (code, message, outcome = "invalid_input") => ({
|
|
44
114
|
ok: false,
|
|
45
115
|
command: command === "skill" && action !== null ? `skill ${action}` : command ?? (version ? "version" : null),
|
|
46
116
|
json,
|
|
47
117
|
code,
|
|
48
118
|
message,
|
|
119
|
+
outcome,
|
|
49
120
|
});
|
|
50
121
|
|
|
51
122
|
for (let index = 0; index < argv.length; index += 1) {
|
|
@@ -71,9 +142,24 @@ export function parseArgs(argv) {
|
|
|
71
142
|
continue;
|
|
72
143
|
}
|
|
73
144
|
|
|
74
|
-
if (command
|
|
145
|
+
if (READ_COMMANDS.includes(command)) {
|
|
146
|
+
const equals = arg.indexOf("=");
|
|
147
|
+
const name = equals === -1 ? arg : arg.slice(0, equals);
|
|
148
|
+
if (Object.hasOwn(REFUSED, name)) {
|
|
149
|
+
const { reason } = REFUSED[name];
|
|
150
|
+
return fail(reason, REFUSED_MESSAGES[reason], "unsupported");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (Object.hasOwn(OPTIONS, command ?? "")) {
|
|
75
155
|
const equals = arg.indexOf("=");
|
|
76
156
|
const name = equals === -1 ? arg : arg.slice(0, equals);
|
|
157
|
+
if (FLAGS[command]?.has(name)) {
|
|
158
|
+
if (equals !== -1) return fail("unexpected_value", `${name} does not take a value.`);
|
|
159
|
+
if (flags.has(name)) return fail("duplicate_option", `${name} may be given only once.`);
|
|
160
|
+
flags.add(name);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
77
163
|
if (OPTIONS[command].has(name)) {
|
|
78
164
|
let value;
|
|
79
165
|
if (equals === -1) {
|
|
@@ -116,18 +202,19 @@ export function parseArgs(argv) {
|
|
|
116
202
|
return { ok: true, command: "help", topic, json };
|
|
117
203
|
}
|
|
118
204
|
if (command === "skill") return parseSkill(action, values, json, fail);
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
"Credentials, paths, queries and fragments are not accepted.",
|
|
128
|
-
);
|
|
205
|
+
if (command === "login") return parseLogin(values, flags, json, fail);
|
|
206
|
+
if (command === "setup") return parseSetup(values, flags, json, fail);
|
|
207
|
+
if (command === "verify") return parseVerify(values, flags, json, fail);
|
|
208
|
+
if (READ_COMMANDS.includes(command)) return parseRead(command, values, json, fail);
|
|
209
|
+
if (command === "logout") {
|
|
210
|
+
const paths = parsePaths(values, fail);
|
|
211
|
+
if (!paths.ok) return paths;
|
|
212
|
+
return { ok: true, command: "logout", project: paths.project, configDir: paths.configDir, json };
|
|
129
213
|
}
|
|
130
214
|
|
|
215
|
+
const origin = parseUrlOption(values);
|
|
216
|
+
if (origin === null) return fail("invalid_url", INVALID_URL);
|
|
217
|
+
|
|
131
218
|
const rawTimeout = values["--timeout-ms"];
|
|
132
219
|
const timeoutMs =
|
|
133
220
|
rawTimeout === undefined ? DEFAULT_TIMEOUT_MS : parseTimeout(rawTimeout);
|
|
@@ -141,6 +228,39 @@ export function parseArgs(argv) {
|
|
|
141
228
|
return { ok: true, command: "doctor", origin, timeoutMs, json };
|
|
142
229
|
}
|
|
143
230
|
|
|
231
|
+
function parseVerify(values, flags, json, fail) {
|
|
232
|
+
const paths = parsePaths(values, fail);
|
|
233
|
+
if (!paths.ok) return paths;
|
|
234
|
+
const traceId = values["--trace-id"] ?? null;
|
|
235
|
+
const requestId = values["--request-id"] ?? null;
|
|
236
|
+
if ((traceId === null) === (requestId === null) ||
|
|
237
|
+
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(traceId ?? requestId)) {
|
|
238
|
+
return fail("invalid_trace_identity", "Provide exactly one --trace-id or --request-id (1 to 200 safe characters).");
|
|
239
|
+
}
|
|
240
|
+
const since = values["--since"];
|
|
241
|
+
const until = values["--until"];
|
|
242
|
+
const validTime = (value) => typeof value === "string" && value.length <= 40 &&
|
|
243
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/.test(value) && Number.isFinite(Date.parse(value));
|
|
244
|
+
if (!validTime(since) || !validTime(until) || Date.parse(since) > Date.parse(until) || Date.parse(until) > Date.now()) {
|
|
245
|
+
return fail("invalid_invocation_window", "--since and --until must bound a past invocation using ISO 8601 timestamps.");
|
|
246
|
+
}
|
|
247
|
+
const source = values["--source"] ?? "unspecified";
|
|
248
|
+
if (!["application", "synthetic", "demo", "import", "unspecified"].includes(source)) {
|
|
249
|
+
return fail("invalid_source", "--source must be application, synthetic, demo, import or unspecified.");
|
|
250
|
+
}
|
|
251
|
+
const days = values["--days"] === undefined ? undefined : parseBounded(values["--days"], 1, 90);
|
|
252
|
+
if (days === null) return fail("invalid_days", "--days must be a whole number from 1 to 90.");
|
|
253
|
+
const timeoutMs = values["--timeout-ms"] === undefined ? 30000 : parseTimeout(values["--timeout-ms"], 100, 60000);
|
|
254
|
+
if (timeoutMs === null) return fail("invalid_timeout", "--timeout-ms must be a whole number from 100 to 60000.");
|
|
255
|
+
const pollIntervalMs = values["--poll-ms"] === undefined ? 1000 : parseTimeout(values["--poll-ms"], 100, 10000);
|
|
256
|
+
if (pollIntervalMs === null) return fail("invalid_poll_interval", "--poll-ms must be a whole number from 100 to 10000.");
|
|
257
|
+
const maxAttempts = values["--max-attempts"] === undefined ? 30 : parseBounded(values["--max-attempts"], 1, 60);
|
|
258
|
+
if (maxAttempts === null) return fail("invalid_max_attempts", "--max-attempts must be a whole number from 1 to 60.");
|
|
259
|
+
return { ok: true, command: "verify", project: paths.project, configDir: paths.configDir,
|
|
260
|
+
traceId, requestId, since, until, source, days, timeoutMs, pollIntervalMs, maxAttempts,
|
|
261
|
+
open: flags.has("--open"), noBrowser: flags.has("--no-browser"), json };
|
|
262
|
+
}
|
|
263
|
+
|
|
144
264
|
// Client and runtime are required, so a script states where the skill is
|
|
145
265
|
// used instead of the CLI guessing. Values for clients and runtimes that
|
|
146
266
|
// cannot use project skill files are accepted here and handed off later.
|
|
@@ -172,9 +292,195 @@ function parseSkill(action, values, json, fail) {
|
|
|
172
292
|
return { ok: true, command: "skill", action, client, runtime, project: project ?? null, json };
|
|
173
293
|
}
|
|
174
294
|
|
|
175
|
-
|
|
295
|
+
const INVALID_URL =
|
|
296
|
+
"--url must be a bare https origin such as https://metergraph.example.com, " +
|
|
297
|
+
"or an http origin on localhost, 127.0.0.1 or [::1]. " +
|
|
298
|
+
"Credentials, paths, queries and fragments are not accepted.";
|
|
299
|
+
|
|
300
|
+
function parseUrlOption(values) {
|
|
301
|
+
const raw = values["--url"];
|
|
302
|
+
return raw === undefined ? DEFAULT_ORIGIN : parseOrigin(raw);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// The runtime is required so a script states where the browser is. Cloud
|
|
306
|
+
// runtimes are accepted here and handed off later, before any request.
|
|
307
|
+
function parseLogin(values, flags, json, fail) {
|
|
308
|
+
const runtime = values["--runtime"];
|
|
309
|
+
if (runtime === undefined) return fail("missing_runtime", "--runtime is required. Use local.");
|
|
310
|
+
if (!LOGIN_RUNTIMES.includes(runtime) && !HANDOFF_LOGIN_RUNTIMES.includes(runtime)) {
|
|
311
|
+
return fail("invalid_runtime", "--runtime must be local, cloud or cloud-no-shell.");
|
|
312
|
+
}
|
|
313
|
+
const origin = parseUrlOption(values);
|
|
314
|
+
if (origin === null) return fail("invalid_url", INVALID_URL);
|
|
315
|
+
const rawWorkspace = values["--workspace"];
|
|
316
|
+
if (rawWorkspace !== undefined && !UUID.test(rawWorkspace)) {
|
|
317
|
+
return fail("invalid_workspace", "--workspace must be a workspace ID in UUID form.");
|
|
318
|
+
}
|
|
319
|
+
const paths = parsePaths(values, fail);
|
|
320
|
+
if (!paths.ok) return paths;
|
|
321
|
+
const rawTimeout = values["--timeout-ms"];
|
|
322
|
+
const timeoutMs =
|
|
323
|
+
rawTimeout === undefined
|
|
324
|
+
? LOGIN_DEFAULT_TIMEOUT_MS
|
|
325
|
+
: parseTimeout(rawTimeout, LOGIN_MIN_TIMEOUT_MS, LOGIN_MAX_TIMEOUT_MS);
|
|
326
|
+
if (timeoutMs === null) {
|
|
327
|
+
return fail(
|
|
328
|
+
"invalid_timeout",
|
|
329
|
+
`--timeout-ms must be a whole number from ${LOGIN_MIN_TIMEOUT_MS} to ${LOGIN_MAX_TIMEOUT_MS}.`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
return {
|
|
333
|
+
ok: true,
|
|
334
|
+
command: "login",
|
|
335
|
+
runtime,
|
|
336
|
+
origin,
|
|
337
|
+
workspace: rawWorkspace === undefined ? null : rawWorkspace.toLowerCase(),
|
|
338
|
+
project: paths.project,
|
|
339
|
+
configDir: paths.configDir,
|
|
340
|
+
timeoutMs,
|
|
341
|
+
signup: flags.has("--signup"),
|
|
342
|
+
noBrowser: flags.has("--no-browser"),
|
|
343
|
+
reconnect: flags.has("--reconnect"),
|
|
344
|
+
json,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function parseSetup(values, flags, json, fail) {
|
|
349
|
+
const runtime = values["--runtime"];
|
|
350
|
+
if (runtime === undefined) return fail("missing_runtime", "--runtime is required. Use local.");
|
|
351
|
+
if (!LOGIN_RUNTIMES.includes(runtime) && !HANDOFF_LOGIN_RUNTIMES.includes(runtime)) {
|
|
352
|
+
return fail("invalid_runtime", "--runtime must be local, cloud or cloud-no-shell.");
|
|
353
|
+
}
|
|
354
|
+
const paths = parsePaths(values, fail);
|
|
355
|
+
if (!paths.ok) return paths;
|
|
356
|
+
const origin = parseUrlOption(values);
|
|
357
|
+
if (origin === null) return fail("invalid_url", INVALID_URL);
|
|
358
|
+
const deployment = values["--deployment"] ?? "managed";
|
|
359
|
+
if (!["managed", "customer-local", "byoc", "oss"].includes(deployment)) {
|
|
360
|
+
return fail("invalid_deployment", "--deployment must be managed, customer-local, byoc or oss.");
|
|
361
|
+
}
|
|
362
|
+
if (deployment === "managed" && (flags.has("--confirm-prerequisites") || values["--agent-token-file"] !== undefined)) {
|
|
363
|
+
return fail("managed_route_conflict", "Operator prerequisites and agent token files are only for non-hosted deployments.");
|
|
364
|
+
}
|
|
365
|
+
if (deployment !== "managed" && values["--url"] === undefined) {
|
|
366
|
+
return fail("non_hosted_origin_required", "Non-hosted setup requires an explicit --url for the installed service.");
|
|
367
|
+
}
|
|
368
|
+
const rawWorkspace = values["--workspace"];
|
|
369
|
+
if (rawWorkspace !== undefined && !UUID.test(rawWorkspace)) {
|
|
370
|
+
return fail("invalid_workspace", "--workspace must be a workspace ID in UUID form.");
|
|
371
|
+
}
|
|
372
|
+
if (deployment !== "managed" && rawWorkspace === undefined) {
|
|
373
|
+
return fail("non_hosted_workspace_required", "Non-hosted setup requires an explicit --workspace UUID.");
|
|
374
|
+
}
|
|
375
|
+
const agentTokenFile = values["--agent-token-file"] ?? null;
|
|
376
|
+
if (agentTokenFile !== null && (agentTokenFile === "" || agentTokenFile.includes("\0"))) {
|
|
377
|
+
return fail("invalid_agent_token_file", "--agent-token-file must name a private absolute file.");
|
|
378
|
+
}
|
|
379
|
+
const client = values["--client"] ?? null;
|
|
380
|
+
if (client === null && !flags.has("--skip-skill")) {
|
|
381
|
+
return fail("client_required", "Choose --client codex, claude or cursor, or explicitly use --skip-skill.");
|
|
382
|
+
}
|
|
383
|
+
if (client !== null && !Object.hasOwn(SKILL_CLIENTS, client)) {
|
|
384
|
+
return fail("invalid_client", "--client must be codex, claude or cursor.");
|
|
385
|
+
}
|
|
386
|
+
if (client !== null && flags.has("--skip-skill")) {
|
|
387
|
+
return fail("client_conflict", "Use either --client or --skip-skill, not both.");
|
|
388
|
+
}
|
|
389
|
+
const envFile = values["--env-file"] ?? ".env";
|
|
390
|
+
if (envFile === "" || envFile.includes("\0")) return fail("invalid_env_file", "--env-file must name a project-relative env file.");
|
|
391
|
+
const rawTimeout = values["--timeout-ms"];
|
|
392
|
+
const timeoutMs = rawTimeout === undefined ? LOGIN_DEFAULT_TIMEOUT_MS :
|
|
393
|
+
parseTimeout(rawTimeout, LOGIN_MIN_TIMEOUT_MS, LOGIN_MAX_TIMEOUT_MS);
|
|
394
|
+
if (timeoutMs === null) return fail("invalid_timeout", `--timeout-ms must be a whole number from ${LOGIN_MIN_TIMEOUT_MS} to ${LOGIN_MAX_TIMEOUT_MS}.`);
|
|
395
|
+
return { ok: true, command: "setup", runtime, origin, originExplicit: values["--url"] !== undefined,
|
|
396
|
+
deployment, confirmPrerequisites: flags.has("--confirm-prerequisites"), agentTokenFile,
|
|
397
|
+
workspace: rawWorkspace === undefined ? null : rawWorkspace.toLowerCase(),
|
|
398
|
+
project: paths.project, configDir: paths.configDir, envFile, client, skipSkill: flags.has("--skip-skill"),
|
|
399
|
+
timeoutMs, noBrowser: flags.has("--no-browser"), repair: flags.has("--repair"),
|
|
400
|
+
signup: flags.has("--signup"), reconnect: flags.has("--reconnect"), json };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Read commands take the project and config directory, one total timeout and,
|
|
404
|
+
// where the endpoint supports them, bounded days, limit and trace filters.
|
|
405
|
+
// Out of range values fail here; nothing is clamped.
|
|
406
|
+
function parseRead(command, values, json, fail) {
|
|
407
|
+
const paths = parsePaths(values, fail);
|
|
408
|
+
if (!paths.ok) return paths;
|
|
409
|
+
const rawTimeout = values["--timeout-ms"];
|
|
410
|
+
const timeoutMs =
|
|
411
|
+
rawTimeout === undefined
|
|
412
|
+
? READ_DEFAULT_TIMEOUT_MS
|
|
413
|
+
: parseTimeout(rawTimeout, READ_MIN_TIMEOUT_MS, READ_MAX_TIMEOUT_MS);
|
|
414
|
+
if (timeoutMs === null) {
|
|
415
|
+
return fail(
|
|
416
|
+
"invalid_timeout",
|
|
417
|
+
`--timeout-ms must be a whole number from ${READ_MIN_TIMEOUT_MS} to ${READ_MAX_TIMEOUT_MS}.`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
const takesDays = command === "usage" || command === "traces";
|
|
421
|
+
const takesLimit = takesDays || command === "routes";
|
|
422
|
+
let days = null;
|
|
423
|
+
if (takesDays) {
|
|
424
|
+
days = values["--days"] === undefined ? READ_DEFAULT_DAYS : parseBounded(values["--days"], 1, READ_MAX_DAYS);
|
|
425
|
+
if (days === null) return fail("invalid_days", `--days must be a whole number from 1 to ${READ_MAX_DAYS}.`);
|
|
426
|
+
}
|
|
427
|
+
let limit = null;
|
|
428
|
+
if (takesLimit) {
|
|
429
|
+
const fallback = command === "traces" ? TRACES_DEFAULT_LIMIT : READ_DEFAULT_LIMIT;
|
|
430
|
+
limit = values["--limit"] === undefined ? fallback : parseBounded(values["--limit"], 1, READ_MAX_LIMIT);
|
|
431
|
+
if (limit === null) return fail("invalid_limit", `--limit must be a whole number from 1 to ${READ_MAX_LIMIT}.`);
|
|
432
|
+
}
|
|
433
|
+
const route = values["--route"] ?? null;
|
|
434
|
+
if (route !== null && !isSafeFilter(route)) {
|
|
435
|
+
return fail("invalid_route", "--route must be 1 to 256 characters without control characters.");
|
|
436
|
+
}
|
|
437
|
+
const status = values["--status"] ?? null;
|
|
438
|
+
if (status !== null && status !== "success" && status !== "error") {
|
|
439
|
+
return fail("invalid_status", "--status must be success or error.");
|
|
440
|
+
}
|
|
441
|
+
const cursor = values["--cursor"] ?? null;
|
|
442
|
+
if (cursor !== null && !isCursor(cursor)) {
|
|
443
|
+
return fail(
|
|
444
|
+
"invalid_cursor",
|
|
445
|
+
"--cursor must be the next_cursor value from an earlier traces result, at most 512 printable characters.",
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
return {
|
|
449
|
+
ok: true,
|
|
450
|
+
command,
|
|
451
|
+
project: paths.project,
|
|
452
|
+
configDir: paths.configDir,
|
|
453
|
+
timeoutMs,
|
|
454
|
+
days,
|
|
455
|
+
limit,
|
|
456
|
+
route,
|
|
457
|
+
status,
|
|
458
|
+
cursor,
|
|
459
|
+
json,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function parseBounded(raw, min, max) {
|
|
464
|
+
if (!/^[0-9]{1,3}$/.test(raw)) return null;
|
|
465
|
+
const value = Number(raw);
|
|
466
|
+
return value < min || value > max ? null : value;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function parsePaths(values, fail) {
|
|
470
|
+
const project = values["--project"];
|
|
471
|
+
if (project !== undefined && (project === "" || project.includes("\0"))) {
|
|
472
|
+
return fail("invalid_project", "--project must name an existing directory.");
|
|
473
|
+
}
|
|
474
|
+
const configDir = values["--config-dir"];
|
|
475
|
+
if (configDir !== undefined && (configDir === "" || configDir.includes("\0"))) {
|
|
476
|
+
return fail("invalid_config_dir", "--config-dir must name a directory path.");
|
|
477
|
+
}
|
|
478
|
+
return { ok: true, project: project ?? null, configDir: configDir ?? null };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function parseTimeout(raw, min = MIN_TIMEOUT_MS, max = MAX_TIMEOUT_MS) {
|
|
176
482
|
if (!/^[0-9]{1,6}$/.test(raw)) return null;
|
|
177
483
|
const value = Number(raw);
|
|
178
|
-
if (value <
|
|
484
|
+
if (value < min || value > max) return null;
|
|
179
485
|
return value;
|
|
180
486
|
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { Stop, isSlot } from "./auth-store.js";
|
|
6
|
+
import { SUPPORTED_PROFILES } from "./constants.js";
|
|
7
|
+
import { parseOrigin } from "./origin.js";
|
|
8
|
+
|
|
9
|
+
// The project binding, .metergraph/project.json, names which origin,
|
|
10
|
+
// workspace and profile a project is signed in to and which credential slot
|
|
11
|
+
// in the user's private store holds the grant. It holds no credential, user
|
|
12
|
+
// name or absolute path, so it is safe to commit. Other files in .metergraph,
|
|
13
|
+
// such as skill receipts, are never touched.
|
|
14
|
+
|
|
15
|
+
export const BINDING_PATH = ".metergraph/project.json";
|
|
16
|
+
const DIR = ".metergraph";
|
|
17
|
+
const FILE = "project.json";
|
|
18
|
+
const LOCK = "project.lock";
|
|
19
|
+
const MAX_BYTES = 16 * 1024;
|
|
20
|
+
const KEYS = ["credential_slot", "deployment_profile", "origin", "schema_version", "workspace_id"].join();
|
|
21
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
22
|
+
const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
|
|
23
|
+
|
|
24
|
+
export function resolveProject(project) {
|
|
25
|
+
try {
|
|
26
|
+
const real = fs.realpathSync(path.resolve(project ?? process.cwd()));
|
|
27
|
+
if (fs.statSync(real).isDirectory()) return real;
|
|
28
|
+
} catch {
|
|
29
|
+
// Fall through to the fixed error.
|
|
30
|
+
}
|
|
31
|
+
throw new Stop("invalid_input", "invalid_project");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Returns { binding, content } or null when the project is not bound.
|
|
35
|
+
export function readBinding(root) {
|
|
36
|
+
try {
|
|
37
|
+
const dir = lstatOrNull(path.join(root, DIR));
|
|
38
|
+
if (dir === null) return null;
|
|
39
|
+
if (!dir.isDirectory()) throw new Stop("conflict", "unsafe_path");
|
|
40
|
+
const content = readRegular(path.join(root, DIR, FILE));
|
|
41
|
+
if (content === null) return null;
|
|
42
|
+
const binding = parse(content);
|
|
43
|
+
if (binding === null) throw new Stop("conflict", "binding_invalid");
|
|
44
|
+
return { binding, content };
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error instanceof Stop) throw error;
|
|
47
|
+
throw new Stop("filesystem_error", "read_failed");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function bindingFor({ origin, workspaceId, profile, slot }) {
|
|
52
|
+
return {
|
|
53
|
+
schema_version: 1,
|
|
54
|
+
origin,
|
|
55
|
+
workspace_id: workspaceId,
|
|
56
|
+
deployment_profile: profile,
|
|
57
|
+
credential_slot: slot,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Writes binding in place of previous (the { content } readBinding saw, or
|
|
62
|
+
// null). Under the project lock, the file must still be exactly previous, so
|
|
63
|
+
// a concurrent change is never overwritten. A failed write leaves the
|
|
64
|
+
// previous file and removes a .metergraph directory this call created.
|
|
65
|
+
export function writeBinding(root, binding, previous) {
|
|
66
|
+
const content = Buffer.from(`${JSON.stringify(binding, null, 2)}\n`);
|
|
67
|
+
if (parse(content) === null) throw new Error("binding is not valid");
|
|
68
|
+
const dir = path.join(root, DIR);
|
|
69
|
+
let created = false;
|
|
70
|
+
try {
|
|
71
|
+
try {
|
|
72
|
+
fs.mkdirSync(dir);
|
|
73
|
+
created = true;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error.code !== "EEXIST") throw error;
|
|
76
|
+
if (!fs.lstatSync(dir).isDirectory()) throw new Stop("conflict", "unsafe_path");
|
|
77
|
+
}
|
|
78
|
+
withProjectLock(dir, () => {
|
|
79
|
+
expectUnchanged(dir, previous);
|
|
80
|
+
replace(dir, content);
|
|
81
|
+
});
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (created) {
|
|
84
|
+
try {
|
|
85
|
+
fs.rmdirSync(dir);
|
|
86
|
+
} catch {
|
|
87
|
+
// Not empty. An empty directory is harmless.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (error instanceof Stop) throw error;
|
|
91
|
+
throw new Stop("filesystem_error", "binding_write_failed");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Removes the binding if it is still exactly previous.
|
|
96
|
+
export function removeBinding(root, previous) {
|
|
97
|
+
const dir = path.join(root, DIR);
|
|
98
|
+
try {
|
|
99
|
+
withProjectLock(dir, () => {
|
|
100
|
+
expectUnchanged(dir, previous);
|
|
101
|
+
fs.unlinkSync(path.join(dir, FILE));
|
|
102
|
+
});
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (error instanceof Stop) throw error;
|
|
105
|
+
throw new Stop("filesystem_error", "binding_write_failed");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function withProjectLock(dir, work) {
|
|
110
|
+
const lock = path.join(dir, LOCK);
|
|
111
|
+
try {
|
|
112
|
+
fs.closeSync(fs.openSync(lock, "wx"));
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error.code === "EEXIST") throw new Stop("conflict", "binding_locked");
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
return work();
|
|
119
|
+
} finally {
|
|
120
|
+
try {
|
|
121
|
+
fs.unlinkSync(lock);
|
|
122
|
+
} catch {
|
|
123
|
+
// Reported by the next run as a held lock.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function expectUnchanged(dir, previous) {
|
|
129
|
+
const now = readRegular(path.join(dir, FILE));
|
|
130
|
+
const same = previous === null ? now === null : now !== null && now.equals(previous.content);
|
|
131
|
+
if (!same) throw new Stop("conflict", "binding_changed");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function replace(dir, content) {
|
|
135
|
+
const dest = path.join(dir, FILE);
|
|
136
|
+
const temp = path.join(dir, `.${FILE}.${randomBytes(6).toString("hex")}.tmp`);
|
|
137
|
+
const fd = fs.openSync(temp, "wx", 0o666);
|
|
138
|
+
try {
|
|
139
|
+
try {
|
|
140
|
+
fs.writeFileSync(fd, content);
|
|
141
|
+
fs.fsyncSync(fd);
|
|
142
|
+
} finally {
|
|
143
|
+
fs.closeSync(fd);
|
|
144
|
+
}
|
|
145
|
+
fs.renameSync(temp, dest);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
try {
|
|
148
|
+
fs.unlinkSync(temp);
|
|
149
|
+
} catch {
|
|
150
|
+
// Already renamed or never created.
|
|
151
|
+
}
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Accepts only the exact shape this CLI writes.
|
|
157
|
+
function parse(content) {
|
|
158
|
+
let value;
|
|
159
|
+
try {
|
|
160
|
+
value = JSON.parse(content.toString("utf8"));
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
|
|
165
|
+
if (Object.keys(value).sort().join() !== KEYS) return null;
|
|
166
|
+
if (value.schema_version !== 1) return null;
|
|
167
|
+
if (typeof value.origin !== "string" || parseOrigin(value.origin) !== value.origin) return null;
|
|
168
|
+
if (typeof value.workspace_id !== "string" || !UUID.test(value.workspace_id)) return null;
|
|
169
|
+
if (!SUPPORTED_PROFILES.includes(value.deployment_profile)) return null;
|
|
170
|
+
if (!isSlot(value.credential_slot)) return null;
|
|
171
|
+
return value;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function lstatOrNull(file) {
|
|
175
|
+
try {
|
|
176
|
+
return fs.lstatSync(file);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (error.code === "ENOENT") return null;
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function readRegular(file) {
|
|
184
|
+
const stat = lstatOrNull(file);
|
|
185
|
+
if (stat === null) return null;
|
|
186
|
+
if (!stat.isFile()) throw new Stop("conflict", "unsafe_path");
|
|
187
|
+
let fd;
|
|
188
|
+
try {
|
|
189
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | NOFOLLOW);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (error.code === "ELOOP") throw new Stop("conflict", "unsafe_path");
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
const opened = fs.fstatSync(fd);
|
|
196
|
+
if (!opened.isFile()) throw new Stop("conflict", "unsafe_path");
|
|
197
|
+
if (opened.size > MAX_BYTES) throw new Stop("conflict", "binding_invalid");
|
|
198
|
+
return fs.readFileSync(fd);
|
|
199
|
+
} finally {
|
|
200
|
+
fs.closeSync(fd);
|
|
201
|
+
}
|
|
202
|
+
}
|