metergraph-cli 0.2.0 → 0.2.2

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.
@@ -0,0 +1,169 @@
1
+ import fs from "node:fs";
2
+
3
+ import { CONNECTION_GUIDE_URL, DEFAULT_ORIGIN } from "./constants.js";
4
+
5
+ // Where this CLI runs, for the sign in and setup path matrix. Only the
6
+ // presence of a variable or file is checked; no value is ever read into
7
+ // output.
8
+ //
9
+ // local_shell A terminal on the person's own machine. Browser sign in
10
+ // and setup work here.
11
+ // local_container A container on the person's machine (Docker, Podman, a
12
+ // local dev container) with no remote marker. It is not
13
+ // treated as remote: browser sign in works when the
14
+ // container's loopback port is forwarded, and --no-browser
15
+ // prints the URL to open.
16
+ // ssh_session A shell reached over SSH. The person's browser is on
17
+ // another machine.
18
+ // cloud_workspace A hosted development environment (Codespaces, Gitpod,
19
+ // Cloud Shell, or a remote dev container). The person's
20
+ // browser is on another machine.
21
+ // ci A CI job. No person is present.
22
+ //
23
+ // Remote markers win over container markers: a cloud workspace usually runs
24
+ // in a container, and it is still remote.
25
+
26
+ const REMOTE_MARKERS = [
27
+ ["ssh_session", ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"]],
28
+ ["cloud_workspace", ["CODESPACES", "GITPOD_WORKSPACE_ID", "CLOUD_SHELL"]],
29
+ ["ci", ["CI"]],
30
+ ];
31
+ const CONTAINER_VARIABLES = ["REMOTE_CONTAINERS", "DEVCONTAINER", "container"];
32
+ const CONTAINER_FILES = ["/.dockerenv", "/run/.containerenv"];
33
+
34
+ // The fixed reason each remote kind reports. These are the reasons login and
35
+ // setup have always used, so scripts that match them keep working.
36
+ const REMOTE_REASONS = Object.freeze({
37
+ ssh_session: "ssh_session",
38
+ cloud_workspace: "cloud_workspace",
39
+ ci: "ci_environment",
40
+ });
41
+
42
+ // The secret name an agent reads its Metadata key from. It matches the name
43
+ // the hosted Keys page and the Agent Access MCP setup already use.
44
+ export const AGENT_TOKEN_ENV = "METERGRAPH_AGENT_TOKEN";
45
+ // The origin an agent key is sent to, when no project binding and no --url
46
+ // names one. It matches the MCP setup's variable.
47
+ export const AGENT_URL_ENV = "METERGRAPH_URL";
48
+
49
+ function present(value) {
50
+ return typeof value === "string" && value !== "" && value !== "0" && value.toLowerCase() !== "false";
51
+ }
52
+
53
+ function defaultFileExists(file) {
54
+ try {
55
+ return fs.existsSync(file);
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ // Returns { kind, remote, reason } where reason is null for a local kind.
62
+ export function classifyEnvironment(env = process.env, { fileExists = defaultFileExists } = {}) {
63
+ for (const [kind, names] of REMOTE_MARKERS) {
64
+ if (names.some((name) => present(env[name]))) return { kind, remote: true, reason: REMOTE_REASONS[kind] };
65
+ }
66
+ const container =
67
+ CONTAINER_VARIABLES.some((name) => present(env[name])) || CONTAINER_FILES.some((file) => fileExists(file));
68
+ return { kind: container ? "local_container" : "local_shell", remote: false, reason: null };
69
+ }
70
+
71
+ // Hosted pages a person opens on their own device. The Keys page creates a
72
+ // coding-agent key in the workspace the person is signed in to, which is how
73
+ // the person chooses the workspace. Sign up exists only on the hosted service.
74
+ export function handoffPages(origin) {
75
+ const hosted = origin === DEFAULT_ORIGIN;
76
+ return {
77
+ sign_up_url: hosted ? `${origin}/v1/auth/signup?return_to=${encodeURIComponent("/#api-keys")}` : null,
78
+ keys_url: `${origin}/#api-keys`,
79
+ };
80
+ }
81
+
82
+ // The structured next action for an agent that cannot finish browser sign in
83
+ // where it runs, or has no credential yet. It names what a person does on
84
+ // their own device, where the key goes, and the exact commands the agent runs
85
+ // afterwards. The key itself never passes through the agent's chat, a command
86
+ // line or a file in the project.
87
+ export function agentKeyAction(environment, origin) {
88
+ const pages = handoffPages(origin);
89
+ const where = environment.kind === "ci"
90
+ ? `a CI secret exposed to this job as ${AGENT_TOKEN_ENV}`
91
+ : `this environment's secret settings as ${AGENT_TOKEN_ENV}`;
92
+ const steps = [];
93
+ if (pages.sign_up_url !== null) {
94
+ steps.push({ action: "sign_up_or_sign_in", url: pages.sign_up_url });
95
+ } else {
96
+ steps.push({ action: "request_invitation", url: null });
97
+ }
98
+ steps.push({ action: "create_metadata_key", url: pages.keys_url });
99
+ steps.push({ action: "store_secret", name: AGENT_TOKEN_ENV });
100
+ // A key for a deployment other than the hosted service must only ever be
101
+ // sent there, so the commands name its origin; without it they would use
102
+ // the hosted default.
103
+ const hosted = origin === DEFAULT_ORIGIN;
104
+ const url = hosted ? "" : ` --url ${shellArg(origin)}`;
105
+ return {
106
+ kind: "use_agent_key",
107
+ environment: environment.kind,
108
+ credential_env: AGENT_TOKEN_ENV,
109
+ origin,
110
+ person_steps: steps,
111
+ then_run: [`metergraph status${url} --json`, `metergraph usage${url} --json`],
112
+ message:
113
+ (environment.remote === false
114
+ ? "If browser sign in cannot finish here, use a Metadata key instead. "
115
+ : "Browser sign in cannot finish here. ") +
116
+ "Ask the person to sign in on their own device, open the Keys page " +
117
+ "in the workspace this agent should read, create a coding-agent key with Metadata access, and save it in " +
118
+ `${where}. Do not ask them to paste the key into chat or a command. Then run the listed commands; ` +
119
+ "they verify the origin, workspace, scope and capabilities before reading." +
120
+ (hosted
121
+ ? ""
122
+ : " On the open-source self-hosted server there is no Keys page: the operator provides one of its " +
123
+ "MG_AGENT_TOKENS values instead."),
124
+ alternative: { kind: "run_on_local_machine", command: "metergraph login --runtime local" },
125
+ url: CONNECTION_GUIDE_URL,
126
+ };
127
+ }
128
+
129
+ // The next action for setup in a remote session. Ingest setup writes the
130
+ // application's own key and needs a local browser approval, so it runs on the
131
+ // person's machine. Metadata reads can still use an agent key here; that key
132
+ // is separate from the ingest key and never replaces it.
133
+ // options are the parsed setup options; the command repeats the caller's
134
+ // deployment, origin, workspace and skill choice so it runs as given.
135
+ export function remoteSetupAction(environment, origin, options = {}) {
136
+ return {
137
+ kind: "run_on_local_machine",
138
+ environment: environment.kind,
139
+ command: setupCommand(origin, options),
140
+ message:
141
+ "Setup approves an ingest key in a browser on the same machine as the CLI, so it cannot finish here. " +
142
+ "Run it on the person's own machine, or follow the connection guide. For Metadata reads from this " +
143
+ "environment, follow metadata_reads.",
144
+ metadata_reads: agentKeyAction(environment, origin),
145
+ url: CONNECTION_GUIDE_URL,
146
+ };
147
+ }
148
+
149
+ // Agents run these commands in a shell, and an origin may come from a
150
+ // committed project file, so every interpolated value is quoted even though
151
+ // the parsers already restrict it. Without a client choice the command skips
152
+ // the skill rather than show a placeholder a shell would misread.
153
+ function setupCommand(origin, options) {
154
+ const parts = ["metergraph setup --runtime local"];
155
+ if (options.deployment && options.deployment !== "managed") parts.push(`--deployment ${shellArg(options.deployment)}`);
156
+ if (origin !== DEFAULT_ORIGIN) parts.push(`--url ${shellArg(origin)}`);
157
+ if (options.workspace) parts.push(`--workspace ${shellArg(options.workspace)}`);
158
+ if (options.skipSkill || !options.client) parts.push("--skip-skill");
159
+ else parts.push(`--client ${shellArg(options.client)}`);
160
+ return parts.join(" ");
161
+ }
162
+
163
+ // A value as one POSIX shell word: as is when it holds only characters no
164
+ // shell treats specially, otherwise single-quoted.
165
+ export function shellArg(value) {
166
+ const text = String(value);
167
+ if (/^[A-Za-z0-9._:/=-]+$/.test(text)) return text;
168
+ return `'${text.replace(/'/g, "'\\''")}'`;
169
+ }
package/src/origin.js CHANGED
@@ -9,6 +9,7 @@ const HTTPS_ORIGIN = /^https:\/\/[^/?#@\\]+\/?$/i;
9
9
  // such as 127.1 or 0x7f000001 are rejected even though they resolve locally.
10
10
  const HTTP_LOOPBACK_ORIGIN = /^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]{1,5})?\/?$/i;
11
11
  const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
12
+ const SAFE_HOSTNAME = /^(?:[a-z0-9-]+(?:\.[a-z0-9-]+)*\.?|\[[0-9a-f:.]+\])$/;
12
13
 
13
14
  // Returns the normalized origin (for example "https://example.com") or null.
14
15
  // The caller must not echo the raw input when this returns null.
@@ -32,6 +33,11 @@ export function parseOrigin(raw) {
32
33
  if (url.pathname !== "/" || url.search !== "" || url.hash !== "") return null;
33
34
  if (url.hostname === "" || url.port === "0") return null;
34
35
  if (url.protocol === "http:" && !LOOPBACK_HOSTNAMES.has(url.hostname)) return null;
36
+ // A DNS name (letters, digits, hyphens and dots) or a bracketed IPv6
37
+ // literal. The URL parser accepts characters such as ; $ ( ) ` & ' in a
38
+ // host, which no real deployment uses and which would be unsafe wherever
39
+ // the origin is shown inside a command.
40
+ if (!SAFE_HOSTNAME.test(url.hostname)) return null;
35
41
 
36
42
  return url.origin;
37
43
  }