metergraph-cli 0.2.0-preview.2 → 0.2.0-preview.4
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 +147 -839
- package/assets/skill/SKILL.md +22 -2
- package/assets/skill/manifest.json +3 -3
- package/package.json +1 -1
- package/src/args.js +13 -3
- package/src/auth-login.js +10 -9
- package/src/auth-oauth.js +26 -0
- package/src/cli.js +19 -1
- package/src/output.js +5 -2
- package/src/repository-identity.js +246 -0
- package/src/setup.js +31 -8
- package/src/skill-bundle.js +1 -1
- package/src/verify.js +7 -2
package/assets/skill/SKILL.md
CHANGED
|
@@ -7,6 +7,10 @@ description: Route Metergraph setup by client, execution runtime and deployment,
|
|
|
7
7
|
|
|
8
8
|
Public setup: https://www.metergraph.dev/docs/guides/agent-access/
|
|
9
9
|
Connection and troubleshooting: https://www.metergraph.dev/docs/guides/agent-access/
|
|
10
|
+
Documentation index, every public page with a one-line summary: https://www.metergraph.dev/llms.txt
|
|
11
|
+
|
|
12
|
+
If a step fails or something is unclear, look it up in that index before
|
|
13
|
+
improvising, asking for new credentials or stopping.
|
|
10
14
|
|
|
11
15
|
## Confirm setup before producing instructions
|
|
12
16
|
|
|
@@ -22,12 +26,28 @@ Use the routing below with those four choices. Follow the matching credential an
|
|
|
22
26
|
For a first application trace, check the installed CLI's version and help before
|
|
23
27
|
using it. `metergraph-cli@0.1.0` supports `doctor` and project skill
|
|
24
28
|
installation; later versions may also offer `login`, `setup` and `verify`.
|
|
25
|
-
Use those commands only if the installed package lists them.
|
|
29
|
+
Use those commands only if the installed package lists them. Install this skill
|
|
30
|
+
with whichever the installed CLI lists, `metergraph setup --client <client>` or
|
|
31
|
+
`metergraph skill install --client <client> --runtime local`, not by copying this
|
|
32
|
+
file into the project: the CLI never replaces a skill file it did not install. Pass `--skip-skill` to `setup` when the person chose not to
|
|
33
|
+
install the skill. Use the [first
|
|
26
34
|
trace guide](https://www.metergraph.dev/docs/start/first-trace/) for steps the
|
|
27
35
|
package does not support. A CLI login or health probe alone does not prove
|
|
28
36
|
application traffic. Verification needs an exact trace or request ID from an
|
|
29
37
|
application invocation, its time window and the intended workspace.
|
|
30
38
|
|
|
39
|
+
Repository identity is optional for capture but keeps traces attributable when
|
|
40
|
+
several repositories share a workspace. If the installed CLI's `setup` help lists
|
|
41
|
+
`--repository`, setup records `owner/name` from the git remote in a committed
|
|
42
|
+
`.metergraph/config.json` when none exists, reports a different existing value
|
|
43
|
+
instead of changing it, and skips this with `--no-repository`. Otherwise, with the
|
|
44
|
+
person's agreement, add `.metergraph/config.json` containing
|
|
45
|
+
`{"version": 2, "repository": "owner/name"}`; it holds no secret, so show the diff
|
|
46
|
+
and let them commit it. For several repositories, run setup once per repository,
|
|
47
|
+
pass the same `--workspace` UUID each time, and give each repository its own
|
|
48
|
+
identity. The SDK warning that repository identity is not configured does not
|
|
49
|
+
stop capture.
|
|
50
|
+
|
|
31
51
|
## Route honestly
|
|
32
52
|
|
|
33
53
|
| Client and runtime | Hosted | Customer-local bundle | Customer AWS | Open source self-hosted |
|
|
@@ -70,4 +90,4 @@ Use https://www.metergraph.dev/docs/ for public product documentation and https:
|
|
|
70
90
|
4. Explain the failure or anomaly using only returned evidence. Cite workspace, time window, provenance, trace/report IDs, completeness/coverage, warnings and working app links when returned. Do not fabricate links. Distinguish no data, incomplete evidence, unavailable capability, classification pending, stale/mismatched identity, failed analysis and missing report. An empty result alone does not prove absence of traffic.
|
|
71
91
|
5. An optional approved replay must be eligible, bounded and non-persistent. Do not claim that it changed production telemetry or fixed the application. Refuse autonomous dashboard mutations or production configuration changes.
|
|
72
92
|
|
|
73
|
-
For an empty workspace, use https://www.metergraph.dev/docs/agents/instrument-a-repo
|
|
93
|
+
For an empty workspace, use https://www.metergraph.dev/docs/agents/instrument-a-repo/. If `metergraph setup` already ran in this project, it delivered the ingest credential: the project's `.env` holds `METERGRAPH_APP_TOKEN` and `METERGRAPH_INGEST_URL`. Use them; do not ask for or create another key, and never print their values. The SDK reads the process environment, not `.env`, so load it when you run the application (for example `set -a; . ./.env; set +a`, python-dotenv or `node --env-file=.env`). Otherwise confirm the ingestion destination and obtain a separate ingest credential securely. Ask before running anything that calls a paid model provider, and do not initiate provider calls or paid work merely to validate setup. Confirm the exact trace with `metergraph verify`; if the CLI is not installed, or `metergraph --version` lacks `verify`, use `npx --yes metergraph-cli@next verify`. Ingestion accepted, processed, retained content, classification readiness and analysis readiness are distinct states. Label synthetic/demo fixtures; they do not prove a first real application trace.
|
|
@@ -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": "8b81eb27b6159c4447a5c300409c4c64917238b7797f5ee37a35248cba50fcae",
|
|
7
|
+
"size": 12436,
|
|
8
|
+
"revision": "sha256-8b81eb27b615"
|
|
9
9
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metergraph-cli",
|
|
3
|
-
"version": "0.2.0-preview.
|
|
3
|
+
"version": "0.2.0-preview.4",
|
|
4
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": {
|
package/src/args.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseRepository } from "./repository-identity.js";
|
|
1
2
|
import {
|
|
2
3
|
DEFAULT_ORIGIN,
|
|
3
4
|
DEFAULT_TIMEOUT_MS,
|
|
@@ -33,7 +34,7 @@ const OPTIONS = {
|
|
|
33
34
|
doctor: new Set(["--url", "--timeout-ms"]),
|
|
34
35
|
skill: new Set(["--client", "--runtime", "--project"]),
|
|
35
36
|
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
|
+
setup: new Set(["--runtime", "--url", "--workspace", "--project", "--config-dir", "--env-file", "--client", "--timeout-ms", "--deployment", "--agent-token-file", "--repository"]),
|
|
37
38
|
logout: new Set(["--project", "--config-dir"]),
|
|
38
39
|
status: new Set(READ_BASE),
|
|
39
40
|
context: new Set(READ_BASE),
|
|
@@ -75,7 +76,7 @@ const REFUSED_MESSAGES = {
|
|
|
75
76
|
// Options that take no value.
|
|
76
77
|
const FLAGS = {
|
|
77
78
|
login: new Set(["--signup", "--no-browser", "--reconnect"]),
|
|
78
|
-
setup: new Set(["--no-browser", "--repair", "--signup", "--reconnect", "--skip-skill", "--confirm-prerequisites"]),
|
|
79
|
+
setup: new Set(["--no-browser", "--repair", "--signup", "--reconnect", "--skip-skill", "--confirm-prerequisites", "--no-repository"]),
|
|
79
80
|
verify: new Set(["--open", "--no-browser"]),
|
|
80
81
|
};
|
|
81
82
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
@@ -388,6 +389,14 @@ function parseSetup(values, flags, json, fail) {
|
|
|
388
389
|
}
|
|
389
390
|
const envFile = values["--env-file"] ?? ".env";
|
|
390
391
|
if (envFile === "" || envFile.includes("\0")) return fail("invalid_env_file", "--env-file must name a project-relative env file.");
|
|
392
|
+
const rawRepository = values["--repository"];
|
|
393
|
+
const repository = rawRepository === undefined ? null : parseRepository(rawRepository);
|
|
394
|
+
if (rawRepository !== undefined && repository === null) {
|
|
395
|
+
return fail("invalid_repository", "--repository must be owner/name, for example example-org/example-app.");
|
|
396
|
+
}
|
|
397
|
+
if (repository !== null && flags.has("--no-repository")) {
|
|
398
|
+
return fail("repository_conflict", "Use either --repository or --no-repository, not both.");
|
|
399
|
+
}
|
|
391
400
|
const rawTimeout = values["--timeout-ms"];
|
|
392
401
|
const timeoutMs = rawTimeout === undefined ? LOGIN_DEFAULT_TIMEOUT_MS :
|
|
393
402
|
parseTimeout(rawTimeout, LOGIN_MIN_TIMEOUT_MS, LOGIN_MAX_TIMEOUT_MS);
|
|
@@ -397,7 +406,8 @@ function parseSetup(values, flags, json, fail) {
|
|
|
397
406
|
workspace: rawWorkspace === undefined ? null : rawWorkspace.toLowerCase(),
|
|
398
407
|
project: paths.project, configDir: paths.configDir, envFile, client, skipSkill: flags.has("--skip-skill"),
|
|
399
408
|
timeoutMs, noBrowser: flags.has("--no-browser"), repair: flags.has("--repair"),
|
|
400
|
-
signup: flags.has("--signup"), reconnect: flags.has("--reconnect"),
|
|
409
|
+
signup: flags.has("--signup"), reconnect: flags.has("--reconnect"),
|
|
410
|
+
repository, skipRepository: flags.has("--no-repository"), json };
|
|
401
411
|
}
|
|
402
412
|
|
|
403
413
|
// Read commands take the project and config directory, one total timeout and,
|
package/src/auth-login.js
CHANGED
|
@@ -10,11 +10,11 @@ import {
|
|
|
10
10
|
} from "./auth-binding.js";
|
|
11
11
|
import {
|
|
12
12
|
authorizationUrl,
|
|
13
|
+
clientFor,
|
|
13
14
|
discover,
|
|
14
15
|
endpointsFor,
|
|
15
16
|
exchangeCode,
|
|
16
17
|
newPkce,
|
|
17
|
-
register,
|
|
18
18
|
revoke,
|
|
19
19
|
verifyContext,
|
|
20
20
|
} from "./auth-oauth.js";
|
|
@@ -164,10 +164,11 @@ export async function runLogin(options, progress) {
|
|
|
164
164
|
}
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
// Preflight, discovery,
|
|
168
|
-
// exchange and server-side verification. Nothing is
|
|
169
|
-
// grant that is not accepted is sent for revocation
|
|
170
|
-
// best effort, and a token response that fails
|
|
167
|
+
// Preflight, discovery, the client (offered or newly registered), the browser
|
|
168
|
+
// round trip, the code exchange and server-side verification. Nothing is
|
|
169
|
+
// written here. A validated grant that is not accepted is sent for revocation
|
|
170
|
+
// before returning; that is best effort, and a token response that fails
|
|
171
|
+
// validation cannot be revoked.
|
|
171
172
|
async function authorize(options, ctx, cancel, progress) {
|
|
172
173
|
const preflight = await runDoctor({ origin: ctx.origin, timeoutMs: AUTH_HTTP_TIMEOUT_MS });
|
|
173
174
|
if (cancel.aborted) return fail("authorization_failed", "cancelled");
|
|
@@ -181,7 +182,7 @@ async function authorize(options, ctx, cancel, progress) {
|
|
|
181
182
|
let limit = deadline(AUTH_HTTP_TIMEOUT_MS, cancel);
|
|
182
183
|
const discovered = await discover(ctx.origin, limit.signal);
|
|
183
184
|
if (!discovered.ok) return httpFailure(discovered, cancel);
|
|
184
|
-
const { endpoints, requireIss } = discovered;
|
|
185
|
+
const { endpoints, requireIss, offeredClientId } = discovered;
|
|
185
186
|
|
|
186
187
|
const listener = await startCallbackListener();
|
|
187
188
|
let callback;
|
|
@@ -189,9 +190,9 @@ async function authorize(options, ctx, cancel, progress) {
|
|
|
189
190
|
let verifier;
|
|
190
191
|
try {
|
|
191
192
|
limit = deadline(AUTH_HTTP_TIMEOUT_MS, cancel);
|
|
192
|
-
const
|
|
193
|
-
if (!
|
|
194
|
-
clientId =
|
|
193
|
+
const client = await clientFor(endpoints, offeredClientId, listener.redirectUri, limit.signal);
|
|
194
|
+
if (!client.ok) return httpFailure(client, cancel);
|
|
195
|
+
clientId = client.clientId;
|
|
195
196
|
|
|
196
197
|
const pkce = newPkce();
|
|
197
198
|
verifier = pkce.verifier;
|
package/src/auth-oauth.js
CHANGED
|
@@ -23,6 +23,12 @@ const ACCESS_TOKEN = /^[\x21-\x7e]{16,8192}$/;
|
|
|
23
23
|
const REFRESH_TOKEN = /^[\x21-\x7e]{16,4096}$/;
|
|
24
24
|
const MAX_EXPIRES_IN = 366 * 24 * 60 * 60;
|
|
25
25
|
|
|
26
|
+
// A deployment that configures the Metergraph CLI as a known OAuth client
|
|
27
|
+
// names its client ID in this metadata field, in the authorization server
|
|
28
|
+
// metadata and in the setup discovery document. Without it the CLI registers
|
|
29
|
+
// a new public client for each sign in, as servers without the field expect.
|
|
30
|
+
export const CLI_CLIENT_ID_FIELD = "metergraph_cli_client_id";
|
|
31
|
+
|
|
26
32
|
// The access token claim that names the workspace the grant is for. The
|
|
27
33
|
// claim is a sanity check only; the CLI does not verify token signatures.
|
|
28
34
|
// The server-authoritative check is GET /v1/agent/workspace.
|
|
@@ -109,13 +115,33 @@ export async function discover(origin, signal) {
|
|
|
109
115
|
) {
|
|
110
116
|
return stop("unsupported", "revocation_unsupported");
|
|
111
117
|
}
|
|
118
|
+
const offered = offeredClientId(server);
|
|
119
|
+
if (offered === undefined) return stop("unsupported", "oauth_metadata_invalid");
|
|
112
120
|
return {
|
|
113
121
|
ok: true,
|
|
114
122
|
endpoints: expected,
|
|
115
123
|
requireIss: server.authorization_response_iss_parameter_supported === true,
|
|
124
|
+
offeredClientId: offered,
|
|
116
125
|
};
|
|
117
126
|
}
|
|
118
127
|
|
|
128
|
+
// The client ID a metadata document offers for the CLI: the ID, null when the
|
|
129
|
+
// field is absent or null, or undefined when the value is malformed. A
|
|
130
|
+
// malformed value is refused rather than ignored, like any other bad field.
|
|
131
|
+
export function offeredClientId(doc) {
|
|
132
|
+
const value = doc[CLI_CLIENT_ID_FIELD];
|
|
133
|
+
if (value === undefined || value === null) return null;
|
|
134
|
+
return typeof value === "string" && CLIENT_ID.test(value) ? value : undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// The client for one sign in: the pre-registered client the service offered,
|
|
138
|
+
// or a newly registered one when it offered none. The offered client accepts
|
|
139
|
+
// the loopback redirect on any port, so it needs no registration request.
|
|
140
|
+
export async function clientFor(endpoints, offered, redirectUri, signal) {
|
|
141
|
+
if (offered !== null) return { ok: true, clientId: offered };
|
|
142
|
+
return register(endpoints, redirectUri, signal);
|
|
143
|
+
}
|
|
144
|
+
|
|
119
145
|
async function fetchMetadata(origin, paths, signal) {
|
|
120
146
|
for (const path of paths) {
|
|
121
147
|
const response = await send(origin, path, { signal, maxBytes: METADATA_BYTES });
|
package/src/cli.js
CHANGED
|
@@ -105,7 +105,8 @@ async function run(argv, { stdout, stderr }) {
|
|
|
105
105
|
}
|
|
106
106
|
const receipt = result.data?.receipt;
|
|
107
107
|
const summary = receipt ? `Workspace: ${receipt.workspace_id}\nDeployment: ${receipt.deployment_profile} at ${receipt.origin}\nCompleted: ${receipt.completed_steps.join(", ")}\nPending: ${receipt.pending_steps.join(", ")}\n` : "";
|
|
108
|
-
const
|
|
108
|
+
const identity = repositoryLine(result.data?.repository);
|
|
109
|
+
const human = `Metergraph setup: ${result.data?.status ?? "not_ready"}\n${summary}${identity}Application traffic verified: no\n${result.ok ? "Next: instrument your application and verify an exact trace.\n" : `Result: ${result.outcome} (${result.error.reason})\n`}`;
|
|
109
110
|
if (containsKnownCredential(human, routed)) {
|
|
110
111
|
result = envelope({ command: "setup", outcome: "unsupported", reason: "credential_echo" });
|
|
111
112
|
stdout.write(parsed.json ? toJsonLine(result) : "Setup stopped.\n");
|
|
@@ -164,3 +165,20 @@ async function run(argv, { stdout, stderr }) {
|
|
|
164
165
|
stdout.write(parsed.json ? toJsonLine(result) : doctorText(result));
|
|
165
166
|
return result;
|
|
166
167
|
}
|
|
168
|
+
|
|
169
|
+
// One line on the repository identity setup found or recorded. The value is
|
|
170
|
+
// owner/name only; a remote URL is never shown.
|
|
171
|
+
function repositoryLine(repository) {
|
|
172
|
+
if (!repository) return "";
|
|
173
|
+
const where = repository.path ? ` in ${repository.path}` : repository.source === "env_file" ? " in the env file" : "";
|
|
174
|
+
switch (repository.status) {
|
|
175
|
+
case "written": return `Repository: ${repository.repository} (recorded${where}; commit it)\n`;
|
|
176
|
+
case "existing": return repository.repository === null
|
|
177
|
+
? `Repository: already set${where}\n` : `Repository: ${repository.repository} (already set${where})\n`;
|
|
178
|
+
case "mismatch": return `Repository: ${repository.repository} (already set${where}; differs from ${repository.expected}, left unchanged)\n`;
|
|
179
|
+
case "invalid_config": return `Repository: not recorded (${repository.path} is not a usable config; left unchanged)\n`;
|
|
180
|
+
case "skipped": return "Repository: not recorded (--no-repository)\n";
|
|
181
|
+
case "write_failed": return `Repository: not recorded (could not write ${repository.path})\n`;
|
|
182
|
+
default: return "Repository: not recorded (no owner/name git remote; pass --repository OWNER/NAME)\n";
|
|
183
|
+
}
|
|
184
|
+
}
|
package/src/output.js
CHANGED
|
@@ -112,7 +112,7 @@ const LOGIN_OPTIONS = [
|
|
|
112
112
|
summary: `Time to wait for the browser, ${LOGIN_MIN_TIMEOUT_MS} to ${LOGIN_MAX_TIMEOUT_MS}. Default ${LOGIN_DEFAULT_TIMEOUT_MS}.`,
|
|
113
113
|
},
|
|
114
114
|
{ name: "--signup", value: null, summary: "Start at the hosted sign up page. Managed service only." },
|
|
115
|
-
{ name: "--no-browser", value: null, summary: "Print the sign in URL on stderr instead of opening a browser.
|
|
115
|
+
{ name: "--no-browser", value: null, summary: "Print the sign in URL on stderr instead of opening a browser. With --json, only a rerun that needs no sign in succeeds." },
|
|
116
116
|
{ name: "--reconnect", value: null, summary: "Allow switching a bound project to another origin or workspace." },
|
|
117
117
|
{ name: "--json", value: null, summary: "Print one JSON line on stdout." },
|
|
118
118
|
];
|
|
@@ -130,6 +130,7 @@ const LOGOUT_USAGE = "metergraph logout [--project DIR] [--config-dir DIR] [--js
|
|
|
130
130
|
const SETUP_USAGE = "metergraph setup --runtime local (--client codex|claude|cursor | --skip-skill) " +
|
|
131
131
|
"[--url ORIGIN] [--workspace UUID] [--project DIR] [--config-dir DIR] [--env-file .env] " +
|
|
132
132
|
"[--deployment managed|customer-local|byoc|oss] [--confirm-prerequisites] [--agent-token-file FILE] " +
|
|
133
|
+
"[--repository OWNER/NAME | --no-repository] " +
|
|
133
134
|
"[--timeout-ms N] [--signup] [--reconnect] [--no-browser] [--repair] [--json]";
|
|
134
135
|
const JSON_OPTION = { name: "--json", value: null, summary: "Print one JSON line on stdout." };
|
|
135
136
|
const VERIFY_USAGE =
|
|
@@ -310,11 +311,13 @@ export function helpData(topic) {
|
|
|
310
311
|
{ name: "--timeout-ms", value: "N", summary: "Time to wait for browser approval." },
|
|
311
312
|
{ name: "--signup", value: null, summary: "Start at hosted sign up when the project needs login." },
|
|
312
313
|
{ name: "--reconnect", value: null, summary: "Permit switching an existing project binding." },
|
|
313
|
-
{ name: "--no-browser", value: null, summary: "Print approval URL on stderr
|
|
314
|
+
{ name: "--no-browser", value: null, summary: "Print approval URL on stderr. With --json, only a rerun that needs no approval succeeds." },
|
|
314
315
|
{ name: "--repair", value: null, summary: "Explicitly approve replacement of an acknowledged key that no longer verifies." },
|
|
315
316
|
{ name: "--deployment", value: "MODEL", summary: "managed, customer-local, byoc or oss. Default managed." },
|
|
316
317
|
{ name: "--confirm-prerequisites", value: null, summary: "Attest deployment prerequisites are met; it does not verify bundle publication." },
|
|
317
318
|
{ name: "--agent-token-file", value: "FILE", summary: "Optional separate Metadata token for local/BYOC; required for OSS handoff." },
|
|
319
|
+
{ name: "--repository", value: "OWNER/NAME", summary: "Repository identity to record in .metergraph/config.json when none exists. Default: inferred from the git remote." },
|
|
320
|
+
{ name: "--no-repository", value: null, summary: "Leave the repository identity unrecorded." },
|
|
318
321
|
JSON_OPTION,
|
|
319
322
|
],
|
|
320
323
|
},
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
// Repository identity for the SDKs: `.metergraph/config.json` with
|
|
6
|
+
// {"version": 2, "repository": "owner/name"}. The SDKs read
|
|
7
|
+
// METERGRAPH_REPOSITORY first, then the nearest config.json walking up from
|
|
8
|
+
// the app root. The file holds no secret and is meant to be committed.
|
|
9
|
+
//
|
|
10
|
+
// Setup records the identity only when none exists. An existing identity is
|
|
11
|
+
// never changed; a different value from the git remote or --repository is
|
|
12
|
+
// reported as a mismatch for the person to resolve. A git remote URL can
|
|
13
|
+
// carry credentials, so only the parsed owner/name ever leaves this module.
|
|
14
|
+
|
|
15
|
+
export const CONFIG_PATH = ".metergraph/config.json";
|
|
16
|
+
const CONFIG_VERSION = 2;
|
|
17
|
+
const MAX_CONFIG_BYTES = 16384;
|
|
18
|
+
const MAX_WALK_UP = 64;
|
|
19
|
+
const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
|
|
20
|
+
const NONBLOCK = fs.constants.O_NONBLOCK ?? 0;
|
|
21
|
+
// One owner and one name, each a plain path segment. Hosts that nest groups
|
|
22
|
+
// (owner/group/name) are not guessed at; pass --repository instead.
|
|
23
|
+
const SEGMENT = /^(?!\.{1,2}$)[A-Za-z0-9_.-]{1,100}$/;
|
|
24
|
+
|
|
25
|
+
export function parseRepository(value) {
|
|
26
|
+
if (typeof value !== "string") return null;
|
|
27
|
+
const parts = value.trim().split("/");
|
|
28
|
+
if (parts.length !== 2 || !parts.every((part) => SEGMENT.test(part))) return null;
|
|
29
|
+
return `${parts[0]}/${parts[1]}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// owner/name from a git remote URL, or null. Accepts https://host/owner/name,
|
|
33
|
+
// ssh://user@host[:port]/owner/name and user@host:owner/name, each with an
|
|
34
|
+
// optional .git suffix.
|
|
35
|
+
export function repositoryFromRemote(url) {
|
|
36
|
+
if (typeof url !== "string") return null;
|
|
37
|
+
const text = url.trim();
|
|
38
|
+
let pathname = null;
|
|
39
|
+
if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(text)) {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = new URL(text);
|
|
42
|
+
if (!["https:", "http:", "ssh:", "git:"].includes(parsed.protocol)) return null;
|
|
43
|
+
pathname = decodeURIComponent(parsed.pathname);
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
const scp = /^[^@/\s]+@[^:/\s]+:(.+)$/.exec(text);
|
|
49
|
+
if (scp === null) return null;
|
|
50
|
+
pathname = scp[1];
|
|
51
|
+
}
|
|
52
|
+
const trimmed = pathname.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
|
|
53
|
+
return parseRepository(trimmed);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function gitEnv() {
|
|
57
|
+
const env = {};
|
|
58
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
59
|
+
if (!key.toUpperCase().startsWith("GIT_") ||
|
|
60
|
+
["GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_CONFIG_NOSYSTEM", "GIT_CEILING_DIRECTORIES",
|
|
61
|
+
"GIT_DISCOVERY_ACROSS_FILESYSTEM"].includes(key.toUpperCase())) env[key] = value;
|
|
62
|
+
}
|
|
63
|
+
env.GIT_TERMINAL_PROMPT = "0";
|
|
64
|
+
env.GIT_OPTIONAL_LOCKS = "0";
|
|
65
|
+
return env;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Read-only: git runs without a shell, from the project, with fixed arguments.
|
|
69
|
+
function git(root, args) {
|
|
70
|
+
const result = spawnSync("git", ["-c", "core.fsmonitor=false", ...args], {
|
|
71
|
+
cwd: root, env: gitEnv(), shell: false, windowsHide: true, timeout: 15000,
|
|
72
|
+
maxBuffer: 64 * 1024, stdio: ["ignore", "pipe", "ignore"],
|
|
73
|
+
});
|
|
74
|
+
if (result.error?.code === "ENOENT") return { missing: true, out: null };
|
|
75
|
+
if (result.error || result.status !== 0) return { missing: false, out: null };
|
|
76
|
+
return { missing: false, out: result.stdout.toString("utf8").trim() };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The origin remote, or the only remote when there is exactly one. Returns
|
|
80
|
+
// { repository, reason } and never the URL itself.
|
|
81
|
+
export function inferFromGit(root) {
|
|
82
|
+
const inside = git(root, ["rev-parse", "--is-inside-work-tree"]);
|
|
83
|
+
if (inside.missing) return { repository: null, reason: "git_unavailable" };
|
|
84
|
+
if (inside.out !== "true") return { repository: null, reason: "not_a_git_repository" };
|
|
85
|
+
let url = git(root, ["config", "--get", "remote.origin.url"]).out;
|
|
86
|
+
if (url === null) {
|
|
87
|
+
const remotes = (git(root, ["remote"]).out ?? "").split("\n").filter(Boolean);
|
|
88
|
+
if (remotes.length !== 1) return { repository: null, reason: remotes.length ? "remote_ambiguous" : "no_remote" };
|
|
89
|
+
url = git(root, ["config", "--get", `remote.${remotes[0]}.url`]).out;
|
|
90
|
+
}
|
|
91
|
+
const repository = repositoryFromRemote(url);
|
|
92
|
+
return repository ? { repository, reason: null } : { repository: null, reason: "remote_unrecognized" };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function readConfig(file) {
|
|
96
|
+
// Check the type before opening: opening a FIFO for reading would block.
|
|
97
|
+
let stat;
|
|
98
|
+
try {
|
|
99
|
+
stat = fs.statSync(file);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error.code === "ENOENT" || error.code === "ENOTDIR") return { exists: false };
|
|
102
|
+
return { exists: true, invalid: true };
|
|
103
|
+
}
|
|
104
|
+
if (!stat.isFile()) return { exists: true, invalid: true };
|
|
105
|
+
let fd;
|
|
106
|
+
try {
|
|
107
|
+
// O_NONBLOCK also covers a file swapped for a FIFO after the check.
|
|
108
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | NONBLOCK);
|
|
109
|
+
} catch {
|
|
110
|
+
return { exists: true, invalid: true };
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
if (!fs.fstatSync(fd).isFile()) return { exists: true, invalid: true };
|
|
114
|
+
const buffer = Buffer.alloc(MAX_CONFIG_BYTES + 1);
|
|
115
|
+
const length = fs.readSync(fd, buffer, 0, buffer.length, 0);
|
|
116
|
+
if (length > MAX_CONFIG_BYTES) return { exists: true, invalid: true };
|
|
117
|
+
const doc = JSON.parse(buffer.subarray(0, length).toString("utf8"));
|
|
118
|
+
if (doc === null || typeof doc !== "object" || Array.isArray(doc)) return { exists: true, invalid: true };
|
|
119
|
+
return { exists: true, doc };
|
|
120
|
+
} catch {
|
|
121
|
+
return { exists: true, invalid: true };
|
|
122
|
+
} finally {
|
|
123
|
+
fs.closeSync(fd);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The nearest config.json walking up from root, as the SDKs search.
|
|
128
|
+
function nearestConfig(root) {
|
|
129
|
+
let dir = root;
|
|
130
|
+
for (let i = 0; i < MAX_WALK_UP; i += 1) {
|
|
131
|
+
const file = path.join(dir, CONFIG_PATH);
|
|
132
|
+
const found = readConfig(file);
|
|
133
|
+
if (found.exists) return { ...found, rel: path.relative(root, file).split(path.sep).join("/") };
|
|
134
|
+
const parent = path.dirname(dir);
|
|
135
|
+
if (parent === dir) break;
|
|
136
|
+
dir = parent;
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Writes a temporary file, then links it into place: link never replaces an
|
|
142
|
+
// existing entry, so a config that appeared meanwhile is kept, and a failed
|
|
143
|
+
// write never leaves a partial config.json behind.
|
|
144
|
+
function writeNewConfig(root, repository) {
|
|
145
|
+
const dir = path.join(root, ".metergraph");
|
|
146
|
+
try {
|
|
147
|
+
if (!fs.lstatSync(dir).isDirectory()) throw new Error("unsafe_path");
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (error.code !== "ENOENT") throw error;
|
|
150
|
+
fs.mkdirSync(dir, { mode: 0o755 });
|
|
151
|
+
}
|
|
152
|
+
const dest = path.join(dir, "config.json");
|
|
153
|
+
const temp = path.join(dir, `.config.json.${process.pid}.${Date.now()}.tmp`);
|
|
154
|
+
const content = `${JSON.stringify({ version: CONFIG_VERSION, repository }, null, 2)}\n`;
|
|
155
|
+
try {
|
|
156
|
+
const fd = fs.openSync(temp, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | NOFOLLOW, 0o644);
|
|
157
|
+
try { fs.writeFileSync(fd, content); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
158
|
+
fs.linkSync(temp, dest);
|
|
159
|
+
} finally {
|
|
160
|
+
try { fs.unlinkSync(temp); } catch { /* already gone */ }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const same = (a, b) => a.toLowerCase() === b.toLowerCase();
|
|
165
|
+
|
|
166
|
+
// METERGRAPH_REPOSITORY from the project env file, read only, so an identity
|
|
167
|
+
// the person already set there is respected. Setup has validated envFile as
|
|
168
|
+
// project-relative before this runs. Returns null when unset or unreadable.
|
|
169
|
+
export function envRepository(root, envFile) {
|
|
170
|
+
let text;
|
|
171
|
+
try {
|
|
172
|
+
const file = path.join(root, envFile);
|
|
173
|
+
if (fs.lstatSync(file).size > 1024 * 1024) return null;
|
|
174
|
+
text = fs.readFileSync(file, "utf8");
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
let value = null;
|
|
179
|
+
for (const line of text.split(/\r?\n/)) {
|
|
180
|
+
const match = /^\s*(?:export\s+)?METERGRAPH_REPOSITORY\s*=\s*(.*?)\s*$/.exec(line);
|
|
181
|
+
if (match === null) continue;
|
|
182
|
+
let raw = match[1];
|
|
183
|
+
const quoted = /^(["'])(.*?)\1(?:\s+#.*)?$/.exec(raw);
|
|
184
|
+
raw = quoted ? quoted[2] : raw.replace(/\s+#.*$/, "");
|
|
185
|
+
value = raw.trim() || null;
|
|
186
|
+
}
|
|
187
|
+
return value;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Ensures the project has a repository identity and returns the setup result
|
|
191
|
+
// field. requested is a validated --repository value or null; envValue is
|
|
192
|
+
// METERGRAPH_REPOSITORY from the project env file, or null.
|
|
193
|
+
export function ensureRepositoryIdentity(root, { requested = null, skip = false, envValue = null, retried = false } = {}) {
|
|
194
|
+
if (skip) return { status: "skipped", repository: null, source: null, path: null, reason: null };
|
|
195
|
+
const inferred = requested === null ? inferFromGit(root) : { repository: requested, reason: null };
|
|
196
|
+
const expected = inferred.repository;
|
|
197
|
+
const expectedSource = requested === null ? "git_remote" : "flag";
|
|
198
|
+
|
|
199
|
+
// The SDK uses any env value containing "/". Respect it, but report only a
|
|
200
|
+
// parsed owner/name: the env file also holds secrets, so a raw value is
|
|
201
|
+
// never echoed.
|
|
202
|
+
if (typeof envValue === "string" && envValue.includes("/")) {
|
|
203
|
+
const current = parseRepository(envValue);
|
|
204
|
+
if (current === null) {
|
|
205
|
+
return { status: "existing", repository: null, source: "env_file", path: null,
|
|
206
|
+
reason: "env_value_unrecognized" };
|
|
207
|
+
}
|
|
208
|
+
const mismatch = expected !== null && !same(current, expected);
|
|
209
|
+
return { status: mismatch ? "mismatch" : "existing", repository: current, source: "env_file",
|
|
210
|
+
path: null, reason: mismatch ? `${expectedSource}_differs` : null, ...(mismatch ? { expected } : {}) };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const found = nearestConfig(root);
|
|
214
|
+
if (found !== null) {
|
|
215
|
+
const raw = found.invalid ? null : found.doc.repository;
|
|
216
|
+
const usable = !found.invalid && (found.doc.version === undefined || found.doc.version === CONFIG_VERSION) &&
|
|
217
|
+
typeof raw === "string" && raw.includes("/");
|
|
218
|
+
if (!usable) {
|
|
219
|
+
// Never rewrite an existing config; the person fixes it.
|
|
220
|
+
return { status: "invalid_config", repository: null, source: "config", path: found.rel,
|
|
221
|
+
reason: "config_unusable", ...(expected !== null ? { expected } : {}) };
|
|
222
|
+
}
|
|
223
|
+
// The SDK accepts any value containing "/"; report it only as owner/name.
|
|
224
|
+
const current = parseRepository(raw);
|
|
225
|
+
if (current === null) {
|
|
226
|
+
return { status: "existing", repository: null, source: "config", path: found.rel,
|
|
227
|
+
reason: "config_value_unrecognized" };
|
|
228
|
+
}
|
|
229
|
+
const mismatch = expected !== null && !same(current, expected);
|
|
230
|
+
return { status: mismatch ? "mismatch" : "existing", repository: current, source: "config", path: found.rel,
|
|
231
|
+
reason: mismatch ? `${expectedSource}_differs` : null, ...(mismatch ? { expected } : {}) };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (expected === null) {
|
|
235
|
+
return { status: "not_inferred", repository: null, source: null, path: null, reason: inferred.reason };
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
writeNewConfig(root, expected);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
// Another setup created it first: report what is there now.
|
|
241
|
+
if (error.code === "EEXIST" && !retried) return ensureRepositoryIdentity(root, { requested, envValue, retried: true });
|
|
242
|
+
return { status: "write_failed", repository: null, source: expectedSource, path: CONFIG_PATH,
|
|
243
|
+
reason: "config_not_written", expected };
|
|
244
|
+
}
|
|
245
|
+
return { status: "written", repository: expected, source: expectedSource, path: CONFIG_PATH, reason: null };
|
|
246
|
+
}
|
package/src/setup.js
CHANGED
|
@@ -2,13 +2,14 @@ import { detectRemoteSession, openBrowser } from "./auth-browser.js";
|
|
|
2
2
|
import { readBinding, resolveProject } from "./auth-binding.js";
|
|
3
3
|
import { startCallbackListener } from "./auth-callback.js";
|
|
4
4
|
import { runLogin } from "./auth-login.js";
|
|
5
|
-
import {
|
|
5
|
+
import { clientFor, endpointsFor, newPkce, offeredClientId } from "./auth-oauth.js";
|
|
6
6
|
import { Stop } from "./auth-store.js";
|
|
7
7
|
import { verifiedSession } from "./auth-session.js";
|
|
8
8
|
import { AUTH_HTTP_TIMEOUT_MS } from "./constants.js";
|
|
9
9
|
import { preflightEnv, currentEnvValues, commitEnv, isAppToken } from "./setup-env.js";
|
|
10
10
|
import { newSetupState, readSetupState, setupReceipt, withSetupState, writeSetupState } from "./setup-state.js";
|
|
11
11
|
import { runSkill } from "./skill.js";
|
|
12
|
+
import { ensureRepositoryIdentity, envRepository } from "./repository-identity.js";
|
|
12
13
|
import { parseJsonObject } from "./http.js";
|
|
13
14
|
import { deadline, failureOf, send, trapSignals } from "./transport.js";
|
|
14
15
|
|
|
@@ -23,8 +24,15 @@ const PURPOSE = "ingest-bootstrap-v1";
|
|
|
23
24
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
24
25
|
const RECEIPT = /^mgbs_[A-Za-z0-9_-]{32,256}$/;
|
|
25
26
|
const KEY = (value) => typeof value === "string" && UUID.test(value);
|
|
27
|
+
// --json cannot show a person the --no-browser approval URL. The refusal names
|
|
28
|
+
// a fixed, secret-free next step instead of only a reason token.
|
|
29
|
+
const RUN_IN_TERMINAL = Object.freeze({
|
|
30
|
+
kind: "run_in_terminal",
|
|
31
|
+
message: "Run the same setup command in a terminal without --json to see the approval URL.",
|
|
32
|
+
});
|
|
26
33
|
const fail = (outcome, reason, status = "not_ready", receipt = null) => ({ outcome, reason,
|
|
27
|
-
data: { status, application_traffic_verified: false, receipt
|
|
34
|
+
data: { status, application_traffic_verified: false, receipt,
|
|
35
|
+
next_action: reason === "no_browser_requires_terminal" ? RUN_IN_TERMINAL : null } });
|
|
28
36
|
|
|
29
37
|
// This command never receives an ingest key from argv, stdin, an environment
|
|
30
38
|
// variable or a Metadata OAuth grant. Only the purpose-bound redemption
|
|
@@ -159,9 +167,10 @@ export async function runSetup(options, progress = () => {}) {
|
|
|
159
167
|
let clientId;
|
|
160
168
|
let pkce;
|
|
161
169
|
try {
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
170
|
+
const client = await clientFor(endpointsFor(origin), setup.offeredClientId, listener.redirectUri,
|
|
171
|
+
deadline(AUTH_HTTP_TIMEOUT_MS, trap.signal).signal);
|
|
172
|
+
if (!client.ok) return stop(client.outcome, client.reason);
|
|
173
|
+
clientId = client.clientId;
|
|
165
174
|
pkce = newPkce();
|
|
166
175
|
const pending = listener.wait({ state: pkce.state, issuer: `${origin}/v1/oauth`, requireIss: false,
|
|
167
176
|
timeoutMs: options.timeoutMs, cancel: trap.signal });
|
|
@@ -225,7 +234,8 @@ function finishSkill(root, options, envStatus) {
|
|
|
225
234
|
previous = readSetupState(root);
|
|
226
235
|
}
|
|
227
236
|
return { outcome: "ok", reason: null, data: { status: "ready_for_instrumentation",
|
|
228
|
-
application_traffic_verified: false, env: envStatus, skill: "skipped", receipt: setupReceipt(previous.value)
|
|
237
|
+
application_traffic_verified: false, env: envStatus, skill: "skipped", receipt: setupReceipt(previous.value),
|
|
238
|
+
repository: repositoryIdentity(root, options), next_action: null } };
|
|
229
239
|
}
|
|
230
240
|
let skill = runSkill({ action: "install", client: options.client, runtime: "local", project: root });
|
|
231
241
|
if (skill.reason === "update_required") {
|
|
@@ -252,7 +262,18 @@ function finishSkill(root, options, envStatus) {
|
|
|
252
262
|
}
|
|
253
263
|
return { outcome: "ok", reason: null, data: { status: "ready_for_instrumentation",
|
|
254
264
|
application_traffic_verified: false, env: envStatus, skill: skill.data.status,
|
|
255
|
-
receipt: setupReceipt(previous.value) } };
|
|
265
|
+
receipt: setupReceipt(previous.value), repository: repositoryIdentity(root, options), next_action: null } };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Records the repository identity the SDKs read, after the credential is
|
|
269
|
+
// ready. It never fails setup and never changes an existing identity; the
|
|
270
|
+
// returned field says what was found, written or left for the person.
|
|
271
|
+
function repositoryIdentity(root, options) {
|
|
272
|
+
return ensureRepositoryIdentity(root, {
|
|
273
|
+
requested: options.repository ?? null,
|
|
274
|
+
skip: options.skipRepository === true,
|
|
275
|
+
envValue: envRepository(root, options.envFile ?? ".env"),
|
|
276
|
+
});
|
|
256
277
|
}
|
|
257
278
|
|
|
258
279
|
async function discoverSetup(origin, profile, cancel) {
|
|
@@ -273,7 +294,9 @@ async function discoverSetup(origin, profile, cancel) {
|
|
|
273
294
|
!Array.isArray(doc.code_challenge_methods_supported) || !doc.code_challenge_methods_supported.includes("S256")) {
|
|
274
295
|
return { ok: false, outcome: "unsupported", reason: "setup_contract_mismatch" };
|
|
275
296
|
}
|
|
276
|
-
|
|
297
|
+
const offered = offeredClientId(doc);
|
|
298
|
+
if (offered === undefined) return { ok: false, outcome: "unsupported", reason: "setup_contract_mismatch" };
|
|
299
|
+
return { ok: true, offeredClientId: offered };
|
|
277
300
|
}
|
|
278
301
|
|
|
279
302
|
async function redeem(origin, form, profile, cancel) {
|
package/src/skill-bundle.js
CHANGED
|
@@ -5,7 +5,7 @@ import { readFileSync } from "node:fs";
|
|
|
5
5
|
// public skill at the manifest's source_url. The source has no version of its
|
|
6
6
|
// own, so the revision is derived from the content hash. The hash is pinned
|
|
7
7
|
// here as well as in the manifest, so editing either file alone is detected.
|
|
8
|
-
const PINNED_SHA256 = "
|
|
8
|
+
const PINNED_SHA256 = "8b81eb27b6159c4447a5c300409c4c64917238b7797f5ee37a35248cba50fcae";
|
|
9
9
|
|
|
10
10
|
const ASSET_DIR = new URL("../assets/skill/", import.meta.url);
|
|
11
11
|
const MANIFEST_KEYS = ["file", "manifest_version", "name", "revision", "sha256", "size", "source_url"];
|