metergraph-cli 0.2.0-preview.1 → 0.2.0-preview.3
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 +95 -15
- package/package.json +1 -1
- package/src/args.js +13 -3
- package/src/auth-login.js +19 -15
- package/src/auth-oauth.js +26 -0
- package/src/cli.js +19 -1
- package/src/output.js +7 -4
- package/src/repository-identity.js +246 -0
- package/src/setup.js +34 -9
- package/src/trace-open.js +7 -2
- package/src/verify.js +9 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# metergraph-cli
|
|
2
2
|
|
|
3
|
-
The Metergraph command line tool. Version `0.2.0-preview.
|
|
3
|
+
The Metergraph command line tool. Version `0.2.0-preview.3` is published on the
|
|
4
4
|
`next` npm tag. The `latest` tag remains on `0.1.0`.
|
|
5
5
|
|
|
6
6
|
Install the released preview channel with npm or run it directly:
|
|
@@ -11,7 +11,7 @@ npx --yes metergraph-cli@next doctor --json
|
|
|
11
11
|
npm install -g metergraph-cli@next
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
The installed command is `metergraph`. Pin `metergraph-cli@0.2.0-preview.
|
|
14
|
+
The installed command is `metergraph`. Pin `metergraph-cli@0.2.0-preview.3`
|
|
15
15
|
when you need this exact preview rather than whichever version `next` names later.
|
|
16
16
|
|
|
17
17
|
**Availability:**
|
|
@@ -24,6 +24,18 @@ when you need this exact preview rather than whichever version `next` names late
|
|
|
24
24
|
`METERGRAPH_INGEST_URL` and repairs the full ingest endpoint that
|
|
25
25
|
`0.2.0-preview.0` wrote, after checking the saved key. It also keeps validated,
|
|
26
26
|
workspace-bound trace links in Metadata reads.
|
|
27
|
+
- `metergraph-cli@0.2.0-preview.2` makes `verify` exit 11 `verification_failed`
|
|
28
|
+
when its deadline expires after the service reported the trace as pending, lets
|
|
29
|
+
`login` and `setup` reruns that need no approval succeed with `--json
|
|
30
|
+
--no-browser`, and reports why `verify --open` did not launch a browser.
|
|
31
|
+
- `metergraph-cli@0.2.0-preview.3` makes a `verify` deadline that expires after
|
|
32
|
+
the service reported the trace as pending return `trace_not_found_within_bounds`,
|
|
33
|
+
the same result as running out of attempts, instead of `verification_timeout`.
|
|
34
|
+
`setup --json --no-browser` now returns a `run_in_terminal` next action when
|
|
35
|
+
approval is needed.
|
|
36
|
+
`login` and `setup` use a pre-registered CLI client when the service names one
|
|
37
|
+
in `metergraph_cli_client_id`, instead of registering a new client, and still
|
|
38
|
+
register one on services that do not.
|
|
27
39
|
- Sign in needs a Metergraph service that offers Metadata-only CLI grants and grant
|
|
28
40
|
revocation. A service without them is reported as unsupported, and the CLI never
|
|
29
41
|
falls back to broader access.
|
|
@@ -100,7 +112,19 @@ sends a test event.
|
|
|
100
112
|
`--open` launches only a server-provided link carrying the exact trace and the
|
|
101
113
|
verified workspace ID. The dashboard must check that ID against its signed-in
|
|
102
114
|
workspace before displaying traces. Older links without a workspace remain a
|
|
103
|
-
manual handoff; a conflicting workspace or unsafe link is refused.
|
|
115
|
+
manual handoff; a conflicting workspace or unsafe link is refused. The result's
|
|
116
|
+
`browser` field is `launcher_started`, `launcher_unavailable`, `not_requested`
|
|
117
|
+
(no `--open`), or `suppressed_by_json` / `suppressed_by_no_browser` when `--open`
|
|
118
|
+
was given but that flag returned the link instead of launching it.
|
|
119
|
+
|
|
120
|
+
If `--timeout-ms` expires after the service has answered at least once with a
|
|
121
|
+
pending result, `verify` exits 11 `verification_failed` with reason
|
|
122
|
+
`trace_not_found_within_bounds`, the same result as running out of
|
|
123
|
+
`--max-attempts`: the origin is reachable and the trace is not visible yet. Exit 4
|
|
124
|
+
`connection_failed` is kept for transport failures. Its reason is
|
|
125
|
+
`verification_timeout` when no valid answer arrived before the deadline. A deadline
|
|
126
|
+
while `--open` launches a trace that was already found exits 11 with
|
|
127
|
+
`verification_timeout`.
|
|
104
128
|
|
|
105
129
|
### doctor
|
|
106
130
|
|
|
@@ -225,7 +249,7 @@ metergraph logout
|
|
|
225
249
|
| `--config-dir DIR` | see below | Private per-user directory for the saved grant. |
|
|
226
250
|
| `--timeout-ms N` | `300000` | How long to wait for the browser, 1000 to 900000. |
|
|
227
251
|
| `--signup` | off | Start at the hosted sign up page, which returns to the same authorization request. Managed service only; other profiles exit 6. |
|
|
228
|
-
| `--no-browser` | off | Print the authorization URL on stderr for you to open on this machine, then wait.
|
|
252
|
+
| `--no-browser` | off | Print the authorization URL on stderr for you to open on this machine, then wait. With `--json`, a rerun that needs no approval succeeds; one that needs approval exits 6 `no_browser_requires_terminal` with a `run_in_terminal` next action. |
|
|
229
253
|
| `--reconnect` | off | Allow replacing a binding to a different origin or workspace. |
|
|
230
254
|
| `--json` | off | Print exactly one JSON line on stdout and nothing on stderr. |
|
|
231
255
|
|
|
@@ -240,10 +264,14 @@ What `login` does, in order:
|
|
|
240
264
|
3. Runs the same checks as `doctor`, then reads the service's OAuth metadata from the
|
|
241
265
|
same origin. Every endpoint must be a fixed path on that origin, and the service must
|
|
242
266
|
offer `agent:metadata`, PKCE with `S256`, public clients and revocation.
|
|
243
|
-
4.
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
267
|
+
4. Chooses the OAuth client for a loopback redirect, `http://127.0.0.1:PORT/callback`
|
|
268
|
+
on an ephemeral port. When the service's OAuth metadata names a pre-registered
|
|
269
|
+
Metergraph CLI client in `metergraph_cli_client_id`, the CLI uses that client and
|
|
270
|
+
registers nothing; the service accepts the loopback redirect on any port (RFC 8252
|
|
271
|
+
section 7.3). Otherwise it registers a new public client named `Metergraph CLI` for
|
|
272
|
+
that one redirect, as servers without the field expect. A malformed value stops
|
|
273
|
+
sign in. The CLI then creates a random state and PKCE verifier, and arms the callback
|
|
274
|
+
listener and its timeout before the browser opens.
|
|
247
275
|
5. Opens the authorization URL with the operating system's launcher (no shell). The
|
|
248
276
|
listener accepts one `GET` with the exact host, path and state. Other requests get a
|
|
249
277
|
fixed page and do not end the wait.
|
|
@@ -255,7 +283,9 @@ What `login` does, in order:
|
|
|
255
283
|
the deployment profile must match step 3, the access scopes must be exactly
|
|
256
284
|
`agent:metadata`, and content, evidence and replay capabilities must be unavailable.
|
|
257
285
|
Nothing else is read.
|
|
258
|
-
8. Saves the grant in the config directory and writes `.metergraph/project.json`.
|
|
286
|
+
8. Saves the grant in the config directory and writes `.metergraph/project.json`. The
|
|
287
|
+
saved grant keeps the client ID it was issued to, so a grant from a registered client
|
|
288
|
+
still refreshes and revokes after the service starts offering a pre-registered one.
|
|
259
289
|
|
|
260
290
|
Once the token response has been validated and holds a usable refresh token, a grant
|
|
261
291
|
the CLI decides not to keep (a workspace other than the one expected or bound, failed
|
|
@@ -328,7 +358,9 @@ If the project has no usable Metadata sign in, `setup` opens the deployment's
|
|
|
328
358
|
browser sign in and workspace choice. `--signup` starts at hosted sign up;
|
|
329
359
|
`--workspace UUID` requires that exact workspace. An existing binding to a
|
|
330
360
|
different origin or workspace requires explicit `--reconnect`. The selected
|
|
331
|
-
deployment must advertise `metergraph.cli-setup/v1` on its own origin.
|
|
361
|
+
deployment must advertise `metergraph.cli-setup/v1` on its own origin. Its setup
|
|
362
|
+
metadata may name a pre-registered CLI client in `metergraph_cli_client_id`, which
|
|
363
|
+
setup then uses for ingest approval instead of registering a new client. Setup
|
|
332
364
|
refuses reconnecting an existing ingest family to another workspace; its
|
|
333
365
|
original workspace must be restored before that family can be reused. Setup
|
|
334
366
|
checks the env file and Git state, then opens the deployment's consent page. An
|
|
@@ -369,12 +401,60 @@ The JSON result includes a secret-free `receipt` with the origin, workspace ID,
|
|
|
369
401
|
deployment profile, selected client, and completed and pending steps. A skill
|
|
370
402
|
conflict leaves the delivered key in place and reports `credential_ready_skill_pending`;
|
|
371
403
|
resolve the skill file conflict and rerun setup without another approval.
|
|
404
|
+
With `--no-browser`, setup prints the approval URL on stderr instead of opening a
|
|
405
|
+
browser. Under `--json` it cannot show that URL, so a rerun that needs no approval
|
|
406
|
+
(a working saved key) still succeeds, and one that needs approval exits 6
|
|
407
|
+
`no_browser_requires_terminal` before any approval request or env write, with
|
|
408
|
+
`data.next_action` set to `{"kind": "run_in_terminal", "message": "..."}`. The
|
|
409
|
+
message is fixed text and holds no URL or credential. Non-hosted operator
|
|
410
|
+
handoffs carry their own deployment `next_action`; every other setup result has
|
|
411
|
+
`next_action: null`.
|
|
372
412
|
Success means the key was delivered and the project is ready to instrument.
|
|
373
413
|
It does **not** mean application traffic has arrived. Run your application and
|
|
374
414
|
verify one exact trace afterward. The hosted service advertises the setup
|
|
375
415
|
contract, but each non-hosted deployment must be checked at its own origin.
|
|
376
416
|
Local protocol tests do not prove browser approval or real application traffic.
|
|
377
417
|
|
|
418
|
+
#### Repository identity
|
|
419
|
+
|
|
420
|
+
After the key is ready, setup records which repository the project belongs to,
|
|
421
|
+
so traces from several repositories in one workspace stay apart. The SDKs read
|
|
422
|
+
`METERGRAPH_REPOSITORY`, then the nearest `.metergraph/config.json`:
|
|
423
|
+
|
|
424
|
+
```json
|
|
425
|
+
{
|
|
426
|
+
"version": 2,
|
|
427
|
+
"repository": "example-org/example-app"
|
|
428
|
+
}
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
Setup takes `owner/name` from `--repository`, or else from the git `origin`
|
|
432
|
+
remote (or the only remote). It writes `.metergraph/config.json` in the project
|
|
433
|
+
directory only when no identity exists yet. The file holds no secret; review
|
|
434
|
+
and commit it. An identity already set in `METERGRAPH_REPOSITORY` in the env
|
|
435
|
+
file, or in a `.metergraph/config.json` in the project or a parent directory, is
|
|
436
|
+
kept. If it differs from the remote or `--repository`, setup reports a mismatch
|
|
437
|
+
and changes nothing. Setup never rewrites a config file the SDKs cannot use.
|
|
438
|
+
Remotes with nested groups are not guessed; pass `--repository OWNER/NAME`.
|
|
439
|
+
`--no-repository` records nothing.
|
|
440
|
+
|
|
441
|
+
The JSON result reports the outcome in `data.repository`: `status` is
|
|
442
|
+
`written`, `existing`, `mismatch`, `invalid_config`, `not_inferred`,
|
|
443
|
+
`write_failed` or `skipped`, with the `repository`, its `source`
|
|
444
|
+
(`git_remote`, `flag`, `config` or `env_file`), the config `path`, and, for a
|
|
445
|
+
mismatch, the `expected` value. Only `owner/name` is reported, never a remote
|
|
446
|
+
URL. Recording an identity never fails setup.
|
|
447
|
+
|
|
448
|
+
With an identity configured, the SDKs exchange the ingest key for a
|
|
449
|
+
repository-scoped session before sending traces. Without one they keep the
|
|
450
|
+
older ingestion path and log that the identity is not configured; capture
|
|
451
|
+
continues either way.
|
|
452
|
+
|
|
453
|
+
Several repositories can send to one workspace. Run `setup` once in each
|
|
454
|
+
repository, so each has its own project binding and private `.env`; pass the
|
|
455
|
+
same `--workspace UUID` each time to pin the workspace; and give each
|
|
456
|
+
repository its own identity.
|
|
457
|
+
|
|
378
458
|
### Read commands
|
|
379
459
|
|
|
380
460
|
The read commands use the grant `login` saved for this project. They never open a
|
|
@@ -525,14 +605,14 @@ added in `0.2.0-preview.0` and are unavailable in `0.1.0`.
|
|
|
525
605
|
| 1 | `internal_error` | Unexpected failure inside the CLI. |
|
|
526
606
|
| 2 | `invalid_input` | Unknown command or argument, or an invalid option value. No request was made. |
|
|
527
607
|
| 3 | `authentication_required` | Service is reachable, healthy and supported, and requires authentication. No workspace is connected. |
|
|
528
|
-
| 4 | `connection_failed` | The origin could not be reached, the connection failed, or the probe timed out. |
|
|
608
|
+
| 4 | `connection_failed` | The origin could not be reached, the connection failed, or the probe timed out. For `verify`, the deadline expired before any valid answer arrived. A trace that is still pending is exit 11, not 4. |
|
|
529
609
|
| 5 | `unhealthy` | The service answered but reported that it is not healthy, or answered with a server error. |
|
|
530
|
-
| 6 | `unsupported` | The service answered with a response, deployment profile or status this CLI does not support, or the skill client or runtime cannot use project skill files, or sign in cannot run in this environment. Nothing was written. |
|
|
610
|
+
| 6 | `unsupported` | The service answered with a response, deployment profile or status this CLI does not support, or the skill client or runtime cannot use project skill files, or sign in cannot run in this environment, or `--json --no-browser` needs a browser approval it cannot show (`no_browser_requires_terminal`). Nothing was written. |
|
|
531
611
|
| 7 | `redirect_rejected` | The service answered with a redirect. Redirects are never followed. |
|
|
532
612
|
| 8 | `conflict` | The skill target is not owned by this CLI, was modified, is unsafe, is locked or needs an explicit update, or the project is bound to a different origin or workspace. Nothing was changed. |
|
|
533
613
|
| 9 | `filesystem_error` | Project or credential files could not be read or written. Partial changes were rolled back unless the message says otherwise. |
|
|
534
614
|
| 10 | `authorization_failed` | Browser authorization did not finish: it was denied, cancelled, timed out or returned an invalid callback. Nothing was saved. |
|
|
535
|
-
| 11 | `verification_failed` | The service issued a grant that does not match the requested origin, workspace, client, resource or Metadata scope. Nothing was saved. |
|
|
615
|
+
| 11 | `verification_failed` | The service issued a grant that does not match the requested origin, workspace, client, resource or Metadata scope. Nothing was saved. For `verify`, the exact trace was not confirmed: for example `trace_not_found_within_bounds` when the service kept answering but the trace was not visible before attempts or the deadline ran out. |
|
|
536
616
|
| 12 | `login_required` | No usable sign in for this project: none was saved, it expired, was revoked, lost access or could not be refreshed safely. Run login again. |
|
|
537
617
|
| 13 | `revocation_unconfirmed` | Local credentials were removed, but the service did not confirm that the grant was revoked. |
|
|
538
618
|
| 14 | `capability_unavailable` | The service does not make this read available to the project's Metadata grant. No data was read. |
|
|
@@ -763,7 +843,7 @@ To try a packed artifact without publishing:
|
|
|
763
843
|
|
|
764
844
|
```sh
|
|
765
845
|
npm pack --pack-destination "$(mktemp -d)"
|
|
766
|
-
npx --yes --package=/path/to/metergraph-cli-0.2.0-preview.
|
|
846
|
+
npx --yes --package=/path/to/metergraph-cli-0.2.0-preview.3.tgz -- metergraph --version
|
|
767
847
|
```
|
|
768
848
|
|
|
769
849
|
Do not commit tarballs or other generated files.
|
|
@@ -772,7 +852,7 @@ Do not commit tarballs or other generated files.
|
|
|
772
852
|
|
|
773
853
|
The source of truth is the public repository
|
|
774
854
|
[github.com/metergraph/cli](https://github.com/metergraph/cli), licensed Apache-2.0.
|
|
775
|
-
`0.2.0-preview.
|
|
855
|
+
`0.2.0-preview.3` is published on the `next` npm tag; `latest` remains on
|
|
776
856
|
`0.1.0`. The hosted service supports sign in, Metadata reads and ingest bootstrap.
|
|
777
857
|
Subsequent releases must pass the checks below before publication.
|
|
778
858
|
|
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.3",
|
|
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";
|
|
@@ -58,12 +58,13 @@ export async function runLogin(options, progress) {
|
|
|
58
58
|
if (options.runtime !== "local") return end("unsupported", "runtime_not_supported", GUIDE);
|
|
59
59
|
const remote = detectRemoteSession();
|
|
60
60
|
if (remote !== null) return end("unsupported", remote, GUIDE);
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
61
|
+
// --json cannot show a person the --no-browser URL. A rerun with a saved,
|
|
62
|
+
// working grant needs no URL, so only refuse once approval is required.
|
|
63
|
+
const noBrowserHandoff = () => end("unsupported", "no_browser_requires_terminal", {
|
|
64
|
+
kind: "run_in_terminal",
|
|
65
|
+
message: "Run the same login command in a terminal without --json to see the sign in URL.",
|
|
66
|
+
});
|
|
67
|
+
const urlUnshowable = options.json && options.noBrowser;
|
|
67
68
|
|
|
68
69
|
const trap = trapSignals();
|
|
69
70
|
try {
|
|
@@ -71,6 +72,7 @@ export async function runLogin(options, progress) {
|
|
|
71
72
|
const configDir = resolveConfigDir(options.configDir);
|
|
72
73
|
const existing = readBinding(root);
|
|
73
74
|
const bound = existing?.binding ?? null;
|
|
75
|
+
if (urlUnshowable && (bound === null || options.reconnect)) return noBrowserHandoff();
|
|
74
76
|
// Setup pins a deployment model before sign in. A stale binding for a
|
|
75
77
|
// different profile must never be silently reused or reconnected.
|
|
76
78
|
if (options.expectedProfile && bound !== null && !options.reconnect &&
|
|
@@ -100,6 +102,7 @@ export async function runLogin(options, progress) {
|
|
|
100
102
|
}
|
|
101
103
|
if (session.outcome !== "login_required") return end(session.outcome, session.reason);
|
|
102
104
|
}
|
|
105
|
+
if (urlUnshowable) return noBrowserHandoff();
|
|
103
106
|
|
|
104
107
|
const flow = await authorize(options, ctx, trap.signal, progress);
|
|
105
108
|
if (!flow.ok) return end(flow.outcome, flow.reason, flow.next ?? null);
|
|
@@ -161,10 +164,11 @@ export async function runLogin(options, progress) {
|
|
|
161
164
|
}
|
|
162
165
|
}
|
|
163
166
|
|
|
164
|
-
// Preflight, discovery,
|
|
165
|
-
// exchange and server-side verification. Nothing is
|
|
166
|
-
// grant that is not accepted is sent for revocation
|
|
167
|
-
// 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.
|
|
168
172
|
async function authorize(options, ctx, cancel, progress) {
|
|
169
173
|
const preflight = await runDoctor({ origin: ctx.origin, timeoutMs: AUTH_HTTP_TIMEOUT_MS });
|
|
170
174
|
if (cancel.aborted) return fail("authorization_failed", "cancelled");
|
|
@@ -178,7 +182,7 @@ async function authorize(options, ctx, cancel, progress) {
|
|
|
178
182
|
let limit = deadline(AUTH_HTTP_TIMEOUT_MS, cancel);
|
|
179
183
|
const discovered = await discover(ctx.origin, limit.signal);
|
|
180
184
|
if (!discovered.ok) return httpFailure(discovered, cancel);
|
|
181
|
-
const { endpoints, requireIss } = discovered;
|
|
185
|
+
const { endpoints, requireIss, offeredClientId } = discovered;
|
|
182
186
|
|
|
183
187
|
const listener = await startCallbackListener();
|
|
184
188
|
let callback;
|
|
@@ -186,9 +190,9 @@ async function authorize(options, ctx, cancel, progress) {
|
|
|
186
190
|
let verifier;
|
|
187
191
|
try {
|
|
188
192
|
limit = deadline(AUTH_HTTP_TIMEOUT_MS, cancel);
|
|
189
|
-
const
|
|
190
|
-
if (!
|
|
191
|
-
clientId =
|
|
193
|
+
const client = await clientFor(endpoints, offeredClientId, listener.redirectUri, limit.signal);
|
|
194
|
+
if (!client.ok) return httpFailure(client, cancel);
|
|
195
|
+
clientId = client.clientId;
|
|
192
196
|
|
|
193
197
|
const pkce = newPkce();
|
|
194
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
|
},
|
|
@@ -599,8 +602,8 @@ const AUTH_MESSAGES = {
|
|
|
599
602
|
ci_environment:
|
|
600
603
|
"This is a CI environment, where no person can approve sign in in a browser. Nothing was written.",
|
|
601
604
|
no_browser_requires_terminal:
|
|
602
|
-
"--no-browser prints
|
|
603
|
-
"Run
|
|
605
|
+
"Approval is needed, and --no-browser prints its URL for a person to open, which --json cannot do. " +
|
|
606
|
+
"Run the same command without --json in a terminal. Nothing was done.",
|
|
604
607
|
bound_to_other_origin:
|
|
605
608
|
"This project is bound to a different origin. Nothing was changed. Use --reconnect to switch it.",
|
|
606
609
|
bound_to_other_workspace:
|
|
@@ -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,15 +24,21 @@ 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
|
|
31
39
|
// response can supply it. Every returned string is fixed and non-secret.
|
|
32
40
|
export async function runSetup(options, progress = () => {}) {
|
|
33
41
|
if (options.runtime !== "local" || detectRemoteSession() !== null) return fail("unsupported", "run_on_local_machine");
|
|
34
|
-
if (options.json && options.noBrowser) return fail("unsupported", "no_browser_requires_terminal");
|
|
35
42
|
const trap = trapSignals();
|
|
36
43
|
let receipt = null;
|
|
37
44
|
let loginVerified = false;
|
|
@@ -138,6 +145,9 @@ export async function runSetup(options, progress = () => {}) {
|
|
|
138
145
|
return stop("conflict", "repair_requires_saved_key");
|
|
139
146
|
}
|
|
140
147
|
|
|
148
|
+
// A verified rerun returned above without approval. From here a person
|
|
149
|
+
// must open an approval URL, which --json cannot show with --no-browser.
|
|
150
|
+
if (options.json && options.noBrowser) return stop("unsupported", "no_browser_requires_terminal");
|
|
141
151
|
const setup = await discoverSetup(origin, profile, trap.signal);
|
|
142
152
|
if (!setup.ok) return stop(setup.outcome, setup.reason);
|
|
143
153
|
|
|
@@ -157,9 +167,10 @@ export async function runSetup(options, progress = () => {}) {
|
|
|
157
167
|
let clientId;
|
|
158
168
|
let pkce;
|
|
159
169
|
try {
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
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;
|
|
163
174
|
pkce = newPkce();
|
|
164
175
|
const pending = listener.wait({ state: pkce.state, issuer: `${origin}/v1/oauth`, requireIss: false,
|
|
165
176
|
timeoutMs: options.timeoutMs, cancel: trap.signal });
|
|
@@ -223,7 +234,8 @@ function finishSkill(root, options, envStatus) {
|
|
|
223
234
|
previous = readSetupState(root);
|
|
224
235
|
}
|
|
225
236
|
return { outcome: "ok", reason: null, data: { status: "ready_for_instrumentation",
|
|
226
|
-
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 } };
|
|
227
239
|
}
|
|
228
240
|
let skill = runSkill({ action: "install", client: options.client, runtime: "local", project: root });
|
|
229
241
|
if (skill.reason === "update_required") {
|
|
@@ -250,7 +262,18 @@ function finishSkill(root, options, envStatus) {
|
|
|
250
262
|
}
|
|
251
263
|
return { outcome: "ok", reason: null, data: { status: "ready_for_instrumentation",
|
|
252
264
|
application_traffic_verified: false, env: envStatus, skill: skill.data.status,
|
|
253
|
-
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
|
+
});
|
|
254
277
|
}
|
|
255
278
|
|
|
256
279
|
async function discoverSetup(origin, profile, cancel) {
|
|
@@ -271,7 +294,9 @@ async function discoverSetup(origin, profile, cancel) {
|
|
|
271
294
|
!Array.isArray(doc.code_challenge_methods_supported) || !doc.code_challenge_methods_supported.includes("S256")) {
|
|
272
295
|
return { ok: false, outcome: "unsupported", reason: "setup_contract_mismatch" };
|
|
273
296
|
}
|
|
274
|
-
|
|
297
|
+
const offered = offeredClientId(doc);
|
|
298
|
+
if (offered === undefined) return { ok: false, outcome: "unsupported", reason: "setup_contract_mismatch" };
|
|
299
|
+
return { ok: true, offeredClientId: offered };
|
|
275
300
|
}
|
|
276
301
|
|
|
277
302
|
async function redeem(origin, form, profile, cancel) {
|
package/src/trace-open.js
CHANGED
|
@@ -4,7 +4,8 @@ import { normalizeUuid } from "./auth-oauth.js";
|
|
|
4
4
|
import { parseOrigin } from "./origin.js";
|
|
5
5
|
|
|
6
6
|
// Operates only on an already verified, provenance-bound receipt. JSON and
|
|
7
|
-
// no-browser mode return its URL without starting a launcher
|
|
7
|
+
// no-browser mode return its URL without starting a launcher, and say which
|
|
8
|
+
// one suppressed an --open request. "opened" means
|
|
8
9
|
// the OS launcher started; it does not assert a browser rendered the page.
|
|
9
10
|
export async function openTrace(receipt, options = {}, launch = openBrowser) {
|
|
10
11
|
if (receipt?.readiness?.metadata_available !== true || receipt?.readiness?.processed !== true ||
|
|
@@ -20,7 +21,11 @@ export async function openTrace(receipt, options = {}, launch = openBrowser) {
|
|
|
20
21
|
if (link.workspaceBound !== true || receipt.link_workspace_bound !== true || receipt.link_status !== "available") {
|
|
21
22
|
return { outcome: "unsupported", reason: "link_workspace_binding_unavailable", data: { ...receipt, browser: "not_requested" } };
|
|
22
23
|
}
|
|
23
|
-
if (
|
|
24
|
+
if (!options.open) return { outcome: "ok", reason: null, data: { ...receipt, browser: "not_requested" } };
|
|
25
|
+
if (options.json || options.noBrowser) {
|
|
26
|
+
const browser = options.json ? "suppressed_by_json" : "suppressed_by_no_browser";
|
|
27
|
+
return { outcome: "ok", reason: null, data: { ...receipt, browser } };
|
|
28
|
+
}
|
|
24
29
|
if (options.signal?.aborted) return { outcome: "cancelled", reason: "cancelled", data: receipt };
|
|
25
30
|
const launched = await launchWithinSignal(link.value, launch, options.signal);
|
|
26
31
|
if (options.signal?.aborted) return { outcome: "cancelled", reason: "cancelled", data: receipt };
|
package/src/verify.js
CHANGED
|
@@ -28,12 +28,19 @@ export async function runVerify(options, dependencies = {}) {
|
|
|
28
28
|
const limit = deadline(timeoutMs, cancel);
|
|
29
29
|
let known = [];
|
|
30
30
|
let receipt = null;
|
|
31
|
+
let found = false;
|
|
31
32
|
let attempts = 0;
|
|
32
33
|
const end = (outcome, reason, data = receipt) => {
|
|
33
34
|
if (holdsKnown(data, known)) return { outcome: "verification_failed", reason: "credential_in_metadata_response", data: null };
|
|
34
35
|
return { outcome, reason, data: data === null ? null : { ...data, attempts } };
|
|
35
36
|
};
|
|
36
|
-
|
|
37
|
+
// Once the origin has answered with a valid pending receipt, a deadline
|
|
38
|
+
// means the trace is not visible yet, exactly as when attempts run out.
|
|
39
|
+
// Only a deadline with no valid answer is reported as a connection failure.
|
|
40
|
+
// A deadline while opening an already found trace keeps the timeout reason.
|
|
41
|
+
const interrupted = () => !limit.timedOut() ? end("cancelled", "cancelled")
|
|
42
|
+
: receipt === null ? end("connection_failed", "verification_timeout")
|
|
43
|
+
: end("verification_failed", found ? "verification_timeout" : "trace_not_found_within_bounds");
|
|
37
44
|
try {
|
|
38
45
|
const sessionResult = await (dependencies.session ?? verifiedSession)({ project: options.project, configDir: options.configDir, cancel: limit.signal });
|
|
39
46
|
if (limit.signal.aborted) return interrupted();
|
|
@@ -55,6 +62,7 @@ export async function runVerify(options, dependencies = {}) {
|
|
|
55
62
|
receipt = checked.value;
|
|
56
63
|
if (holdsKnown(receipt, known)) return end("verification_failed", "credential_in_metadata_response", null);
|
|
57
64
|
if (checked.found) {
|
|
65
|
+
found = true;
|
|
58
66
|
if (options.open) {
|
|
59
67
|
const opened = await openTrace(receipt, { ...options, signal: limit.signal }, dependencies.launch);
|
|
60
68
|
if (limit.signal.aborted) return interrupted();
|