metergraph-cli 0.2.0-preview.5 → 0.2.0-preview.6
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 +23 -11
- package/assets/skill/SKILL.md +10 -1
- package/assets/skill/manifest.json +3 -3
- package/assets/skills/manifest.json +10 -4
- package/assets/skills/metergraph-local-bundle/SKILL.md +283 -0
- package/assets/skills/metergraph-onboarding/SKILL.md +3 -2
- package/bin/metergraph.js +4 -4
- package/package.json +1 -1
- package/src/approval-handoff.js +91 -0
- package/src/args.js +7 -8
- package/src/auth-login.js +27 -13
- package/src/cli.js +54 -6
- package/src/constants.js +5 -0
- package/src/deployment-route.js +1 -1
- package/src/output.js +22 -11
- package/src/setup-deployment.js +103 -7
- package/src/setup.js +33 -16
- package/src/skill-bundle.js +2 -2
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ npx --yes metergraph-cli@next traces --limit 20 # one page of trace metadata
|
|
|
52
52
|
```
|
|
53
53
|
|
|
54
54
|
To install it globally instead, run `npm install -g metergraph-cli@next`. The command is
|
|
55
|
-
`metergraph`. Pin an exact version, such as `metergraph-cli@0.2.0-preview.
|
|
55
|
+
`metergraph`. Pin an exact version, such as `metergraph-cli@0.2.0-preview.6`, when a
|
|
56
56
|
script must not change with the `next` tag.
|
|
57
57
|
|
|
58
58
|
## Requirements
|
|
@@ -80,11 +80,11 @@ The CLI is built to be run by Codex, Claude Code and Cursor as well as by people
|
|
|
80
80
|
network access:
|
|
81
81
|
|
|
82
82
|
```sh
|
|
83
|
-
npx --yes metergraph-cli@next skill install --client codex
|
|
83
|
+
npx --yes metergraph-cli@next skill install --client codex
|
|
84
84
|
```
|
|
85
85
|
|
|
86
86
|
Python users can run `pip install metergraph-skills` and
|
|
87
|
-
`metergraph-skills install --client codex
|
|
87
|
+
`metergraph-skills install --client codex` instead
|
|
88
88
|
([details](https://github.com/metergraph/cli/blob/main/python/metergraph-skills/README.md)).
|
|
89
89
|
Both write the same files and recognise each other's installs.
|
|
90
90
|
- **Model-swap skills.** `skills install` adds the workflow skills from
|
|
@@ -94,17 +94,26 @@ The CLI is built to be run by Codex, Claude Code and Cursor as well as by people
|
|
|
94
94
|
the `metergraph/skills` plugin marketplace instead.
|
|
95
95
|
|
|
96
96
|
```sh
|
|
97
|
-
npx --yes metergraph-cli@next skills install --client claude
|
|
97
|
+
npx --yes metergraph-cli@next skills install --client claude
|
|
98
98
|
```
|
|
99
99
|
|
|
100
|
+
`--runtime` defaults to `local`. Pass `--runtime cloud` when the client runs in a
|
|
101
|
+
cloud environment with a checkout of the project.
|
|
102
|
+
|
|
100
103
|
- **Machine output.** Every command takes `--json` and then prints exactly one JSON
|
|
101
104
|
line on stdout and nothing on stderr. All results share one envelope:
|
|
102
105
|
`schema_version`, `command`, `ok`, `outcome`, `exit_code`, `data` and `error`.
|
|
103
106
|
Failures carry a fixed `error.reason` and, where useful, a `data.next_action` that
|
|
104
107
|
tells the agent what to do next.
|
|
105
108
|
- **No hidden prompts.** No command reads stdin or asks a question. `--no-browser`
|
|
106
|
-
prints the sign in URL for a person to open instead of launching a browser
|
|
107
|
-
`--json`, a step that needs approval exits
|
|
109
|
+
prints the sign in URL for a person to open instead of launching a browser. With
|
|
110
|
+
`--json`, a step that needs approval exits within seconds with 18
|
|
111
|
+
`action_required` and `data.next_action` set to
|
|
112
|
+
`{"kind": "open_url", "url": "...", "timeout_seconds": N, "message": "..."}`, so an
|
|
113
|
+
agent can show the URL to the person. A background process waits for the approval
|
|
114
|
+
until `--timeout-ms`; afterwards, run the same command again to continue from the
|
|
115
|
+
saved state
|
|
116
|
+
([details](https://github.com/metergraph/cli/blob/main/docs/reference.md#approval-from-an-agent)).
|
|
108
117
|
- **MCP.** Workspace tools for agents are served by the hosted MCP endpoint, not by this
|
|
109
118
|
CLI. See the [MCP server guide](https://www.metergraph.dev/docs/guides/mcp-server/)
|
|
110
119
|
and the [agent access guide](https://www.metergraph.dev/docs/guides/agent-access/).
|
|
@@ -141,7 +150,7 @@ writes, and full JSON examples.
|
|
|
141
150
|
|
|
142
151
|
## Exit codes
|
|
143
152
|
|
|
144
|
-
Exit codes are stable. Changing one is a breaking change. Codes 10 to
|
|
153
|
+
Exit codes are stable. Changing one is a breaking change. Codes 10 to 18 are not in
|
|
145
154
|
`0.1.0`.
|
|
146
155
|
|
|
147
156
|
| Code | Outcome | Meaning |
|
|
@@ -152,7 +161,7 @@ Exit codes are stable. Changing one is a breaking change. Codes 10 to 17 are not
|
|
|
152
161
|
| 3 | `authentication_required` | Service is reachable, healthy and supported, and requires authentication. No workspace is connected. |
|
|
153
162
|
| 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. |
|
|
154
163
|
| 5 | `unhealthy` | The service answered but reported that it is not healthy, or answered with a server error. |
|
|
155
|
-
| 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
|
|
164
|
+
| 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. |
|
|
156
165
|
| 7 | `redirect_rejected` | The service answered with a redirect. Redirects are never followed. |
|
|
157
166
|
| 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. |
|
|
158
167
|
| 9 | `filesystem_error` | Project or credential files could not be read or written. Partial changes were rolled back unless the message says otherwise. |
|
|
@@ -164,6 +173,7 @@ Exit codes are stable. Changing one is a breaking change. Codes 10 to 17 are not
|
|
|
164
173
|
| 15 | `permission_denied` | The service refused this read for the signed in grant, for example for a missing scope or permission. |
|
|
165
174
|
| 16 | `rate_limited` | The service asked the CLI to slow down. Nothing was retried. Try again later. |
|
|
166
175
|
| 17 | `cancelled` | A read command was interrupted before it finished. Read commands never change workspace configuration or telemetry. |
|
|
176
|
+
| 18 | `action_required` | A person must act before the command can finish; `data.next_action` says what to do. `login` and `setup` with `--json --no-browser` exit with this as soon as an approval URL exists (`next_action.kind: "open_url"`), while a background process waits for the approval until `--timeout-ms`. After approval, run the same command again to continue. |
|
|
167
177
|
|
|
168
178
|
## Security and privacy
|
|
169
179
|
|
|
@@ -194,9 +204,11 @@ report. See [SECURITY.md](https://github.com/metergraph/cli/blob/main/SECURITY.m
|
|
|
194
204
|
|
|
195
205
|
`--url ORIGIN` selects the service; the default is `https://app.metergraph.dev`. It
|
|
196
206
|
accepts a bare `https://` origin, or `http://` on `localhost`, `127.0.0.1` or `[::1]`.
|
|
197
|
-
`setup --deployment`
|
|
198
|
-
`customer-local` bundle, customer-owned cloud (`byoc`)
|
|
199
|
-
(`oss`).
|
|
207
|
+
`setup --deployment` routes to the hosted service (`managed`), the commercial
|
|
208
|
+
`customer-local` bundle, customer-owned cloud (`byoc`) or the open source server
|
|
209
|
+
(`oss`). The hosted service and released customer-local bundle are the release
|
|
210
|
+
targets; `byoc` and `oss` are not yet verified against a live deployment. See
|
|
211
|
+
[self-hosting](https://www.metergraph.dev/docs/self-host/local/) and the
|
|
200
212
|
[setup reference](https://github.com/metergraph/cli/blob/main/docs/reference.md#setup).
|
|
201
213
|
|
|
202
214
|
## Links
|
package/assets/skill/SKILL.md
CHANGED
|
@@ -36,6 +36,15 @@ package does not support. A CLI login or health probe alone does not prove
|
|
|
36
36
|
application traffic. Verification needs an exact trace or request ID from an
|
|
37
37
|
application invocation, its time window and the intended workspace.
|
|
38
38
|
|
|
39
|
+
`login` and `setup` need the person to approve in a browser on this machine. If the
|
|
40
|
+
installed CLI's help lists exit code 18 `action_required`, run them with
|
|
41
|
+
`--json --no-browser`. When a step needs approval, the command exits 18 within
|
|
42
|
+
seconds. Show `data.next_action.url` to the person and ask them to approve; a
|
|
43
|
+
background process waits for the approval. After they approve, run the same command
|
|
44
|
+
again. A new project takes three runs: sign in, ingest approval, then a final check.
|
|
45
|
+
An older CLI exits 6 `no_browser_requires_terminal` instead. Then the person has to
|
|
46
|
+
run setup in their own terminal.
|
|
47
|
+
|
|
39
48
|
Repository identity is optional for capture but keeps traces attributable when
|
|
40
49
|
several repositories share a workspace. If the installed CLI's `setup` help lists
|
|
41
50
|
`--repository`, setup records `owner/name` from the git remote in a committed
|
|
@@ -68,7 +77,7 @@ For hosted Claude Desktop on an individual account, guide the human through Cust
|
|
|
68
77
|
|
|
69
78
|
For a fresh hosted workspace, first ask whether the human already has a Metergraph account and workspace. Direct them to https://app.metergraph.dev/ and the signup, invitation or sign-in flow actually offered. The hosted UI can offer "Get a free API key" for self-service signup, but do not assume that button or a workspace invitation is available to everyone. If access is unavailable, direct them to their workspace owner or Metergraph. Confirm the workspace before creating a coding-agent key.
|
|
70
79
|
|
|
71
|
-
For the commercial customer-local bundle, direct them to https://www.metergraph.dev/docs/self-host/local/#the-customer-local-bundle and the signed public release. They need a separate pull-only registry credential from Metergraph to pull the images; the public bundle download does not grant registry access. Verify the release before installation. Local admin credentials from the bundle's private configuration provide dashboard sign-in; create a coding-agent key in that installation's Keys page. Do not substitute hosted signup or a hosted key. Do not clone the private implementation or run development seed commands.
|
|
80
|
+
For the commercial customer-local bundle, direct them to https://www.metergraph.dev/docs/self-host/local/#the-customer-local-bundle and the signed public release. They need a separate pull-only registry credential from Metergraph to pull the images; the public bundle download does not grant registry access. Verify the release before installation. Local admin credentials from the bundle's private configuration provide dashboard sign-in; create a coding-agent key in that installation's Keys page. Do not substitute hosted signup or a hosted key. Do not clone the private implementation or run development seed commands. Use `metergraph-local-bundle` to stand the bundle up when that skill is installed.
|
|
72
81
|
|
|
73
82
|
For customer-owned AWS, direct them to https://www.metergraph.dev/docs/self-host/aws/. An operator provisions the installation, identity and membership. There is no public signup. Ask for the installation's actual reachable address and use a coding-agent key from its Keys page. Its default internal load balancer is private, and its Agent Access surface excludes trace content and replay.
|
|
74
83
|
|
|
@@ -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": "e7fc4eb7a22eeb16c357bede9d0a080ad3b415aaab1d9c72f164d5350d48c4b1",
|
|
7
|
+
"size": 13664,
|
|
8
|
+
"revision": "sha256-e7fc4eb7a22e"
|
|
9
9
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 1,
|
|
3
3
|
"repository": "metergraph/skills",
|
|
4
|
-
"commit": "
|
|
4
|
+
"commit": "dda4d5f130448f12a7fb2549ed264503d8b51d8d",
|
|
5
5
|
"skills": [
|
|
6
6
|
{
|
|
7
7
|
"name": "metergraph-analyze",
|
|
@@ -33,6 +33,12 @@
|
|
|
33
33
|
"size": 3789,
|
|
34
34
|
"revision": "sha256-9ce5a2cf527e"
|
|
35
35
|
},
|
|
36
|
+
{
|
|
37
|
+
"name": "metergraph-local-bundle",
|
|
38
|
+
"sha256": "692cd8952a8a8896ec2c06091b329a7a01912e261e87d62a9458452172a7c9b0",
|
|
39
|
+
"size": 12245,
|
|
40
|
+
"revision": "sha256-692cd8952a8a"
|
|
41
|
+
},
|
|
36
42
|
{
|
|
37
43
|
"name": "metergraph-model-swap",
|
|
38
44
|
"sha256": "d77ee37f882ab1e50dc4a093e64a2c3661f4cd9bb59f184da6dccbd76fd51a18",
|
|
@@ -41,9 +47,9 @@
|
|
|
41
47
|
},
|
|
42
48
|
{
|
|
43
49
|
"name": "metergraph-onboarding",
|
|
44
|
-
"sha256": "
|
|
45
|
-
"size":
|
|
46
|
-
"revision": "sha256-
|
|
50
|
+
"sha256": "6e6f418933f37b34437035ab3171344d43f37dee3770815b19f97efb62e5f1d1",
|
|
51
|
+
"size": 10267,
|
|
52
|
+
"revision": "sha256-6e6f418933f3"
|
|
47
53
|
},
|
|
48
54
|
{
|
|
49
55
|
"name": "metergraph-report",
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: metergraph-local-bundle
|
|
3
|
+
description: Verify and start the signed Metergraph customer-local bundle on a person's own machine, preserve existing local data and secrets, then hand over to metergraph setup. Use when customer-local setup reports that the bundle_started_verified prerequisite is incomplete or when installing the commercial local bundle from a public release.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Start the signed customer-local bundle
|
|
7
|
+
|
|
8
|
+
Stand up the released bundle only on the person's own machine, then run
|
|
9
|
+
`metergraph setup`. The CLI does not provision it, and
|
|
10
|
+
`--confirm-prerequisites` records confirmation rather than installing anything.
|
|
11
|
+
|
|
12
|
+
Keep registry, admin and database secrets out of chat, command arguments and
|
|
13
|
+
output. Never read or print `.env` values, `~/.docker/config.json`,
|
|
14
|
+
`docker compose config` output or container environment from `docker inspect`.
|
|
15
|
+
Do not configure model-provider keys.
|
|
16
|
+
|
|
17
|
+
## Stop in remote runtimes
|
|
18
|
+
|
|
19
|
+
Before changing anything, determine where the agent executes. Check only
|
|
20
|
+
whether these variables exist, never their values:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
for name in SSH_CONNECTION SSH_CLIENT SSH_TTY CODESPACES GITPOD_WORKSPACE_ID CLOUD_SHELL CI; do
|
|
24
|
+
if printenv "$name" >/dev/null 2>&1; then printf '%s is set\n' "$name"; fi
|
|
25
|
+
done
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
If any is set, or this is otherwise a hosted/cloud runtime, download, verify,
|
|
29
|
+
extract and start nothing. Prefer an installed `metergraph` whose `--help`
|
|
30
|
+
lists `setup`; otherwise state that the fallback fetches the preview CLI and
|
|
31
|
+
use `npx --yes metergraph-cli@next` as the prefix. Run only:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
metergraph setup --runtime cloud --deployment customer-local --url http://localhost:8080 --workspace 11111111-1111-4111-8111-111111111111 --skip-skill --json
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Relay its `data.next_action.kind: "run_on_customer_machine"` handoff and tell
|
|
38
|
+
the person to run this skill on their own machine. The placeholder workspace is
|
|
39
|
+
intentional: the runtime check refuses before making a request or writing a
|
|
40
|
+
file. When an SSH, cloud or CI marker is set, even `--runtime local` is refused
|
|
41
|
+
with reason `run_on_local_machine` and the same handoff. Valid runtimes are
|
|
42
|
+
`local`, `cloud` and `cloud-no-shell`. Never expose localhost to the remote
|
|
43
|
+
session.
|
|
44
|
+
|
|
45
|
+
## Preflight and reuse
|
|
46
|
+
|
|
47
|
+
Ask for the coding client and bundle version. Default to the newest version at
|
|
48
|
+
https://www.metergraph.dev/docs/releases/ and record its `v`-prefixed value.
|
|
49
|
+
Record the application repository directory to return to for setup. Run:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
docker info >/dev/null
|
|
53
|
+
docker compose version
|
|
54
|
+
cosign version
|
|
55
|
+
curl -fsS http://localhost:8080/v1/deployment
|
|
56
|
+
lsof -nP -iTCP:8080 -sTCP:LISTEN
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Docker with Compose must be running and `cosign` installed. If the deployment
|
|
60
|
+
endpoint returns the `local` profile, reuse that stack and download nothing.
|
|
61
|
+
Find its bundle directory using only the Compose working-directory label:
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
container_id="$(docker ps --filter publish=8080 --format '{{.ID}}' | head -n 1)"
|
|
65
|
+
bundle_dir="$(docker inspect --format '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}' "$container_id")"
|
|
66
|
+
(cd "$bundle_dir" && ./bin/status)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Do not inspect container environment. Read the workspace ID from the
|
|
70
|
+
`workspace` check and skip to **Run setup**. If the directory cannot be found,
|
|
71
|
+
ask for the workspace ID from Settings. If nothing listens on 8080, continue.
|
|
72
|
+
If anything else owns it, stop and ask; never stop or reconfigure it.
|
|
73
|
+
|
|
74
|
+
Use `~/metergraph/<version>` and set
|
|
75
|
+
`bundle_dir="$HOME/metergraph/$version/local"`. If it contains `.env`, reuse
|
|
76
|
+
that existing installation: never overwrite, delete or read the file, and
|
|
77
|
+
never extract over `local/`. Continue at **Log in and start**. Otherwise check:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
docker volume inspect local_pgdata >/dev/null 2>&1
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Because the extracted directory and Compose project are `local`, an existing
|
|
84
|
+
volume is `local_pgdata`. If it exists without the matching `local/.env`, stop
|
|
85
|
+
and ask for the prior bundle/configuration. Postgres fixed its password when
|
|
86
|
+
the volume was created; a new `.env` can cause `password authentication failed`.
|
|
87
|
+
Never remove the volume, delete `.env` or use `down --volumes`.
|
|
88
|
+
|
|
89
|
+
## Download and verify
|
|
90
|
+
|
|
91
|
+
Require a fresh, empty version directory. Before creating it, tell the person
|
|
92
|
+
the directory and four public files that will be written. No account is needed.
|
|
93
|
+
Set `version` to the recorded version and run:
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
release_dir="$HOME/metergraph/$version"; mkdir -p "$release_dir"; cd "$release_dir"
|
|
97
|
+
archive="metergraph-customer-local-$version.tar.gz"
|
|
98
|
+
base="https://www.metergraph.dev/releases/$version"
|
|
99
|
+
curl -fsSLO "$base/$archive"
|
|
100
|
+
curl -fsSLO "$base/metergraph-byoc-release.json"
|
|
101
|
+
curl -fsSLO "$base/metergraph-byoc-release.json.sig"
|
|
102
|
+
curl -fsSLO "$base/metergraph-byoc-release.json.pem"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Do not list, extract or run archive contents. Verify the manifest signature
|
|
106
|
+
with exactly this command. The issuer and identity are pinned here, not read
|
|
107
|
+
from the release page, so a changed download site cannot change who must have
|
|
108
|
+
signed the release:
|
|
109
|
+
|
|
110
|
+
```sh
|
|
111
|
+
cosign verify-blob \
|
|
112
|
+
--certificate metergraph-byoc-release.json.pem \
|
|
113
|
+
--signature metergraph-byoc-release.json.sig \
|
|
114
|
+
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
|
115
|
+
--certificate-identity-regexp '^https://github\.com/PioneerSquareLabs/metergraph-internal/\.github/workflows/byoc-release\.yml@refs/(heads/main|tags/v.+)$' \
|
|
116
|
+
metergraph-byoc-release.json
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Never loosen or drop the issuer or identity, and never add
|
|
120
|
+
`--insecure-ignore-tlog` or a similar bypass. If the release page publishes a
|
|
121
|
+
different identity, stop and tell the person rather than using it.
|
|
122
|
+
|
|
123
|
+
If cosign fails, stop: "The manifest signature did not verify. Do not extract
|
|
124
|
+
or run this bundle." After it succeeds, check signed fields:
|
|
125
|
+
|
|
126
|
+
```sh
|
|
127
|
+
python3 - "$version" "$archive" <<'EOF'
|
|
128
|
+
import json, sys
|
|
129
|
+
version, archive = sys.argv[1:]
|
|
130
|
+
m = json.load(open("metergraph-byoc-release.json"))
|
|
131
|
+
required = ("release", "local_bundle", "local_bundle_sha256",
|
|
132
|
+
"local_image_digest", "local_analysis_image_digest")
|
|
133
|
+
missing = [key for key in required if not m.get(key)]
|
|
134
|
+
if missing: sys.exit("Signed manifest is missing: " + ", ".join(missing))
|
|
135
|
+
if m["release"] != version: sys.exit("Signed manifest release mismatch. Do not run it.")
|
|
136
|
+
if m["local_bundle"] != archive: sys.exit("Signed archive name mismatch. Do not run it.")
|
|
137
|
+
print("Signed manifest release, archive, and image digests are present.")
|
|
138
|
+
EOF
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Then run this digest check, still before any `tar` command:
|
|
142
|
+
|
|
143
|
+
```sh
|
|
144
|
+
python3 - <<'EOF'
|
|
145
|
+
import hashlib, json, sys
|
|
146
|
+
manifest = json.load(open("metergraph-byoc-release.json"))
|
|
147
|
+
name = manifest["local_bundle"]
|
|
148
|
+
actual = hashlib.sha256(open(name, "rb").read()).hexdigest()
|
|
149
|
+
if actual != manifest["local_bundle_sha256"]:
|
|
150
|
+
sys.exit(f"{name} does not match the signed manifest. Do not run it.")
|
|
151
|
+
print(f"{name} matches the signed manifest.")
|
|
152
|
+
EOF
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Stop on failure. After both checks pass, list and validate archive paths:
|
|
156
|
+
|
|
157
|
+
```sh
|
|
158
|
+
tar -tzf "$archive"
|
|
159
|
+
python3 - "$archive" <<'EOF'
|
|
160
|
+
from pathlib import PurePosixPath
|
|
161
|
+
import sys, tarfile
|
|
162
|
+
bad = []
|
|
163
|
+
with tarfile.open(sys.argv[1], "r:gz") as bundle:
|
|
164
|
+
for member in bundle.getmembers():
|
|
165
|
+
path = PurePosixPath(member.name)
|
|
166
|
+
if path.is_absolute() or not path.parts or path.parts[0] != "local" or ".." in path.parts:
|
|
167
|
+
bad.append(member.name)
|
|
168
|
+
if bad: sys.exit("Unsafe archive paths; do not extract: " + ", ".join(bad[:10]))
|
|
169
|
+
print("Every archive entry is beneath local/ with a safe relative path.")
|
|
170
|
+
EOF
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Only now tell the person extraction will create `local/`, then run:
|
|
174
|
+
|
|
175
|
+
```sh
|
|
176
|
+
tar xzf "$archive"
|
|
177
|
+
mv metergraph-byoc-release.json* local/
|
|
178
|
+
cd local
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Configure without exposing passwords
|
|
182
|
+
|
|
183
|
+
Reuse an existing `.env` unchanged. For a new installation, state that a
|
|
184
|
+
mode-0600 file with random admin/database passwords and empty provider keys
|
|
185
|
+
will be created. Confirm `local_pgdata` did not exist, then run:
|
|
186
|
+
|
|
187
|
+
```sh
|
|
188
|
+
umask 077; cp .env.example .env; chmod 600 .env
|
|
189
|
+
python3 - <<'EOF'
|
|
190
|
+
from pathlib import Path
|
|
191
|
+
import secrets
|
|
192
|
+
path = Path(".env")
|
|
193
|
+
keys = ("METERGRAPH_LOCAL_ADMIN_PASSWORD", "METERGRAPH_DB_PASSWORD")
|
|
194
|
+
values = {key: secrets.token_urlsafe(24) for key in keys}
|
|
195
|
+
lines, found = path.read_text().splitlines(), set()
|
|
196
|
+
for i, line in enumerate(lines):
|
|
197
|
+
for key in keys:
|
|
198
|
+
if line.startswith(key + "="):
|
|
199
|
+
lines[i], found = key + "=" + values[key], found | {key}
|
|
200
|
+
if found != set(keys): raise SystemExit("Password keys missing from .env.example")
|
|
201
|
+
path.write_text("\n".join(lines) + "\n")
|
|
202
|
+
print("Set random values for: " + ", ".join(keys))
|
|
203
|
+
EOF
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Do not display, source or parse `.env`. Leave `AI_GATEWAY_API_KEY`,
|
|
207
|
+
`FIREWORKS_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` and `GOOGLE_API_KEY`
|
|
208
|
+
empty.
|
|
209
|
+
|
|
210
|
+
## Log in and start
|
|
211
|
+
|
|
212
|
+
Stop for the person to log in to the registry in their own terminal. They use
|
|
213
|
+
the pull-only invitation values, never chat or an agent-visible command:
|
|
214
|
+
|
|
215
|
+
```sh
|
|
216
|
+
echo "$METERGRAPH_PULL_TOKEN" | docker login ghcr.io -u "$METERGRAPH_PULL_USER" --password-stdin
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Wait for confirmation only; never read Docker's credential file. State that
|
|
220
|
+
`./bin/start` will re-verify the manifest and both image digests, pull by digest,
|
|
221
|
+
start containers, and on a new database send one synthetic demo trace labeled
|
|
222
|
+
`environment=local-demo`. Then run:
|
|
223
|
+
|
|
224
|
+
```sh
|
|
225
|
+
./bin/start
|
|
226
|
+
./bin/status
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
An image-pull failure usually means login is missing or expired. On other
|
|
230
|
+
failure, read only the log named by `./bin/start` and `./bin/status`; never read
|
|
231
|
+
rendered Compose configuration or container environment. On password
|
|
232
|
+
authentication failure with `local_pgdata`, stop and ask for its matching
|
|
233
|
+
`.env`. Never delete data. Read the workspace ID and suggested command from the
|
|
234
|
+
status JSON's `workspace` check. The service listens only on localhost:8080.
|
|
235
|
+
|
|
236
|
+
## Run setup
|
|
237
|
+
|
|
238
|
+
Return to the application repository. Prefer installed `metergraph` only when
|
|
239
|
+
its help lists `setup`; otherwise state that `npx --yes metergraph-cli@next`
|
|
240
|
+
fetches the preview CLI and use it as the prefix. Run with `--json`:
|
|
241
|
+
|
|
242
|
+
```sh
|
|
243
|
+
metergraph setup --runtime local --deployment customer-local --url http://localhost:8080 --workspace 11111111-1111-4111-8111-111111111111 --confirm-prerequisites --client <client> --json
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Replace the example UUID and client only. Setup writes an ingest key to the
|
|
247
|
+
application `.env`, installs the skill and requests Metadata access only; never
|
|
248
|
+
print that file. Setup opens the deployment's browser consent page and waits;
|
|
249
|
+
the person signs in as the local admin and completes two approvals. If the
|
|
250
|
+
browser cannot open, follow the `metergraph` skill's guidance for handing the
|
|
251
|
+
person the approval URL. Continue until `data.status` is `ok`. For
|
|
252
|
+
`operator_handoff`, relay `data.next_action.kind` (`complete_prerequisite`,
|
|
253
|
+
`run_on_customer_machine`, `connection_guide`, or other); never report a
|
|
254
|
+
handoff as success.
|
|
255
|
+
|
|
256
|
+
The agent never runs a command that reads `.env`. To see the admin login without
|
|
257
|
+
printing it, the person runs these no-output pipelines themselves, one at a
|
|
258
|
+
time, in their own terminal or in Claude Code with the `!` prefix. Fill in the
|
|
259
|
+
absolute bundle directory for them; their shell does not have `bundle_dir`:
|
|
260
|
+
|
|
261
|
+
```sh
|
|
262
|
+
sed -n 's/^METERGRAPH_LOCAL_ADMIN_EMAIL=//p' ~/metergraph/<version>/local/.env | pbcopy
|
|
263
|
+
sed -n 's/^METERGRAPH_LOCAL_ADMIN_PASSWORD=//p' ~/metergraph/<version>/local/.env | pbcopy
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
They may instead use their own editor. Never use `cat`, `grep` or any other
|
|
267
|
+
command that prints `.env`, particularly with `!`, because `!` output lands in
|
|
268
|
+
the transcript. A new configuration keeps `admin@example.com` as the email.
|
|
269
|
+
|
|
270
|
+
## Recovery and stopping
|
|
271
|
+
|
|
272
|
+
| State | Action |
|
|
273
|
+
| --- | --- |
|
|
274
|
+
| Signature, manifest, digest or path check fails | Stop; extract and run nothing |
|
|
275
|
+
| Port 8080 belongs to something else | Stop and ask; do not alter it |
|
|
276
|
+
| Registry pull is denied | Person repeats private login; never request the token |
|
|
277
|
+
| Existing `.env` | Reuse unchanged; never regenerate passwords |
|
|
278
|
+
| `local_pgdata` lacks its matching `.env` | Stop and find prior configuration; never remove it |
|
|
279
|
+
| Startup fails | Use status and only the named log; preserve secrets and data |
|
|
280
|
+
| Setup returns a handoff | Relay exact status/next action; do not report success |
|
|
281
|
+
|
|
282
|
+
To stop later, follow the bundle README's `stop`. Prefer it over `down`, which
|
|
283
|
+
loses raw payloads in local object storage. Never use `down --volumes`.
|
|
@@ -34,8 +34,9 @@ skill installation; a later installed version may offer `login`, `setup` and
|
|
|
34
34
|
https://www.metergraph.dev/docs/start/first-trace/ and its manual setup steps.
|
|
35
35
|
Do not infer that a server endpoint or source-only CLI command has shipped.
|
|
36
36
|
|
|
37
|
-
For customer-local MeterGraph, use
|
|
38
|
-
|
|
37
|
+
For customer-local MeterGraph, use `metergraph-local-bundle` to stand up the
|
|
38
|
+
released bundle, then continue here. Use its included README, signed manifest
|
|
39
|
+
and verification files. The human obtains the current
|
|
39
40
|
pull-only registry credential; registry access is not yet universally self-serve.
|
|
40
41
|
Use password-stdin and the local secret store for registry login. Verify the
|
|
41
42
|
archive before running its scripts. Configure the bundle's local settings,
|
package/bin/metergraph.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { handOff, handsOff } from "../src/approval-handoff.js";
|
|
2
3
|
import { main } from "../src/cli.js";
|
|
3
4
|
|
|
4
5
|
// A closed pipe on stdout (for example "metergraph --help | head -1") is not
|
|
5
6
|
// a CLI failure.
|
|
6
7
|
process.stdout.on("error", () => {});
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
});
|
|
9
|
+
const argv = process.argv.slice(2);
|
|
10
|
+
const io = { stdout: process.stdout, stderr: process.stderr };
|
|
11
|
+
process.exitCode = handsOff(argv) ? await handOff(argv, io) : await main(argv, io);
|
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.6",
|
|
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": {
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import childProcess from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
import { parseArgs } from "./args.js";
|
|
5
|
+
import { envelope, toJsonLine } from "./output.js";
|
|
6
|
+
|
|
7
|
+
// login and setup with --json --no-browser may need a person to approve in a
|
|
8
|
+
// browser. The approval returns to a loopback listener, so something must
|
|
9
|
+
// keep listening after the agent has the URL. Agent shells usually show a
|
|
10
|
+
// command's output only after it exits, so that cannot be this process.
|
|
11
|
+
//
|
|
12
|
+
// This process runs the same command as a detached waiter and relays the
|
|
13
|
+
// waiter's one JSON line. When the line is action_required, it exits with it
|
|
14
|
+
// at once and leaves the waiter listening until approval or --timeout-ms; the
|
|
15
|
+
// waiter saves what the approval completes and prints nothing more. Any other
|
|
16
|
+
// line is the command's result, and this process waits for the waiter to
|
|
17
|
+
// finish. The arguments carry no credential, and the waiter's environment is
|
|
18
|
+
// this one plus a marker.
|
|
19
|
+
const BIN = fileURLToPath(new URL("../bin/metergraph.js", import.meta.url));
|
|
20
|
+
const WAITER = "METERGRAPH_APPROVAL_WAITER";
|
|
21
|
+
|
|
22
|
+
export function handsOff(argv, env = process.env) {
|
|
23
|
+
if (env[WAITER] === "1") return false;
|
|
24
|
+
const parsed = parseArgs(argv);
|
|
25
|
+
return parsed.ok && (parsed.command === "login" || parsed.command === "setup") && parsed.json && parsed.noBrowser;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function handOff(argv, { stdout }) {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
let child;
|
|
31
|
+
try {
|
|
32
|
+
child = childProcess.spawn(process.execPath, [...process.execArgv, BIN, ...argv], {
|
|
33
|
+
env: { ...process.env, [WAITER]: "1" },
|
|
34
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
35
|
+
detached: true,
|
|
36
|
+
windowsHide: true,
|
|
37
|
+
});
|
|
38
|
+
} catch {
|
|
39
|
+
resolve(failed(argv, stdout));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
let text = "";
|
|
43
|
+
let settled = false;
|
|
44
|
+
// Ctrl+C before the line arrives cancels the waiter, which reports it.
|
|
45
|
+
const forward = (signal) => child.kill(signal);
|
|
46
|
+
const signals = ["SIGINT", "SIGTERM"];
|
|
47
|
+
for (const signal of signals) process.on(signal, forward);
|
|
48
|
+
const finish = (code) => {
|
|
49
|
+
if (settled) return;
|
|
50
|
+
settled = true;
|
|
51
|
+
for (const signal of signals) process.removeListener(signal, forward);
|
|
52
|
+
resolve(code);
|
|
53
|
+
};
|
|
54
|
+
child.once("error", () => finish(failed(argv, stdout)));
|
|
55
|
+
child.stdout.setEncoding("utf8");
|
|
56
|
+
child.stdout.on("data", (chunk) => {
|
|
57
|
+
if (settled) return;
|
|
58
|
+
text += chunk;
|
|
59
|
+
const end = text.indexOf("\n");
|
|
60
|
+
if (end === -1) return;
|
|
61
|
+
let line;
|
|
62
|
+
try { line = JSON.parse(text.slice(0, end)); } catch { line = null; }
|
|
63
|
+
if (line?.outcome !== "action_required") return;
|
|
64
|
+
stdout.write(text.slice(0, end + 1));
|
|
65
|
+
child.stdout.destroy();
|
|
66
|
+
child.unref();
|
|
67
|
+
finish(line.exit_code);
|
|
68
|
+
});
|
|
69
|
+
child.once("close", (code) => {
|
|
70
|
+
if (settled) return;
|
|
71
|
+
if (text.endsWith("\n") && text.indexOf("\n") === text.length - 1) {
|
|
72
|
+
stdout.write(text);
|
|
73
|
+
finish(code ?? 1);
|
|
74
|
+
} else {
|
|
75
|
+
finish(failed(argv, stdout));
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function failed(argv, stdout) {
|
|
82
|
+
const parsed = parseArgs(argv);
|
|
83
|
+
const result = envelope({
|
|
84
|
+
command: parsed.command ?? null,
|
|
85
|
+
outcome: "internal_error",
|
|
86
|
+
reason: "unexpected_failure",
|
|
87
|
+
message: "The CLI failed unexpectedly. No details are shown to avoid exposing sensitive input.",
|
|
88
|
+
});
|
|
89
|
+
stdout.write(toJsonLine(result));
|
|
90
|
+
return result.exit_code;
|
|
91
|
+
}
|
package/src/args.js
CHANGED
|
@@ -279,9 +279,10 @@ function parseVerify(values, flags, json, fail) {
|
|
|
279
279
|
open: flags.has("--open"), noBrowser: flags.has("--no-browser"), json };
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
282
|
+
// The client is required, so a script states which client loads the skill.
|
|
283
|
+
// The runtime defaults to local, the runtime setup installs the skill for;
|
|
284
|
+
// pass --runtime cloud for a cloud checkout. Values for clients and runtimes
|
|
285
|
+
// that cannot use project skill files are accepted here and handed off later.
|
|
285
286
|
function parseSkill(action, values, json, fail) {
|
|
286
287
|
if (action === null) {
|
|
287
288
|
return fail(
|
|
@@ -296,10 +297,7 @@ function parseSkill(action, values, json, fail) {
|
|
|
296
297
|
if (!Object.hasOwn(SKILL_CLIENTS, client) && !Object.hasOwn(HANDOFF_SKILL_CLIENTS, client)) {
|
|
297
298
|
return fail("invalid_client", "--client must be codex, claude or cursor.");
|
|
298
299
|
}
|
|
299
|
-
const runtime = values["--runtime"];
|
|
300
|
-
if (runtime === undefined) {
|
|
301
|
-
return fail("missing_runtime", "--runtime is required. Use local or cloud.");
|
|
302
|
-
}
|
|
300
|
+
const runtime = values["--runtime"] ?? "local";
|
|
303
301
|
if (!SKILL_RUNTIMES.includes(runtime) && !HANDOFF_SKILL_RUNTIMES.includes(runtime)) {
|
|
304
302
|
return fail("invalid_runtime", "--runtime must be local or cloud.");
|
|
305
303
|
}
|
|
@@ -442,7 +440,8 @@ function parseSetup(values, flags, json, fail) {
|
|
|
442
440
|
parseTimeout(rawTimeout, LOGIN_MIN_TIMEOUT_MS, LOGIN_MAX_TIMEOUT_MS);
|
|
443
441
|
if (timeoutMs === null) return fail("invalid_timeout", `--timeout-ms must be a whole number from ${LOGIN_MIN_TIMEOUT_MS} to ${LOGIN_MAX_TIMEOUT_MS}.`);
|
|
444
442
|
return { ok: true, command: "setup", runtime, origin, originExplicit: values["--url"] !== undefined,
|
|
445
|
-
deployment,
|
|
443
|
+
deployment, deploymentExplicit: values["--deployment"] !== undefined,
|
|
444
|
+
confirmPrerequisites: flags.has("--confirm-prerequisites"), agentTokenFile,
|
|
446
445
|
workspace: rawWorkspace === undefined ? null : rawWorkspace.toLowerCase(),
|
|
447
446
|
project: paths.project, configDir: paths.configDir, envFile, client, skipSkill: flags.has("--skip-skill"),
|
|
448
447
|
timeoutMs, noBrowser: flags.has("--no-browser"), repair: flags.has("--repair"),
|
package/src/auth-login.js
CHANGED
|
@@ -39,6 +39,11 @@ import { deadline, trapSignals } from "./transport.js";
|
|
|
39
39
|
// server-verified workspace UUID. Token values, the authorization code, the
|
|
40
40
|
// PKCE verifier, absolute paths and server text never leave this module.
|
|
41
41
|
// progress(text) prints human progress lines; it is a no-op with --json.
|
|
42
|
+
// options.announce(data), given only with --json --no-browser, receives the
|
|
43
|
+
// login data with an open_url next action as soon as the authorization URL
|
|
44
|
+
// exists, while the listener keeps waiting. The caller prints it as the one
|
|
45
|
+
// JSON line, so an agent can hand the URL to a person; approval-handoff.js
|
|
46
|
+
// lets the agent's command exit then while this process keeps waiting.
|
|
42
47
|
|
|
43
48
|
export async function runLogin(options, progress) {
|
|
44
49
|
const ctx = {
|
|
@@ -58,21 +63,12 @@ export async function runLogin(options, progress) {
|
|
|
58
63
|
if (options.runtime !== "local") return end("unsupported", "runtime_not_supported", GUIDE);
|
|
59
64
|
const remote = detectRemoteSession();
|
|
60
65
|
if (remote !== null) return end("unsupported", remote, GUIDE);
|
|
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;
|
|
68
|
-
|
|
69
66
|
const trap = trapSignals();
|
|
70
67
|
try {
|
|
71
68
|
const root = resolveProject(options.project);
|
|
72
69
|
const configDir = resolveConfigDir(options.configDir);
|
|
73
70
|
const existing = readBinding(root);
|
|
74
71
|
const bound = existing?.binding ?? null;
|
|
75
|
-
if (urlUnshowable && (bound === null || options.reconnect)) return noBrowserHandoff();
|
|
76
72
|
// Setup pins a deployment model before sign in. A stale binding for a
|
|
77
73
|
// different profile must never be silently reused or reconnected.
|
|
78
74
|
if (options.expectedProfile && bound !== null && !options.reconnect &&
|
|
@@ -102,9 +98,11 @@ export async function runLogin(options, progress) {
|
|
|
102
98
|
}
|
|
103
99
|
if (session.outcome !== "login_required") return end(session.outcome, session.reason);
|
|
104
100
|
}
|
|
105
|
-
if (urlUnshowable) return noBrowserHandoff();
|
|
106
101
|
|
|
107
|
-
const
|
|
102
|
+
const announce = options.announce
|
|
103
|
+
? (next) => options.announce(loginData({ ...ctx, status: "approval_pending" }, next))
|
|
104
|
+
: null;
|
|
105
|
+
const flow = await authorize(options, ctx, trap.signal, progress, announce);
|
|
108
106
|
if (!flow.ok) return end(flow.outcome, flow.reason, flow.next ?? null);
|
|
109
107
|
const { endpoints, clientId, grant } = flow;
|
|
110
108
|
|
|
@@ -169,7 +167,7 @@ export async function runLogin(options, progress) {
|
|
|
169
167
|
// written here. A validated grant that is not accepted is sent for revocation
|
|
170
168
|
// before returning; that is best effort, and a token response that fails
|
|
171
169
|
// validation cannot be revoked.
|
|
172
|
-
async function authorize(options, ctx, cancel, progress) {
|
|
170
|
+
async function authorize(options, ctx, cancel, progress, announce) {
|
|
173
171
|
const preflight = await runDoctor({ origin: ctx.origin, timeoutMs: AUTH_HTTP_TIMEOUT_MS });
|
|
174
172
|
if (cancel.aborted) return fail("authorization_failed", "cancelled");
|
|
175
173
|
if (preflight.outcome !== "authentication_required") return fail(preflight.outcome, preflight.reason);
|
|
@@ -217,7 +215,9 @@ async function authorize(options, ctx, cancel, progress) {
|
|
|
217
215
|
const seconds = Math.round(options.timeoutMs / 1000);
|
|
218
216
|
// A wait armed after Ctrl+C has already settled; do not show the URL.
|
|
219
217
|
if (cancel.aborted) return fail("authorization_failed", "cancelled");
|
|
220
|
-
if (options.noBrowser) {
|
|
218
|
+
if (options.noBrowser && announce) {
|
|
219
|
+
announce(openUrl(url, seconds, "sign in with Metadata access"));
|
|
220
|
+
} else if (options.noBrowser) {
|
|
221
221
|
progress(
|
|
222
222
|
"Open this URL in a browser on this machine to sign in:\n" +
|
|
223
223
|
`${url}\n` +
|
|
@@ -375,6 +375,20 @@ function fail(outcome, reason, next = null) {
|
|
|
375
375
|
return { ok: false, outcome, reason, next };
|
|
376
376
|
}
|
|
377
377
|
|
|
378
|
+
// The next action a --json --no-browser run prints while it waits. The URL is
|
|
379
|
+
// the authorization request: public client ID, loopback redirect, state and
|
|
380
|
+
// PKCE challenge. It carries no token, code or verifier.
|
|
381
|
+
export function openUrl(url, seconds, purpose) {
|
|
382
|
+
return {
|
|
383
|
+
kind: "open_url",
|
|
384
|
+
url,
|
|
385
|
+
timeout_seconds: seconds,
|
|
386
|
+
message: `Show this URL to the person now and ask them to open it in a browser on this machine to ` +
|
|
387
|
+
`${purpose}. A background process waits up to ${seconds} seconds for their approval. ` +
|
|
388
|
+
"After they approve, run the same command again to continue.",
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
378
392
|
const GUIDE = { kind: "connection_guide", url: CONNECTION_GUIDE_URL };
|
|
379
393
|
const RECONNECT = {
|
|
380
394
|
kind: "reconnect",
|
package/src/cli.js
CHANGED
|
@@ -18,7 +18,7 @@ import { runRead } from "./read.js";
|
|
|
18
18
|
import { readMessage, readText } from "./read-output.js";
|
|
19
19
|
import { listSkillPack, runSkill, runSkillPack } from "./skill.js";
|
|
20
20
|
import { runSetup } from "./setup.js";
|
|
21
|
-
import { containsKnownCredential, preflightNonHostedSetup } from "./setup-deployment.js";
|
|
21
|
+
import { containsKnownCredential, preflightNonHostedSetup, prerequisiteGuide, resumeNonHostedRoute } from "./setup-deployment.js";
|
|
22
22
|
import { VERSION } from "./constants.js";
|
|
23
23
|
import { runVerify } from "./verify.js";
|
|
24
24
|
import { verifyMessage, verifyText } from "./verify-output.js";
|
|
@@ -75,8 +75,10 @@ async function run(argv, { stdout, stderr }) {
|
|
|
75
75
|
// Human progress goes to stderr, never with --json, so stdout carries
|
|
76
76
|
// only the final result.
|
|
77
77
|
const progress = parsed.json ? () => {} : (text) => stderr.write(`${text}\n`);
|
|
78
|
+
const pending = announcer(parsed, stdout, (data) => data);
|
|
78
79
|
const { outcome, reason, data } =
|
|
79
|
-
parsed.command === "login" ? await runLogin(parsed, progress) : await runLogout(parsed);
|
|
80
|
+
parsed.command === "login" ? await runLogin({ ...parsed, announce: pending.announce }, progress) : await runLogout(parsed);
|
|
81
|
+
if (pending.result) return pending.result;
|
|
80
82
|
const message = authMessage(outcome, reason);
|
|
81
83
|
const result = envelope({
|
|
82
84
|
command: parsed.command,
|
|
@@ -90,14 +92,18 @@ async function run(argv, { stdout, stderr }) {
|
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
if (parsed.command === "setup") {
|
|
93
|
-
const
|
|
95
|
+
const options = resumeNonHostedRoute(parsed);
|
|
96
|
+
const routed = options.deployment === "managed"
|
|
94
97
|
? { proceed: true, profile: "managed" }
|
|
95
|
-
: await preflightNonHostedSetup(
|
|
98
|
+
: await preflightNonHostedSetup(options);
|
|
96
99
|
const progress = parsed.json ? () => {} : (line) =>
|
|
97
100
|
stderr.write(containsKnownCredential(line, routed) ? "Setup progress.\n" : `${line}\n`);
|
|
101
|
+
// The announced line is checked for known credentials like the final one.
|
|
102
|
+
const pending = announcer(parsed, stdout, (data) => containsKnownCredential(data, routed) ? null : data);
|
|
98
103
|
const response = routed.proceed
|
|
99
|
-
? await runSetup({ ...
|
|
104
|
+
? await runSetup({ ...options, expectedProfile: routed.profile ?? null, announce: pending.announce }, progress)
|
|
100
105
|
: routed;
|
|
106
|
+
if (pending.result) return pending.result;
|
|
101
107
|
const { outcome, reason, data } = response;
|
|
102
108
|
let result = envelope({ command: "setup", outcome, reason, data,
|
|
103
109
|
message: outcome === "ok" ? null : "Setup did not complete. Review the reason and retry safely." });
|
|
@@ -107,7 +113,8 @@ async function run(argv, { stdout, stderr }) {
|
|
|
107
113
|
const receipt = result.data?.receipt;
|
|
108
114
|
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` : "";
|
|
109
115
|
const identity = repositoryLine(result.data?.repository);
|
|
110
|
-
const
|
|
116
|
+
const guide = handoffLines(result.data);
|
|
117
|
+
const human = `Metergraph setup: ${result.data?.status ?? "not_ready"}\n${summary}${identity}${guide}Application traffic verified: no\n${result.ok ? "Next: instrument your application and verify an exact trace.\n" : `Result: ${result.outcome} (${result.error.reason})\n`}`;
|
|
111
118
|
if (containsKnownCredential(human, routed)) {
|
|
112
119
|
result = envelope({ command: "setup", outcome: "unsupported", reason: "credential_echo" });
|
|
113
120
|
stdout.write(parsed.json ? toJsonLine(result) : "Setup stopped.\n");
|
|
@@ -181,6 +188,47 @@ async function run(argv, { stdout, stderr }) {
|
|
|
181
188
|
return result;
|
|
182
189
|
}
|
|
183
190
|
|
|
191
|
+
// With --json --no-browser, login and setup print their one JSON line as
|
|
192
|
+
// soon as a person must open an approval URL, then keep waiting for the
|
|
193
|
+
// callback; bin/metergraph.js has already moved this process to the
|
|
194
|
+
// background (approval-handoff.js). Whatever the run does afterwards, that
|
|
195
|
+
// line is its result; the agent reruns the command to continue. screen(data) returns
|
|
196
|
+
// the data to print, or null to print a credential_echo refusal instead.
|
|
197
|
+
function announcer(parsed, stdout, screen) {
|
|
198
|
+
const pending = { announce: null, result: null };
|
|
199
|
+
if (!parsed.json || !parsed.noBrowser) return pending;
|
|
200
|
+
pending.announce = (data) => {
|
|
201
|
+
if (pending.result) return;
|
|
202
|
+
const safe = screen(data);
|
|
203
|
+
pending.result = safe === null
|
|
204
|
+
? envelope({ command: parsed.command, outcome: "unsupported", reason: "credential_echo" })
|
|
205
|
+
: envelope({ command: parsed.command, outcome: "action_required", reason: "browser_approval_required",
|
|
206
|
+
data: safe, message: authMessage("action_required", "browser_approval_required") });
|
|
207
|
+
stdout.write(toJsonLine(pending.result));
|
|
208
|
+
};
|
|
209
|
+
return pending;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// What an operator handoff is waiting for, in fixed text: the route, each
|
|
213
|
+
// unconfirmed prerequisite with its guidance, and the next action.
|
|
214
|
+
function handoffLines(data) {
|
|
215
|
+
if (data?.status !== "operator_handoff") return "";
|
|
216
|
+
let text = data.origin ? `Deployment: ${data.deployment_profile} at ${data.origin}\n` : "";
|
|
217
|
+
if (data.workspace_id) text += `Workspace: ${data.workspace_id}\n`;
|
|
218
|
+
if (data.pending_prerequisites.length > 0) {
|
|
219
|
+
text += "Confirm each prerequisite, then rerun with --confirm-prerequisites:\n";
|
|
220
|
+
for (const name of data.pending_prerequisites) {
|
|
221
|
+
text += ` - ${name}: ${prerequisiteGuide(name, data.deployment_profile) ?? "see the guide"}\n`;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const next = data.next_action;
|
|
225
|
+
if (next?.message && !(next.kind === "complete_prerequisite" && data.pending_prerequisites.includes(next.prerequisite))) {
|
|
226
|
+
text += `Next: ${next.message}\n`;
|
|
227
|
+
}
|
|
228
|
+
if (next?.url) text += `Guide: ${next.url}\n`;
|
|
229
|
+
return text;
|
|
230
|
+
}
|
|
231
|
+
|
|
184
232
|
// One line on the repository identity setup found or recorded. The value is
|
|
185
233
|
// owner/name only; a remote URL is never shown.
|
|
186
234
|
function repositoryLine(repository) {
|
package/src/constants.js
CHANGED
|
@@ -141,6 +141,7 @@ export const EXIT_CODES = Object.freeze({
|
|
|
141
141
|
permission_denied: 15,
|
|
142
142
|
rate_limited: 16,
|
|
143
143
|
cancelled: 17,
|
|
144
|
+
action_required: 18,
|
|
144
145
|
});
|
|
145
146
|
|
|
146
147
|
export const EXIT_CODE_MEANINGS = Object.freeze({
|
|
@@ -177,4 +178,8 @@ export const EXIT_CODE_MEANINGS = Object.freeze({
|
|
|
177
178
|
rate_limited: "The service asked the CLI to slow down. Nothing was retried. Try again later.",
|
|
178
179
|
cancelled:
|
|
179
180
|
"A read command was interrupted before it finished. Read commands never change workspace configuration or telemetry.",
|
|
181
|
+
action_required:
|
|
182
|
+
"A person must act before this command can finish; data.next_action says what to do. " +
|
|
183
|
+
"With --json --no-browser the command exits as soon as the approval URL exists, and a background " +
|
|
184
|
+
"process waits for approval until --timeout-ms. After approval, run the same command again to continue.",
|
|
180
185
|
});
|
package/src/deployment-route.js
CHANGED
|
@@ -117,7 +117,7 @@ const DEPLOYMENT_PATH = "/v1/deployment";
|
|
|
117
117
|
const MIN_TIMEOUT_MS = 100;
|
|
118
118
|
const MAX_TIMEOUT_MS = 60000;
|
|
119
119
|
|
|
120
|
-
function action(kind, prerequisite = null) {
|
|
120
|
+
export function action(kind, prerequisite = null) {
|
|
121
121
|
// Kinds are constants from this module, so this is a programming error.
|
|
122
122
|
if (!ACTION_KINDS.includes(kind)) throw new Error("unknown next action kind");
|
|
123
123
|
return { kind, prerequisite, url: CONNECTION_GUIDE_URL };
|
package/src/output.js
CHANGED
|
@@ -73,20 +73,20 @@ const SKILL_OPTIONS = [
|
|
|
73
73
|
{
|
|
74
74
|
name: "--runtime",
|
|
75
75
|
value: "RUNTIME",
|
|
76
|
-
summary: "
|
|
76
|
+
summary: "local (default) or cloud: where the client runs. Recorded, not detected.",
|
|
77
77
|
},
|
|
78
78
|
{ name: "--project", value: "DIR", summary: "Existing project directory. Default: the current directory." },
|
|
79
79
|
{ name: "--json", value: null, summary: "Print one JSON line on stdout." },
|
|
80
80
|
];
|
|
81
81
|
|
|
82
82
|
const SKILL_USAGE = [
|
|
83
|
-
"metergraph skill install --client CLIENT --runtime RUNTIME [--project DIR] [--json]",
|
|
84
|
-
"metergraph skill update --client CLIENT --runtime RUNTIME [--project DIR] [--json]",
|
|
83
|
+
"metergraph skill install --client CLIENT [--runtime RUNTIME] [--project DIR] [--json]",
|
|
84
|
+
"metergraph skill update --client CLIENT [--runtime RUNTIME] [--project DIR] [--json]",
|
|
85
85
|
];
|
|
86
86
|
|
|
87
87
|
const SKILLS_USAGE = [
|
|
88
|
-
"metergraph skills install --client CLIENT --runtime RUNTIME [--project DIR] [--json]",
|
|
89
|
-
"metergraph skills update --client CLIENT --runtime RUNTIME [--project DIR] [--json]",
|
|
88
|
+
"metergraph skills install --client CLIENT [--runtime RUNTIME] [--project DIR] [--json]",
|
|
89
|
+
"metergraph skills update --client CLIENT [--runtime RUNTIME] [--project DIR] [--json]",
|
|
90
90
|
"metergraph skills list [--project DIR] [--json]",
|
|
91
91
|
];
|
|
92
92
|
|
|
@@ -106,6 +106,15 @@ const SKILLS_STATE_LABELS = {
|
|
|
106
106
|
unsafe_path: "unsafe path",
|
|
107
107
|
};
|
|
108
108
|
|
|
109
|
+
// How a coding agent gets an approval URL from login or setup. Many agent
|
|
110
|
+
// shells show output only after the command exits, which is too late.
|
|
111
|
+
const AGENT_APPROVAL_HELP = [
|
|
112
|
+
"From a coding agent: with --json --no-browser, a step that needs approval exits at once",
|
|
113
|
+
"with exit 18 action_required and the URL in data.next_action.url, while a background",
|
|
114
|
+
"process keeps waiting for the approval until --timeout-ms. Show the URL to the person,",
|
|
115
|
+
"and after they approve, run the same command again to continue.",
|
|
116
|
+
];
|
|
117
|
+
|
|
109
118
|
const CONFIG_DIR_OPTION = {
|
|
110
119
|
name: "--config-dir",
|
|
111
120
|
value: "DIR",
|
|
@@ -134,7 +143,7 @@ const LOGIN_OPTIONS = [
|
|
|
134
143
|
summary: `Time to wait for the browser, ${LOGIN_MIN_TIMEOUT_MS} to ${LOGIN_MAX_TIMEOUT_MS}. Default ${LOGIN_DEFAULT_TIMEOUT_MS}.`,
|
|
135
144
|
},
|
|
136
145
|
{ name: "--signup", value: null, summary: "Start at the hosted sign up page. Managed service only." },
|
|
137
|
-
{ name: "--no-browser", value: null, summary: "Print the sign in URL on stderr instead of opening a browser. With --json,
|
|
146
|
+
{ name: "--no-browser", value: null, summary: "Print the sign in URL on stderr instead of opening a browser. With --json, exit 18 with it in data.next_action (open_url)." },
|
|
138
147
|
{ name: "--reconnect", value: null, summary: "Allow switching a bound project to another origin or workspace." },
|
|
139
148
|
{ name: "--json", value: null, summary: "Print one JSON line on stdout." },
|
|
140
149
|
];
|
|
@@ -353,7 +362,7 @@ export function helpData(topic) {
|
|
|
353
362
|
{ name: "--timeout-ms", value: "N", summary: "Time to wait for browser approval." },
|
|
354
363
|
{ name: "--signup", value: null, summary: "Start at hosted sign up when the project needs login." },
|
|
355
364
|
{ name: "--reconnect", value: null, summary: "Permit switching an existing project binding." },
|
|
356
|
-
{ name: "--no-browser", value: null, summary: "Print approval URL on stderr. With --json,
|
|
365
|
+
{ name: "--no-browser", value: null, summary: "Print approval URL on stderr. With --json, exit 18 with it in data.next_action (open_url); rerun after approval." },
|
|
357
366
|
{ name: "--repair", value: null, summary: "Explicitly approve replacement of an acknowledged key that no longer verifies." },
|
|
358
367
|
{ name: "--deployment", value: "MODEL", summary: "managed, customer-local, byoc or oss. Default managed." },
|
|
359
368
|
{ name: "--confirm-prerequisites", value: null, summary: "Attest deployment prerequisites are met; it does not verify bundle publication." },
|
|
@@ -417,6 +426,7 @@ export function helpText(topic) {
|
|
|
417
426
|
const flag = option.value ? `${option.name} ${option.value}` : option.name;
|
|
418
427
|
lines.push(` ${flag.padEnd(25)}${option.summary}`);
|
|
419
428
|
}
|
|
429
|
+
lines.push("", ...AGENT_APPROVAL_HELP);
|
|
420
430
|
} else if (topic === "login" || topic === "logout") {
|
|
421
431
|
const login = topic === "login";
|
|
422
432
|
lines.push(`Usage: ${login ? LOGIN_USAGE : LOGOUT_USAGE}`, "");
|
|
@@ -449,6 +459,7 @@ export function helpText(topic) {
|
|
|
449
459
|
const flag = option.value ? `${option.name} ${option.value}` : option.name;
|
|
450
460
|
lines.push(` ${flag.padEnd(18)}${option.summary}`);
|
|
451
461
|
}
|
|
462
|
+
if (login) lines.push("", ...AGENT_APPROVAL_HELP);
|
|
452
463
|
} else if (topic === "verify") {
|
|
453
464
|
lines.push(`Usage: ${VERIFY_USAGE}`, "", "Uses the saved Metadata grant to poll for one exact, processed trace.",
|
|
454
465
|
"The source label is supplied by the caller. A Metadata match alone does not prove application traffic.",
|
|
@@ -719,9 +730,9 @@ const AUTH_MESSAGES = {
|
|
|
719
730
|
"so sign in cannot finish here. Follow the connection guide instead. Nothing was written.",
|
|
720
731
|
ci_environment:
|
|
721
732
|
"This is a CI environment, where no person can approve sign in in a browser. Nothing was written.",
|
|
722
|
-
|
|
723
|
-
"
|
|
724
|
-
"
|
|
733
|
+
browser_approval_required:
|
|
734
|
+
"A person must approve in a browser. Open data.next_action.url on this machine; a background process " +
|
|
735
|
+
"waits for the approval until the timeout. After approval, run the same command again to continue.",
|
|
725
736
|
bound_to_other_origin:
|
|
726
737
|
"This project is bound to a different origin. Nothing was changed. Use --reconnect to switch it.",
|
|
727
738
|
bound_to_other_workspace:
|
|
@@ -737,7 +748,7 @@ const AUTH_MESSAGES = {
|
|
|
737
748
|
timeout: "The operation did not finish within the time limit. Nothing was saved.",
|
|
738
749
|
cancelled: "Sign in was cancelled. Nothing was saved.",
|
|
739
750
|
browser_unavailable:
|
|
740
|
-
"The browser could not be opened. Run login again with --no-browser
|
|
751
|
+
"The browser could not be opened. Run login again with --no-browser and open the URL it prints.",
|
|
741
752
|
oauth_metadata_missing: "This service does not support CLI sign in yet. Nothing was written.",
|
|
742
753
|
metadata_scope_unsupported:
|
|
743
754
|
"This service does not offer Metadata-only access for the CLI yet. The CLI does not fall back to broader access.",
|
package/src/setup-deployment.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { detectRemoteSession } from "./auth-browser.js";
|
|
1
2
|
import { runDoctor } from "./doctor.js";
|
|
2
|
-
import { planDeploymentRoute, verifyDeploymentRoute } from "./deployment-route.js";
|
|
3
|
+
import { action, planDeploymentRoute, verifyDeploymentRoute } from "./deployment-route.js";
|
|
3
4
|
import { AUTH_HTTP_TIMEOUT_MS } from "./constants.js";
|
|
5
|
+
import { resolveProject } from "./auth-binding.js";
|
|
6
|
+
import { readSetupState } from "./setup-state.js";
|
|
4
7
|
|
|
5
8
|
// Setup routing uses the same deployment model and Metadata credential checks
|
|
6
9
|
// as the standalone routing contract. Operator prerequisites are assertions,
|
|
@@ -24,6 +27,55 @@ const PREREQUISITES = Object.freeze({
|
|
|
24
27
|
|
|
25
28
|
const EXPECTED_PROFILE = Object.freeze({ "customer-local": "local", byoc: "byoc-core", oss: "oss" });
|
|
26
29
|
|
|
30
|
+
const DEPLOYMENT_FOR_PROFILE = Object.freeze({ local: "customer-local", "byoc-core": "byoc" });
|
|
31
|
+
|
|
32
|
+
// Fixed guidance for each prerequisite, so a person or agent can act on a
|
|
33
|
+
// handoff without the docs. None of it names a credential value, and none of
|
|
34
|
+
// it asks for a credential to be passed to this CLI.
|
|
35
|
+
const PREREQUISITE_GUIDE = Object.freeze({
|
|
36
|
+
released_signed_bundle: "Download the released customer-local bundle and its signed manifest, and verify the signature before starting it. A bundle built from source is not a release.",
|
|
37
|
+
registry_invitation: "Accept the workspace's registry invitation so the bundle can pull its images. The registry pull credential is only for pulling images: never give it to this CLI or put it in the project env file.",
|
|
38
|
+
bundle_started_verified: "Start the bundle with bin/start and wait until bin/status reports it healthy. Pass its origin as --url and the workspace ID that bin/status shows as --workspace.",
|
|
39
|
+
local_admin_configured: "Sign in to the installation once as its local admin, in a browser on this machine. Setup asks that account to approve sign in and an ingest-only key.",
|
|
40
|
+
operator_provisioning: "The operator must already have provisioned the customer-owned AWS deployment. Setup connects to it and never provisions anything.",
|
|
41
|
+
private_network_reachability: "Run setup from a machine that reaches the deployment's private HTTPS origin, for example over the customer VPN. Setup never opens a tunnel or a public endpoint.",
|
|
42
|
+
identity_membership_configured: "The operator must already have given your identity membership of the workspace through the deployment's configured identity provider.",
|
|
43
|
+
server_distribution_installed: "The open source server must already be installed and running under the operator's management.",
|
|
44
|
+
deployment_discovery_supported: "The open source server must answer GET /v1/deployment with the oss profile. If it does not, ask the operator to upgrade it.",
|
|
45
|
+
metadata_scope_supported: "The open source server must advertise the agent:metadata scope in its OAuth metadata. If it does not, ask the operator to upgrade it.",
|
|
46
|
+
ingestion_tokens_configured: "The operator configures ingestion tokens in MG_TOKENS and gives the application one as METERGRAPH_APP_TOKEN. Setup never issues or writes open source ingest tokens.",
|
|
47
|
+
agent_read_tokens_configured: "The operator configures agent read tokens in MG_AGENT_TOKENS, separately from MG_TOKENS. Never reuse an ingestion token for agent reads.",
|
|
48
|
+
});
|
|
49
|
+
const METADATA_CREDENTIAL_GUIDE = Object.freeze({
|
|
50
|
+
local: "Setup obtains its own Metadata-only sign in through the browser. --agent-token-file is optional and must hold a Metadata-only agent token.",
|
|
51
|
+
"byoc-core": "Setup obtains its own Metadata-only sign in through the browser. --agent-token-file is optional and must hold a Metadata-only agent token.",
|
|
52
|
+
oss: "Put one MG_AGENT_TOKENS value in a private file (mode 0600) and pass its absolute path as --agent-token-file.",
|
|
53
|
+
});
|
|
54
|
+
const ACTION_GUIDE = Object.freeze({
|
|
55
|
+
complete_prerequisite: "Complete the named prerequisite, then rerun setup with --confirm-prerequisites.",
|
|
56
|
+
oss_operator_handoff: "Ask the open source server operator to complete this step. This CLI does not create open source credentials or change the server.",
|
|
57
|
+
run_on_customer_machine: "Run setup in a terminal on the machine that reaches the deployment. Setup does not tunnel or forward credentials from a remote or cloud session.",
|
|
58
|
+
use_https_origin: "Pass the deployment's HTTPS origin. A customer-owned AWS deployment is never reached over plain HTTP.",
|
|
59
|
+
connection_guide: "Check that --url is the installation's own origin and that it reports the expected deployment profile.",
|
|
60
|
+
check_private_network: "This machine cannot reach the private origin. Connect to the deployment's network, for example over the customer VPN, and rerun. Do not expose the deployment publicly.",
|
|
61
|
+
platform_credential_handoff: "This platform cannot prove the agent token file is private. Rerun without --agent-token-file, or on macOS or Linux.",
|
|
62
|
+
fix_credential_file: "Make --agent-token-file an absolute path to a regular file you own, with mode 0600, holding one token.",
|
|
63
|
+
use_metadata_only_credential: "The agent token was refused or has more than Metadata access. Use a Metadata-only agent token for this workspace.",
|
|
64
|
+
retry: "Rerun the same command.",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export function prerequisiteGuide(name, profile) {
|
|
68
|
+
if (name === "metadata_agent_credential") return METADATA_CREDENTIAL_GUIDE[profile] ?? null;
|
|
69
|
+
return PREREQUISITE_GUIDE[name] ?? null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function guided(nextAction, profile) {
|
|
73
|
+
if (nextAction === null) return null;
|
|
74
|
+
const message = nextAction.kind === "complete_prerequisite" && nextAction.prerequisite !== null
|
|
75
|
+
? prerequisiteGuide(nextAction.prerequisite, profile) : ACTION_GUIDE[nextAction.kind] ?? null;
|
|
76
|
+
return { ...nextAction, message };
|
|
77
|
+
}
|
|
78
|
+
|
|
27
79
|
const handoff = (outcome, reason, nextAction = null, details = {}) => ({
|
|
28
80
|
proceed: false,
|
|
29
81
|
outcome,
|
|
@@ -38,10 +90,21 @@ const handoff = (outcome, reason, nextAction = null, details = {}) => ({
|
|
|
38
90
|
pending_prerequisites: details.pendingPrerequisites ?? [],
|
|
39
91
|
metadata_access: details.metadataAccess ?? "not_checked",
|
|
40
92
|
ingest_credential: "not_checked",
|
|
41
|
-
next_action: nextAction,
|
|
93
|
+
next_action: guided(nextAction, details.profile ?? null),
|
|
42
94
|
},
|
|
43
95
|
});
|
|
44
96
|
|
|
97
|
+
// The next action when the service did not answer as a healthy deployment.
|
|
98
|
+
// An unreachable customer-local origin usually means the bundle is not
|
|
99
|
+
// running; an unreachable BYOC origin is private networking. Neither is
|
|
100
|
+
// tunnelled or relaxed.
|
|
101
|
+
function unreachedAction(deployment, outcome) {
|
|
102
|
+
if (deployment === "oss") return action("oss_operator_handoff");
|
|
103
|
+
if (outcome !== "connection_failed") return action("connection_guide");
|
|
104
|
+
if (deployment === "byoc") return action("check_private_network");
|
|
105
|
+
return action("complete_prerequisite", "bundle_started_verified");
|
|
106
|
+
}
|
|
107
|
+
|
|
45
108
|
// The static agent token can be any printable string of at least 16 bytes.
|
|
46
109
|
// Even a fixed status such as "operator_handoff" might equal that token. Keep
|
|
47
110
|
// the verifier's non-enumerable knownCredentials on every later result and
|
|
@@ -94,13 +157,21 @@ export function planNonHostedSetup(options) {
|
|
|
94
157
|
return { proceed: true, plan: planned.plan, prerequisites };
|
|
95
158
|
}
|
|
96
159
|
|
|
97
|
-
export async function preflightNonHostedSetup(options) {
|
|
160
|
+
export async function preflightNonHostedSetup(options, { env = process.env } = {}) {
|
|
98
161
|
const planned = planNonHostedSetup(options);
|
|
99
162
|
if (!planned.proceed) return planned;
|
|
100
163
|
const { plan, prerequisites } = planned;
|
|
164
|
+
// Setup itself refuses a remote session, but only after this preflight.
|
|
165
|
+
// Refuse here first, so a remote shell sends no request and never reads the
|
|
166
|
+
// separate agent token file.
|
|
167
|
+
if (detectRemoteSession(env) !== null) {
|
|
168
|
+
return handoff("unsupported", "run_on_local_machine", action("run_on_customer_machine"), {
|
|
169
|
+
profile: plan.deployment_profile, origin: plan.origin, workspaceId: plan.workspace_id,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
101
172
|
const timeoutMs = Math.min(options.timeoutMs, AUTH_HTTP_TIMEOUT_MS);
|
|
102
173
|
if (options.deployment === "oss" && !options.agentTokenFile) {
|
|
103
|
-
return handoff("unsupported", "oss_agent_token_file_required", "oss_operator_handoff", {
|
|
174
|
+
return handoff("unsupported", "oss_agent_token_file_required", action("oss_operator_handoff"), {
|
|
104
175
|
profile: plan.deployment_profile, origin: plan.origin, workspaceId: plan.workspace_id,
|
|
105
176
|
prerequisites: "operator_confirmed",
|
|
106
177
|
});
|
|
@@ -123,7 +194,7 @@ export async function preflightNonHostedSetup(options) {
|
|
|
123
194
|
// The OSS operator provisions MG_TOKENS and MG_AGENT_TOKENS separately.
|
|
124
195
|
// No released OSS server implements the purpose-bound ingest bootstrap,
|
|
125
196
|
// and a Metadata agent token cannot issue a key. Keep this a handoff.
|
|
126
|
-
return guardKnownCredentials(handoff("unsupported", "oss_ingest_operator_handoff", "oss_operator_handoff", {
|
|
197
|
+
return guardKnownCredentials(handoff("unsupported", "oss_ingest_operator_handoff", action("oss_operator_handoff"), {
|
|
127
198
|
profile: plan.deployment_profile, origin: plan.origin, workspaceId: plan.workspace_id,
|
|
128
199
|
prerequisites: "operator_confirmed", metadataAccess: "verified",
|
|
129
200
|
}), verified);
|
|
@@ -134,13 +205,13 @@ export async function preflightNonHostedSetup(options) {
|
|
|
134
205
|
// and unsafe network responses and reports only a fixed profile token.
|
|
135
206
|
const doctor = await runDoctor({ origin: plan.origin, timeoutMs });
|
|
136
207
|
if (doctor.report.deployment_profile !== null && doctor.report.deployment_profile !== plan.deployment_profile) {
|
|
137
|
-
return handoff("unsupported", "deployment_profile_mismatch", "connection_guide", {
|
|
208
|
+
return handoff("unsupported", "deployment_profile_mismatch", action("connection_guide"), {
|
|
138
209
|
profile: plan.deployment_profile, origin: plan.origin, workspaceId: plan.workspace_id,
|
|
139
210
|
prerequisites: "operator_confirmed",
|
|
140
211
|
});
|
|
141
212
|
}
|
|
142
213
|
if (doctor.outcome !== "authentication_required") {
|
|
143
|
-
return handoff(doctor.outcome, doctor.reason, options.deployment
|
|
214
|
+
return handoff(doctor.outcome, doctor.reason, unreachedAction(options.deployment, doctor.outcome), {
|
|
144
215
|
profile: plan.deployment_profile, origin: plan.origin, workspaceId: plan.workspace_id,
|
|
145
216
|
prerequisites: "operator_confirmed",
|
|
146
217
|
});
|
|
@@ -176,3 +247,28 @@ export async function preflightNonHostedSetup(options) {
|
|
|
176
247
|
metadataAccess: "pending_login",
|
|
177
248
|
};
|
|
178
249
|
}
|
|
250
|
+
|
|
251
|
+
// A rerun of non-hosted setup may omit the route it was first given, as a
|
|
252
|
+
// hosted rerun does. Only a saved setup state for a customer-local or BYOC
|
|
253
|
+
// route is resumed, and only to that same origin and workspace. The earlier
|
|
254
|
+
// run's operator confirmation stands for that route; every live check still
|
|
255
|
+
// runs again. Anything else is left for setup's own conflict checks.
|
|
256
|
+
export function resumeNonHostedRoute(options) {
|
|
257
|
+
let saved;
|
|
258
|
+
try {
|
|
259
|
+
saved = readSetupState(resolveProject(options.project));
|
|
260
|
+
} catch {
|
|
261
|
+
return options;
|
|
262
|
+
}
|
|
263
|
+
const deployment = DEPLOYMENT_FOR_PROFILE[saved?.value.deployment_profile];
|
|
264
|
+
if (deployment === undefined) return options;
|
|
265
|
+
const { origin, workspace_id: workspaceId } = saved.value;
|
|
266
|
+
const sameRoute = (!options.originExplicit || options.origin === origin) &&
|
|
267
|
+
(options.workspace === null || options.workspace === workspaceId);
|
|
268
|
+
if (!sameRoute) return options;
|
|
269
|
+
if (!options.deploymentExplicit) {
|
|
270
|
+
return { ...options, deployment, origin, originExplicit: true, workspace: workspaceId, confirmPrerequisites: true };
|
|
271
|
+
}
|
|
272
|
+
if (options.deployment === deployment && options.originExplicit) return { ...options, confirmPrerequisites: true };
|
|
273
|
+
return options;
|
|
274
|
+
}
|
package/src/setup.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
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
|
-
import { runLogin } from "./auth-login.js";
|
|
4
|
+
import { openUrl, runLogin } from "./auth-login.js";
|
|
5
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";
|
|
@@ -24,26 +24,26 @@ const PURPOSE = "ingest-bootstrap-v1";
|
|
|
24
24
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
25
25
|
const RECEIPT = /^mgbs_[A-Za-z0-9_-]{32,256}$/;
|
|
26
26
|
const KEY = (value) => typeof value === "string" && UUID.test(value);
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
});
|
|
33
|
-
const fail = (outcome, reason, status = "not_ready", receipt = null) => ({ outcome, reason,
|
|
34
|
-
data: { status, application_traffic_verified: false, receipt,
|
|
35
|
-
next_action: reason === "no_browser_requires_terminal" ? RUN_IN_TERMINAL : null } });
|
|
27
|
+
const fail = (outcome, reason, status = "not_ready", receipt = null, next = null) => ({ outcome, reason,
|
|
28
|
+
data: { status, application_traffic_verified: false, receipt, next_action: next } });
|
|
36
29
|
|
|
37
30
|
// This command never receives an ingest key from argv, stdin, an environment
|
|
38
31
|
// variable or a Metadata OAuth grant. Only the purpose-bound redemption
|
|
39
32
|
// response can supply it. Every returned string is fixed and non-secret.
|
|
33
|
+
//
|
|
34
|
+
// With --json --no-browser the caller passes options.announce(data). The
|
|
35
|
+
// first approval this run needs (sign in, or ingest-only setup approval) is
|
|
36
|
+
// announced as setup data with an open_url next action while the listener
|
|
37
|
+
// keeps waiting; stdout has room for one line, so a second approval in the
|
|
38
|
+
// same run is not started. The person approves, the run saves what it
|
|
39
|
+
// finished, and the agent reruns setup, which resumes from the saved state.
|
|
40
40
|
export async function runSetup(options, progress = () => {}) {
|
|
41
41
|
if (options.runtime !== "local" || detectRemoteSession() !== null) return fail("unsupported", "run_on_local_machine");
|
|
42
42
|
const trap = trapSignals();
|
|
43
43
|
let receipt = null;
|
|
44
44
|
let loginVerified = false;
|
|
45
45
|
let credentialVerified = false;
|
|
46
|
-
const
|
|
46
|
+
const currentReceipt = () => {
|
|
47
47
|
let current = receipt;
|
|
48
48
|
if (current !== null && (!loginVerified || !credentialVerified)) {
|
|
49
49
|
const completed = loginVerified ? ["login"] : [];
|
|
@@ -52,8 +52,14 @@ export async function runSetup(options, progress = () => {}) {
|
|
|
52
52
|
...(!loginVerified ? ["login"] : []), "credential", "skill", "instrument", "verify", "view",
|
|
53
53
|
] };
|
|
54
54
|
}
|
|
55
|
-
return
|
|
55
|
+
return current;
|
|
56
56
|
};
|
|
57
|
+
const stop = (outcome, reason, status = "not_ready") => fail(outcome, reason, status, currentReceipt());
|
|
58
|
+
let announced = false;
|
|
59
|
+
const announce = options.announce ? (status, next) => {
|
|
60
|
+
announced = true;
|
|
61
|
+
options.announce(fail("action_required", "browser_approval_required", status, currentReceipt(), next).data);
|
|
62
|
+
} : null;
|
|
57
63
|
try {
|
|
58
64
|
const root = resolveProject(options.project);
|
|
59
65
|
const saved = readSetupState(root);
|
|
@@ -67,6 +73,13 @@ export async function runSetup(options, progress = () => {}) {
|
|
|
67
73
|
(bound !== null && (bound.origin !== saved.value.origin || bound.workspace_id !== saved.value.workspace_id)))) {
|
|
68
74
|
return stop("conflict", "setup_binding_changed");
|
|
69
75
|
}
|
|
76
|
+
// An env key this setup does not own stops before any sign in, so the
|
|
77
|
+
// refusal costs no approval and leaves the binding untouched. The check
|
|
78
|
+
// after sign in stays, against a fresh read.
|
|
79
|
+
if (!saved) {
|
|
80
|
+
const early = currentEnvValues(preflightEnv({ project: root, envFile: options.envFile, signal: trap.signal }));
|
|
81
|
+
if (early.token !== null) return stop("conflict", "existing_ingest_key_unowned");
|
|
82
|
+
}
|
|
70
83
|
const bindingDiffers = bound !== null && (bound.origin !== intendedOrigin ||
|
|
71
84
|
(intendedWorkspace !== null && bound.workspace_id !== intendedWorkspace));
|
|
72
85
|
if (bindingDiffers && !options.reconnect) return stop("conflict", "bound_to_other_workspace");
|
|
@@ -74,7 +87,8 @@ export async function runSetup(options, progress = () => {}) {
|
|
|
74
87
|
await verifiedSession({ project: root, configDir: options.configDir, cancel: trap.signal });
|
|
75
88
|
if (!signedIn.ok && signedIn.outcome === "login_required") {
|
|
76
89
|
const login = await runLogin({ ...options, origin: intendedOrigin, workspace: intendedWorkspace,
|
|
77
|
-
project: root, reconnect: options.reconnect, expectedProfile: options.expectedProfile ?? null
|
|
90
|
+
project: root, reconnect: options.reconnect, expectedProfile: options.expectedProfile ?? null,
|
|
91
|
+
announce: announce && ((data) => announce("login_pending", data.next_action)) }, progress);
|
|
78
92
|
if (login.outcome !== "ok") return stop(login.outcome, login.reason, "login_pending");
|
|
79
93
|
signedIn = await verifiedSession({ project: root, configDir: options.configDir, cancel: trap.signal });
|
|
80
94
|
}
|
|
@@ -146,8 +160,9 @@ export async function runSetup(options, progress = () => {}) {
|
|
|
146
160
|
}
|
|
147
161
|
|
|
148
162
|
// A verified rerun returned above without approval. From here a person
|
|
149
|
-
// must open an approval URL
|
|
150
|
-
|
|
163
|
+
// must open an approval URL. A run that already announced its sign in URL
|
|
164
|
+
// has used its one JSON line, so it stops here and the rerun continues.
|
|
165
|
+
if (announced) return stop("action_required", "browser_approval_required", "approval_pending");
|
|
151
166
|
const setup = await discoverSetup(origin, profile, trap.signal);
|
|
152
167
|
if (!setup.ok) return stop(setup.outcome, setup.reason);
|
|
153
168
|
|
|
@@ -183,7 +198,9 @@ export async function runSetup(options, progress = () => {}) {
|
|
|
183
198
|
if (intent === "repair") params.expected_key_id = state.key_id;
|
|
184
199
|
url.search = new URLSearchParams(params).toString();
|
|
185
200
|
if (trap.signal.aborted) return stop("authorization_failed", "cancelled");
|
|
186
|
-
if (options.noBrowser
|
|
201
|
+
if (options.noBrowser && announce) {
|
|
202
|
+
announce("approval_pending", openUrl(url.href, Math.round(options.timeoutMs / 1000), "approve ingest-only setup"));
|
|
203
|
+
} else if (options.noBrowser) progress(`Open this URL on this machine to approve ingest-only setup:\n${url.href}`);
|
|
187
204
|
else if (!(await openBrowser(url.href))) return stop("authorization_failed", "browser_unavailable");
|
|
188
205
|
else progress("Opened your browser for ingest-only setup approval.");
|
|
189
206
|
callback = await pending;
|
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 = "e7fc4eb7a22eeb16c357bede9d0a080ad3b415aaab1d9c72f164d5350d48c4b1";
|
|
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"];
|
|
@@ -55,7 +55,7 @@ export function loadBundledSkill() {
|
|
|
55
55
|
// The workflow skills that "skills install" writes, vendored from the public
|
|
56
56
|
// skills repository at one commit. The manifest's own hash is pinned here, and
|
|
57
57
|
// the manifest pins each skill's hash, so changing any file alone is detected.
|
|
58
|
-
const PINNED_PACK_SHA256 = "
|
|
58
|
+
const PINNED_PACK_SHA256 = "f08a188abba2018770dc3ba071509ff22f3af76ebc5c30a9f3a2ec2b2eae75b5";
|
|
59
59
|
|
|
60
60
|
const PACK_DIR = new URL("../assets/skills/", import.meta.url);
|
|
61
61
|
const PACK_KEYS = ["commit", "manifest_version", "repository", "skills"];
|