metergraph-cli 0.1.0 → 0.2.0-preview.1
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 +500 -33
- package/assets/skill/SKILL.md +17 -1
- package/assets/skill/manifest.json +3 -3
- package/package.json +2 -2
- package/src/args.js +323 -17
- package/src/auth-binding.js +202 -0
- package/src/auth-browser.js +74 -0
- package/src/auth-callback.js +177 -0
- package/src/auth-login.js +410 -0
- package/src/auth-oauth.js +462 -0
- package/src/auth-session.js +174 -0
- package/src/auth-store.js +397 -0
- package/src/cli.js +78 -2
- package/src/constants.js +99 -4
- package/src/deployment-credential.js +207 -0
- package/src/deployment-route.js +449 -0
- package/src/doctor.js +10 -0
- package/src/http.js +1 -1
- package/src/output.js +454 -2
- package/src/read-contract.js +609 -0
- package/src/read-output.js +216 -0
- package/src/read.js +325 -0
- package/src/setup-deployment.js +178 -0
- package/src/setup-env-acl.js +99 -0
- package/src/setup-env-git.js +103 -0
- package/src/setup-env-parse.js +169 -0
- package/src/setup-env.js +685 -0
- package/src/setup-state.js +141 -0
- package/src/setup.js +326 -0
- package/src/skill-bundle.js +1 -1
- package/src/trace-contract.js +129 -0
- package/src/trace-open.js +36 -0
- package/src/transport.js +171 -0
- package/src/verify-output.js +36 -0
- package/src/verify.js +120 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { runDoctor } from "./doctor.js";
|
|
2
|
+
import { planDeploymentRoute, verifyDeploymentRoute } from "./deployment-route.js";
|
|
3
|
+
import { AUTH_HTTP_TIMEOUT_MS } from "./constants.js";
|
|
4
|
+
|
|
5
|
+
// Setup routing uses the same deployment model and Metadata credential checks
|
|
6
|
+
// as the standalone routing contract. Operator prerequisites are assertions,
|
|
7
|
+
// not evidence of a released bundle or provisioning. The CLI still verifies
|
|
8
|
+
// the live origin/profile and (when supplied) a separate Metadata credential.
|
|
9
|
+
const PREREQUISITES = Object.freeze({
|
|
10
|
+
"customer-local": Object.freeze([
|
|
11
|
+
"released_signed_bundle", "registry_invitation", "bundle_started_verified",
|
|
12
|
+
"local_admin_configured", "metadata_agent_credential",
|
|
13
|
+
]),
|
|
14
|
+
byoc: Object.freeze([
|
|
15
|
+
"operator_provisioning", "private_network_reachability",
|
|
16
|
+
"identity_membership_configured", "metadata_agent_credential",
|
|
17
|
+
]),
|
|
18
|
+
oss: Object.freeze([
|
|
19
|
+
"server_distribution_installed", "deployment_discovery_supported",
|
|
20
|
+
"metadata_scope_supported", "ingestion_tokens_configured",
|
|
21
|
+
"agent_read_tokens_configured", "metadata_agent_credential",
|
|
22
|
+
]),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const EXPECTED_PROFILE = Object.freeze({ "customer-local": "local", byoc: "byoc-core", oss: "oss" });
|
|
26
|
+
|
|
27
|
+
const handoff = (outcome, reason, nextAction = null, details = {}) => ({
|
|
28
|
+
proceed: false,
|
|
29
|
+
outcome,
|
|
30
|
+
reason,
|
|
31
|
+
data: {
|
|
32
|
+
status: "operator_handoff",
|
|
33
|
+
application_traffic_verified: false,
|
|
34
|
+
deployment_profile: details.profile ?? null,
|
|
35
|
+
origin: details.origin ?? null,
|
|
36
|
+
workspace_id: details.workspaceId ?? null,
|
|
37
|
+
prerequisites: details.prerequisites ?? "not_confirmed",
|
|
38
|
+
pending_prerequisites: details.pendingPrerequisites ?? [],
|
|
39
|
+
metadata_access: details.metadataAccess ?? "not_checked",
|
|
40
|
+
ingest_credential: "not_checked",
|
|
41
|
+
next_action: nextAction,
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// The static agent token can be any printable string of at least 16 bytes.
|
|
46
|
+
// Even a fixed status such as "operator_handoff" might equal that token. Keep
|
|
47
|
+
// the verifier's non-enumerable knownCredentials on every later result and
|
|
48
|
+
// drop all contextual fields if a coincidental value would echo it.
|
|
49
|
+
function guardKnownCredentials(value, verified) {
|
|
50
|
+
const known = verified.knownCredentials;
|
|
51
|
+
if (!known) return value;
|
|
52
|
+
const safe = known.some((token) => holdsKnown(value, token))
|
|
53
|
+
? { proceed: false, outcome: "unsupported", reason: "credential_echo", data: null }
|
|
54
|
+
: value;
|
|
55
|
+
Object.defineProperty(safe, "knownCredentials", { value: known, enumerable: false });
|
|
56
|
+
return safe;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function containsKnownCredential(value, routed) {
|
|
60
|
+
return routed?.knownCredentials?.some((token) => holdsKnown(value, token)) ?? false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function holdsKnown(value, token) {
|
|
64
|
+
if (typeof value === "string") return value.includes(token);
|
|
65
|
+
if (value === null || typeof value !== "object") return false;
|
|
66
|
+
return Object.entries(value).some(([key, entry]) => holdsKnown(key, token) || holdsKnown(entry, token));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function planNonHostedSetup(options) {
|
|
70
|
+
if (!Object.hasOwn(PREREQUISITES, options.deployment)) {
|
|
71
|
+
return handoff("invalid_input", "deployment_invalid");
|
|
72
|
+
}
|
|
73
|
+
if (options.signup) return handoff("unsupported", "signup_hosted_only");
|
|
74
|
+
if (!options.originExplicit) return handoff("invalid_input", "non_hosted_origin_required");
|
|
75
|
+
if (options.workspace === null) return handoff("invalid_input", "non_hosted_workspace_required");
|
|
76
|
+
|
|
77
|
+
const prerequisites = Object.fromEntries(PREREQUISITES[options.deployment].map((name) => [
|
|
78
|
+
name, options.confirmPrerequisites ? "ready" : "unknown",
|
|
79
|
+
]));
|
|
80
|
+
const planned = planDeploymentRoute({
|
|
81
|
+
model: options.deployment,
|
|
82
|
+
runtime: options.runtime,
|
|
83
|
+
origin: options.origin,
|
|
84
|
+
workspaceId: options.workspace,
|
|
85
|
+
prerequisites,
|
|
86
|
+
});
|
|
87
|
+
if (!planned.ok) {
|
|
88
|
+
return handoff(planned.outcome, planned.reason, planned.plan?.next_action ?? null, {
|
|
89
|
+
profile: EXPECTED_PROFILE[options.deployment], origin: planned.plan?.origin,
|
|
90
|
+
workspaceId: planned.plan?.workspace_id,
|
|
91
|
+
pendingPrerequisites: planned.plan?.prerequisites.filter((item) => item.status !== "ready").map((item) => item.name),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return { proceed: true, plan: planned.plan, prerequisites };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function preflightNonHostedSetup(options) {
|
|
98
|
+
const planned = planNonHostedSetup(options);
|
|
99
|
+
if (!planned.proceed) return planned;
|
|
100
|
+
const { plan, prerequisites } = planned;
|
|
101
|
+
const timeoutMs = Math.min(options.timeoutMs, AUTH_HTTP_TIMEOUT_MS);
|
|
102
|
+
if (options.deployment === "oss" && !options.agentTokenFile) {
|
|
103
|
+
return handoff("unsupported", "oss_agent_token_file_required", "oss_operator_handoff", {
|
|
104
|
+
profile: plan.deployment_profile, origin: plan.origin, workspaceId: plan.workspace_id,
|
|
105
|
+
prerequisites: "operator_confirmed",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (options.deployment === "oss") {
|
|
110
|
+
// The OSS verifier knows the source-only `oss` discovery profile. Doctor
|
|
111
|
+
// intentionally does not accept that profile for hosted login.
|
|
112
|
+
const verified = await verifyDeploymentRoute({
|
|
113
|
+
model: options.deployment, runtime: options.runtime, origin: plan.origin,
|
|
114
|
+
workspaceId: plan.workspace_id, prerequisites,
|
|
115
|
+
credentialFile: options.agentTokenFile, timeoutMs,
|
|
116
|
+
});
|
|
117
|
+
if (!verified.ok) {
|
|
118
|
+
return guardKnownCredentials(handoff(verified.outcome, verified.reason, verified.plan?.next_action ?? null, {
|
|
119
|
+
profile: plan.deployment_profile, origin: plan.origin, workspaceId: plan.workspace_id,
|
|
120
|
+
prerequisites: "operator_confirmed",
|
|
121
|
+
}), verified);
|
|
122
|
+
}
|
|
123
|
+
// The OSS operator provisions MG_TOKENS and MG_AGENT_TOKENS separately.
|
|
124
|
+
// No released OSS server implements the purpose-bound ingest bootstrap,
|
|
125
|
+
// 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", {
|
|
127
|
+
profile: plan.deployment_profile, origin: plan.origin, workspaceId: plan.workspace_id,
|
|
128
|
+
prerequisites: "operator_confirmed", metadataAccess: "verified",
|
|
129
|
+
}), verified);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Every non-hosted route proves the service profile before any login, local
|
|
133
|
+
// binding, credential read or ingest-key write. runDoctor rejects redirects
|
|
134
|
+
// and unsafe network responses and reports only a fixed profile token.
|
|
135
|
+
const doctor = await runDoctor({ origin: plan.origin, timeoutMs });
|
|
136
|
+
if (doctor.report.deployment_profile !== null && doctor.report.deployment_profile !== plan.deployment_profile) {
|
|
137
|
+
return handoff("unsupported", "deployment_profile_mismatch", "connection_guide", {
|
|
138
|
+
profile: plan.deployment_profile, origin: plan.origin, workspaceId: plan.workspace_id,
|
|
139
|
+
prerequisites: "operator_confirmed",
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
if (doctor.outcome !== "authentication_required") {
|
|
143
|
+
return handoff(doctor.outcome, doctor.reason, options.deployment === "oss" ? "oss_operator_handoff" : "connection_guide", {
|
|
144
|
+
profile: plan.deployment_profile, origin: plan.origin, workspaceId: plan.workspace_id,
|
|
145
|
+
prerequisites: "operator_confirmed",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// A separate agent token file is optional for local/BYOC, where setup will
|
|
150
|
+
// obtain its own Metadata OAuth grant. OSS has no scoped ingest bootstrap
|
|
151
|
+
// contract, so its separate MG_AGENT_TOKENS credential must be checked here.
|
|
152
|
+
if (options.agentTokenFile) {
|
|
153
|
+
const verified = await verifyDeploymentRoute({
|
|
154
|
+
model: options.deployment, runtime: options.runtime, origin: plan.origin,
|
|
155
|
+
workspaceId: plan.workspace_id, prerequisites,
|
|
156
|
+
credentialFile: options.agentTokenFile, timeoutMs,
|
|
157
|
+
});
|
|
158
|
+
if (!verified.ok) {
|
|
159
|
+
return guardKnownCredentials(handoff(verified.outcome, verified.reason, verified.plan?.next_action ?? null, {
|
|
160
|
+
profile: plan.deployment_profile, origin: plan.origin, workspaceId: plan.workspace_id,
|
|
161
|
+
prerequisites: "operator_confirmed",
|
|
162
|
+
}), verified);
|
|
163
|
+
}
|
|
164
|
+
return guardKnownCredentials({
|
|
165
|
+
proceed: true, profile: plan.deployment_profile, origin: plan.origin,
|
|
166
|
+
workspaceId: plan.workspace_id, prerequisites: "operator_confirmed",
|
|
167
|
+
metadataAccess: "verified",
|
|
168
|
+
}, verified);
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
proceed: true,
|
|
172
|
+
profile: plan.deployment_profile,
|
|
173
|
+
origin: plan.origin,
|
|
174
|
+
workspaceId: plan.workspace_id,
|
|
175
|
+
prerequisites: "operator_confirmed",
|
|
176
|
+
metadataAccess: "pending_login",
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { systemRoot } from "./auth-browser.js";
|
|
5
|
+
import { Stop } from "./auth-store.js";
|
|
6
|
+
|
|
7
|
+
// Windows access control for a plaintext env file. File modes do not protect
|
|
8
|
+
// anything on Windows, so a private file gets a protected DACL (no inherited
|
|
9
|
+
// entries) that allows only the current user, SYSTEM and the local
|
|
10
|
+
// Administrators group. A fixed Windows PowerShell script does the work; the
|
|
11
|
+
// file path travels base64 encoded on stdin, never on the command line, and
|
|
12
|
+
// nothing from the file's content is ever passed. Any failure is reported as
|
|
13
|
+
// protection_failed, so a file is never called private without proof.
|
|
14
|
+
|
|
15
|
+
const SCRIPT = [
|
|
16
|
+
"$ErrorActionPreference = 'Stop'",
|
|
17
|
+
"$ProgressPreference = 'SilentlyContinue'",
|
|
18
|
+
"$mode = [Console]::In.ReadLine()",
|
|
19
|
+
"$path = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String([Console]::In.ReadLine()))",
|
|
20
|
+
"$arg = [Console]::In.ReadLine()",
|
|
21
|
+
"$me = [Security.Principal.WindowsIdentity]::GetCurrent().User",
|
|
22
|
+
"$system = New-Object Security.Principal.SecurityIdentifier('S-1-5-18')",
|
|
23
|
+
"$admins = New-Object Security.Principal.SecurityIdentifier('S-1-5-32-544')",
|
|
24
|
+
"$allowed = @($me.Value, $system.Value, $admins.Value)",
|
|
25
|
+
"$full = [Security.AccessControl.FileSystemRights]::FullControl",
|
|
26
|
+
"function Test-Private {",
|
|
27
|
+
" $acl = [IO.File]::GetAccessControl($path)",
|
|
28
|
+
" if (-not $acl.AreAccessRulesProtected) { return $false }",
|
|
29
|
+
" if ($allowed -notcontains $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value) { return $false }",
|
|
30
|
+
" $mine = $false",
|
|
31
|
+
" foreach ($rule in $acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) {",
|
|
32
|
+
" if ($rule.AccessControlType -ne 'Allow') { continue }",
|
|
33
|
+
" if ($allowed -notcontains $rule.IdentityReference.Value) { return $false }",
|
|
34
|
+
" if ($rule.IdentityReference.Value -eq $me.Value -and ($rule.FileSystemRights -band $full) -eq $full) { $mine = $true }",
|
|
35
|
+
" }",
|
|
36
|
+
" return $mine",
|
|
37
|
+
"}",
|
|
38
|
+
"if ($mode -eq 'protect') {",
|
|
39
|
+
" $acl = New-Object Security.AccessControl.FileSecurity",
|
|
40
|
+
" $acl.SetAccessRuleProtection($true, $false)",
|
|
41
|
+
" foreach ($sid in @($me, $system, $admins)) {",
|
|
42
|
+
" $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($sid, $full, 'Allow')))",
|
|
43
|
+
" }",
|
|
44
|
+
" [IO.File]::SetAccessControl($path, $acl)",
|
|
45
|
+
" if (Test-Private) { [Console]::Out.Write('private') } else { exit 3 }",
|
|
46
|
+
"} elseif ($mode -eq 'status') {",
|
|
47
|
+
" if (Test-Private) { [Console]::Out.Write('private') } else { [Console]::Out.Write('open') }",
|
|
48
|
+
"} elseif ($mode -eq 'get') {",
|
|
49
|
+
" $sddl = [IO.File]::GetAccessControl($path).GetSecurityDescriptorSddlForm('Access')",
|
|
50
|
+
" [Console]::Out.Write([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($sddl)))",
|
|
51
|
+
"} elseif ($mode -eq 'set') {",
|
|
52
|
+
" $acl = New-Object Security.AccessControl.FileSecurity",
|
|
53
|
+
" $acl.SetSecurityDescriptorSddlForm([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($arg)), 'Access')",
|
|
54
|
+
" [IO.File]::SetAccessControl($path, $acl)",
|
|
55
|
+
" [Console]::Out.Write('ok')",
|
|
56
|
+
"} else { exit 2 }",
|
|
57
|
+
].join("\n");
|
|
58
|
+
const COMMAND = Buffer.from(SCRIPT, "utf16le").toString("base64");
|
|
59
|
+
const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
60
|
+
|
|
61
|
+
function run(mode, file, arg = "") {
|
|
62
|
+
const powershell = path.win32.join(systemRoot(), "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
63
|
+
const result = spawnSync(powershell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", COMMAND], {
|
|
64
|
+
input: `${mode}\n${Buffer.from(file, "utf8").toString("base64")}\n${arg}\n`,
|
|
65
|
+
encoding: "utf8",
|
|
66
|
+
shell: false,
|
|
67
|
+
windowsHide: true,
|
|
68
|
+
timeout: 30000,
|
|
69
|
+
maxBuffer: 1024 * 1024,
|
|
70
|
+
});
|
|
71
|
+
const out = typeof result.stdout === "string" ? result.stdout.trim() : "";
|
|
72
|
+
if (result.error || result.status !== 0) throw new Stop("filesystem_error", "protection_failed");
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Replaces the file's DACL with the private one and verifies it.
|
|
77
|
+
export function protectFile(file) {
|
|
78
|
+
if (run("protect", file) !== "private") throw new Stop("filesystem_error", "protection_failed");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Returns "private" or "open".
|
|
82
|
+
export function aclStatus(file) {
|
|
83
|
+
const out = run("status", file);
|
|
84
|
+
if (out !== "private" && out !== "open") throw new Stop("filesystem_error", "protection_failed");
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The file's DACL as opaque base64 SDDL, for an exact restore on rollback.
|
|
89
|
+
export function saveAcl(file) {
|
|
90
|
+
const out = run("get", file);
|
|
91
|
+
if (!BASE64.test(out)) throw new Stop("filesystem_error", "protection_failed");
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function restoreAcl(file, saved) {
|
|
96
|
+
if (!BASE64.test(saved) || run("set", file, saved) !== "ok") {
|
|
97
|
+
throw new Stop("filesystem_error", "protection_failed");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { Stop } from "./auth-store.js";
|
|
6
|
+
|
|
7
|
+
// Read-only Git checks for the env file. git runs without a shell, from the
|
|
8
|
+
// project directory, with a fixed argument list holding only a validated
|
|
9
|
+
// project-relative path. It never receives a value from the env file, never
|
|
10
|
+
// writes the index (optional locks are off) and never changes any config.
|
|
11
|
+
|
|
12
|
+
// Kept from the caller's environment. Every other GIT_* variable is dropped
|
|
13
|
+
// so nothing can point git at a different repository, index or work tree.
|
|
14
|
+
const KEPT = new Set([
|
|
15
|
+
"GIT_CONFIG_GLOBAL",
|
|
16
|
+
"GIT_CONFIG_SYSTEM",
|
|
17
|
+
"GIT_CONFIG_NOSYSTEM",
|
|
18
|
+
"GIT_CEILING_DIRECTORIES",
|
|
19
|
+
"GIT_DISCOVERY_ACROSS_FILESYSTEM",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
function gitEnv() {
|
|
23
|
+
const env = {};
|
|
24
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
25
|
+
const upper = key.toUpperCase();
|
|
26
|
+
if (!upper.startsWith("GIT_") || KEPT.has(upper)) env[key] = value;
|
|
27
|
+
}
|
|
28
|
+
env.GIT_TERMINAL_PROMPT = "0";
|
|
29
|
+
env.GIT_OPTIONAL_LOCKS = "0";
|
|
30
|
+
return env;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function git(root, args) {
|
|
34
|
+
const result = spawnSync("git", ["-c", "core.fsmonitor=false", ...args], {
|
|
35
|
+
cwd: root,
|
|
36
|
+
env: gitEnv(),
|
|
37
|
+
shell: false,
|
|
38
|
+
windowsHide: true,
|
|
39
|
+
timeout: 15000,
|
|
40
|
+
maxBuffer: 1024 * 1024,
|
|
41
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
42
|
+
});
|
|
43
|
+
return { status: result.error ? null : result.status, missing: result.error?.code === "ENOENT", stdout: result.stdout };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// True when root or any directory above it has a .git entry. Used when git
|
|
47
|
+
// itself cannot answer, so a repository is never mistaken for none.
|
|
48
|
+
function gitAbove(root) {
|
|
49
|
+
for (let dir = root; ; dir = path.dirname(dir)) {
|
|
50
|
+
try {
|
|
51
|
+
fs.lstatSync(path.join(dir, ".git"));
|
|
52
|
+
return true;
|
|
53
|
+
} catch {
|
|
54
|
+
// Keep looking.
|
|
55
|
+
}
|
|
56
|
+
if (path.dirname(dir) === dir) return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Returns { repository: false } outside any repository, or
|
|
61
|
+
// { repository: true, ignored } after checking that rel is not tracked.
|
|
62
|
+
export function inspectGit(root, rel) {
|
|
63
|
+
const inside = git(root, ["rev-parse", "--is-inside-work-tree"]);
|
|
64
|
+
if (inside.status !== 0) {
|
|
65
|
+
if (!gitAbove(root)) return { repository: false };
|
|
66
|
+
throw new Stop("conflict", inside.missing ? "git_unavailable" : "git_check_failed");
|
|
67
|
+
}
|
|
68
|
+
if (inside.stdout.toString("utf8").trim() !== "true") throw new Stop("conflict", "git_check_failed");
|
|
69
|
+
return { repository: true, ...pathState(root, rel) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A tracked env file is refused outright: untracking or resetting it is the
|
|
73
|
+
// person's decision, never this CLI's. Ignore rules are checked with
|
|
74
|
+
// --no-index, so a tracked file cannot hide behind a matching rule.
|
|
75
|
+
export function pathState(root, rel) {
|
|
76
|
+
const tracked = git(root, ["--literal-pathspecs", "ls-files", "-z", "--cached", "--", rel]);
|
|
77
|
+
if (tracked.status !== 0) throw new Stop("conflict", "git_check_failed");
|
|
78
|
+
if (tracked.stdout.length > 0) throw new Stop("conflict", "env_tracked");
|
|
79
|
+
const ignored = git(root, ["check-ignore", "-q", "--no-index", "--", rel]);
|
|
80
|
+
if (ignored.status !== 0 && ignored.status !== 1) throw new Stop("conflict", "git_check_failed");
|
|
81
|
+
return { ignored: ignored.status === 0 };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The anchored, literal .gitignore rule for rel. Characters with a meaning
|
|
85
|
+
// in ignore patterns are escaped. Paths are validated before they get here,
|
|
86
|
+
// so they hold no backslash, wildcard, control character or trailing space.
|
|
87
|
+
export function ignoreRule(rel) {
|
|
88
|
+
return `/${rel.replace(/[[\]!#]/g, "\\$&")}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// True when content has rule as a line and no negation follows it.
|
|
92
|
+
export function hasRule(content, rule) {
|
|
93
|
+
const lines = content.toString("utf8").split(/\r?\n/);
|
|
94
|
+
const at = lines.lastIndexOf(rule);
|
|
95
|
+
return at >= 0 && !lines.slice(at + 1).some((line) => line.trimStart().startsWith("!"));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function appendRule(content, rule) {
|
|
99
|
+
const text = content.toString("utf8");
|
|
100
|
+
const eol = text.includes("\r\n") && !/(^|[^\r])\n/.test(text) ? "\r\n" : "\n";
|
|
101
|
+
const lead = content.length > 0 && !text.endsWith("\n") ? eol : "";
|
|
102
|
+
return Buffer.concat([content, Buffer.from(`${lead}${rule}${eol}`, "utf8")]);
|
|
103
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { Stop } from "./auth-store.js";
|
|
2
|
+
import { parseOrigin } from "./origin.js";
|
|
3
|
+
|
|
4
|
+
// Reads and edits dotenv files as text. Nothing is evaluated: no
|
|
5
|
+
// interpolation, command substitution or escape processing, and no file is
|
|
6
|
+
// ever sourced. Only the two Metergraph assignments are ever interpreted or
|
|
7
|
+
// changed; every other byte is kept exactly. Anything this parser cannot read
|
|
8
|
+
// without guessing stops with env_syntax_ambiguous.
|
|
9
|
+
|
|
10
|
+
export const ENV_NAMES = Object.freeze({
|
|
11
|
+
token: "METERGRAPH_APP_TOKEN",
|
|
12
|
+
ingestUrl: "METERGRAPH_INGEST_URL",
|
|
13
|
+
});
|
|
14
|
+
const NAMES = Object.values(ENV_NAMES);
|
|
15
|
+
|
|
16
|
+
// The SDK expects a service root and appends /v1/ingest itself. Accept the
|
|
17
|
+
// endpoint form only while reading setup files written by the first preview.
|
|
18
|
+
export const INGEST_PATHS = Object.freeze(["", "/v1/ingest"]);
|
|
19
|
+
|
|
20
|
+
// Server-generated application tokens are RFC 3986 unreserved characters
|
|
21
|
+
// only, so a value can never carry a quote, newline, comment or space.
|
|
22
|
+
const TOKEN = /^[A-Za-z0-9._~-]{16,1024}$/;
|
|
23
|
+
const URL_TEXT = /^(https?:\/\/[^/?#@\\]+)(\/[A-Za-z0-9/_.-]*)?$/i;
|
|
24
|
+
const SAFE_URL = /^[A-Za-z0-9.:/[\]_-]+$/;
|
|
25
|
+
|
|
26
|
+
const ASSIGNMENT = /^([ \t]*)(export[ \t]+)?([A-Za-z_][A-Za-z0-9_.-]*)([ \t]*=[ \t]*)(.*)$/;
|
|
27
|
+
const DOUBLE = /^"([^"\\$`]*)"([ \t]*(?:#.*)?)$/;
|
|
28
|
+
const SINGLE = /^'([^'\\$`]*)'([ \t]*(?:#.*)?)$/;
|
|
29
|
+
const BARE = /^([^\s#"'`$\\]*)((?:[ \t]+#.*)?[ \t]*)$/;
|
|
30
|
+
const BLANK_OR_COMMENT = /^[ \t]*(?:#.*)?$/;
|
|
31
|
+
const DECODER = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
32
|
+
const BOM = 0xfeff;
|
|
33
|
+
|
|
34
|
+
export function isAppToken(value) {
|
|
35
|
+
return typeof value === "string" && TOKEN.test(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Returns the normalized service root or legacy ingest endpoint, or null.
|
|
39
|
+
// The caller must not echo the raw input when this returns null.
|
|
40
|
+
export function parseIngestUrl(raw) {
|
|
41
|
+
if (typeof raw !== "string" || raw.length > 2048) return null;
|
|
42
|
+
const match = URL_TEXT.exec(raw);
|
|
43
|
+
if (match === null || !INGEST_PATHS.includes(match[2] ?? "")) return null;
|
|
44
|
+
const origin = parseOrigin(match[1]);
|
|
45
|
+
if (origin === null) return null;
|
|
46
|
+
const url = `${origin}${match[2] ?? ""}`;
|
|
47
|
+
return SAFE_URL.test(url) ? url : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function ambiguous() {
|
|
51
|
+
return new Stop("conflict", "env_syntax_ambiguous");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function mentions(text) {
|
|
55
|
+
const upper = text.toUpperCase();
|
|
56
|
+
return NAMES.some((name) => upper.includes(name));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Returns the text after the closing quote, or null when the quote does not
|
|
60
|
+
// close on this line. Only double quotes treat a backslash as an escape.
|
|
61
|
+
function afterQuote(text, quote) {
|
|
62
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
63
|
+
if (quote === '"' && text[i] === "\\") i += 1;
|
|
64
|
+
else if (text[i] === quote) return text.slice(i + 1);
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function metergraphValue(rest) {
|
|
70
|
+
for (const [pattern, quote] of [[DOUBLE, '"'], [SINGLE, "'"], [BARE, ""]]) {
|
|
71
|
+
const match = pattern.exec(rest);
|
|
72
|
+
if (match !== null) return { quote, value: match[1], trailer: match[2] };
|
|
73
|
+
}
|
|
74
|
+
throw ambiguous();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Returns { bom, eol, lines, entries } where lines keeps each line's text and
|
|
78
|
+
// its own terminator, and entries maps a Metergraph name to its one
|
|
79
|
+
// assignment. content is a Buffer of the whole file.
|
|
80
|
+
export function parseEnv(content) {
|
|
81
|
+
if (content.includes(0)) throw new Stop("conflict", "env_file_invalid");
|
|
82
|
+
let text;
|
|
83
|
+
try {
|
|
84
|
+
text = DECODER.decode(content);
|
|
85
|
+
} catch {
|
|
86
|
+
throw new Stop("conflict", "env_file_invalid");
|
|
87
|
+
}
|
|
88
|
+
const bom = text.charCodeAt(0) === BOM;
|
|
89
|
+
if (bom) text = text.slice(1);
|
|
90
|
+
// A lone carriage return ends a line for some parsers and not others.
|
|
91
|
+
if (/\r(?!\n)/.test(text)) throw ambiguous();
|
|
92
|
+
|
|
93
|
+
const pieces = text.split(/(\r?\n)/);
|
|
94
|
+
const lines = [];
|
|
95
|
+
for (let i = 0; i < pieces.length; i += 2) lines.push({ text: pieces[i], eol: pieces[i + 1] ?? "" });
|
|
96
|
+
if (lines.at(-1).text === "" && lines.at(-1).eol === "") lines.pop();
|
|
97
|
+
|
|
98
|
+
const entries = {};
|
|
99
|
+
let open = null;
|
|
100
|
+
for (const [index, line] of lines.entries()) {
|
|
101
|
+
if (open !== null) {
|
|
102
|
+
// Inside another variable's multiline quoted value.
|
|
103
|
+
if (mentions(line.text)) throw ambiguous();
|
|
104
|
+
const rest = afterQuote(line.text, open);
|
|
105
|
+
if (rest === null) continue;
|
|
106
|
+
if (!BLANK_OR_COMMENT.test(rest)) throw ambiguous();
|
|
107
|
+
open = null;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (BLANK_OR_COMMENT.test(line.text)) continue;
|
|
111
|
+
const match = ASSIGNMENT.exec(line.text);
|
|
112
|
+
if (match === null) {
|
|
113
|
+
// Kept as is unless it could hide a Metergraph value or open a quote.
|
|
114
|
+
if (mentions(line.text) || /["'`]/.test(line.text)) throw ambiguous();
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const [, lead, exported = "", key, separator, rest] = match;
|
|
118
|
+
if (NAMES.includes(key)) {
|
|
119
|
+
if (Object.hasOwn(entries, key)) throw new Stop("conflict", "env_duplicate_assignment");
|
|
120
|
+
entries[key] = { index, lead, exported, separator, ...metergraphValue(rest) };
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (NAMES.includes(key.toUpperCase())) throw ambiguous();
|
|
124
|
+
const quote = rest[0];
|
|
125
|
+
if (quote === '"' || quote === "'" || quote === "`") {
|
|
126
|
+
const after = afterQuote(rest.slice(1), quote);
|
|
127
|
+
if (after === null) open = quote;
|
|
128
|
+
else if (!BLANK_OR_COMMENT.test(after)) throw ambiguous();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (open !== null) throw ambiguous();
|
|
132
|
+
|
|
133
|
+
const endings = new Set(lines.map((line) => line.eol).filter((eol) => eol !== ""));
|
|
134
|
+
const eol = endings.size === 1 && endings.has("\r\n") ? "\r\n" : "\n";
|
|
135
|
+
return { bom, eol, lines, entries };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Returns the file content with each name in updates set to its value. A
|
|
139
|
+
// line whose value already matches is left exactly as it was. A changed line
|
|
140
|
+
// keeps its indentation, export keyword, quote style and trailing comment.
|
|
141
|
+
// Missing names are appended, one line each, with the file's line ending.
|
|
142
|
+
export function serializeEnv(parsed, updates) {
|
|
143
|
+
const lines = parsed.lines.map((line) => ({ ...line }));
|
|
144
|
+
const appended = [];
|
|
145
|
+
for (const [name, value] of Object.entries(updates)) {
|
|
146
|
+
const entry = parsed.entries[name];
|
|
147
|
+
if (entry === undefined) {
|
|
148
|
+
appended.push(`${name}=${value}`);
|
|
149
|
+
} else if (entry.value !== value) {
|
|
150
|
+
const { lead, exported, separator, quote, trailer } = entry;
|
|
151
|
+
lines[entry.index].text = `${lead}${exported}${name}${separator}${quote}${value}${quote}${trailer}`;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (appended.length > 0 && lines.length > 0 && lines.at(-1).eol === "") lines.at(-1).eol = parsed.eol;
|
|
155
|
+
for (const text of appended) lines.push({ text, eol: parsed.eol });
|
|
156
|
+
const body = lines.map((line) => line.text + line.eol).join("");
|
|
157
|
+
return Buffer.from(`${parsed.bom ? String.fromCharCode(BOM) : ""}${body}`, "utf8");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Reparses generated content and checks that it holds exactly the intended
|
|
161
|
+
// Metergraph values, so no value can ever add or hide an assignment.
|
|
162
|
+
export function checkSerialized(content, updates, previous) {
|
|
163
|
+
const parsed = parseEnv(content);
|
|
164
|
+
for (const name of NAMES) {
|
|
165
|
+
const expected = Object.hasOwn(updates, name) ? updates[name] : previous.entries[name]?.value;
|
|
166
|
+
if (parsed.entries[name]?.value !== expected) throw new Stop("internal_error", "env_write_failed");
|
|
167
|
+
}
|
|
168
|
+
return parsed;
|
|
169
|
+
}
|