@nurix/nustack 0.10.0-dev.10
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 +77 -0
- package/dist/commands/add.js +130 -0
- package/dist/commands/agent.js +100 -0
- package/dist/commands/bootstrap.js +54 -0
- package/dist/commands/describe.js +34 -0
- package/dist/commands/device.js +159 -0
- package/dist/commands/docs.js +119 -0
- package/dist/commands/doctor.js +284 -0
- package/dist/commands/graph.js +34 -0
- package/dist/commands/handoff.js +108 -0
- package/dist/commands/init.js +280 -0
- package/dist/commands/lane.js +12 -0
- package/dist/commands/list.js +20 -0
- package/dist/commands/login.js +134 -0
- package/dist/commands/logout.js +44 -0
- package/dist/commands/open.js +54 -0
- package/dist/commands/organization.js +114 -0
- package/dist/commands/review.js +152 -0
- package/dist/commands/search.js +57 -0
- package/dist/commands/sessions.js +62 -0
- package/dist/commands/sync.js +85 -0
- package/dist/commands/telemetry.js +112 -0
- package/dist/commands/toolkit.js +167 -0
- package/dist/commands/validate.js +67 -0
- package/dist/commands/workspace-sync.js +645 -0
- package/dist/commands/workspace.js +479 -0
- package/dist/index.js +357 -0
- package/dist/lib/agent/claudeHooks.js +77 -0
- package/dist/lib/auth_grant.js +398 -0
- package/dist/lib/auth_recovery.js +49 -0
- package/dist/lib/browser_opener.js +24 -0
- package/dist/lib/device_identity.js +28 -0
- package/dist/lib/device_keychain.js +101 -0
- package/dist/lib/env_file.js +75 -0
- package/dist/lib/etna/manager.js +43 -0
- package/dist/lib/etna/profile.js +41 -0
- package/dist/lib/etna/reconcile.js +77 -0
- package/dist/lib/filebeat_installer.js +246 -0
- package/dist/lib/git-sync/core/commit_range.js +37 -0
- package/dist/lib/git-sync/core/engine.js +263 -0
- package/dist/lib/git-sync/core/git_runner.js +137 -0
- package/dist/lib/git-sync/core/operation_state.js +22 -0
- package/dist/lib/git-sync/core/path_normalizer.js +98 -0
- package/dist/lib/git-sync/core/reconciliation_lock.js +103 -0
- package/dist/lib/git-sync/core/repository.js +49 -0
- package/dist/lib/git-sync/core/watch_stream.js +86 -0
- package/dist/lib/git-sync/core/watcher.js +61 -0
- package/dist/lib/git-sync/inbound/applier.js +131 -0
- package/dist/lib/git-sync/inbound/apply_commit.js +225 -0
- package/dist/lib/git-sync/inbound/approval.js +117 -0
- package/dist/lib/git-sync/inbound/content_decision.js +148 -0
- package/dist/lib/git-sync/inbound/download.js +223 -0
- package/dist/lib/git-sync/inbound/event_sync.js +133 -0
- package/dist/lib/git-sync/inbound/index.js +10 -0
- package/dist/lib/git-sync/inbound/notification.js +39 -0
- package/dist/lib/git-sync/inbound/state_fingerprint.js +32 -0
- package/dist/lib/git-sync/inbound/technical_wait.js +39 -0
- package/dist/lib/git-sync/inbound/undo.js +198 -0
- package/dist/lib/git-sync/outbound/extraction.js +107 -0
- package/dist/lib/git-sync/outbound/history_decision.js +112 -0
- package/dist/lib/git-sync/outbound/index.js +26 -0
- package/dist/lib/git-sync/outbound/manifest_builder.js +58 -0
- package/dist/lib/git-sync/outbound/publisher.js +51 -0
- package/dist/lib/git-sync/outbound/queue.js +123 -0
- package/dist/lib/git-sync/outbound/state.js +12 -0
- package/dist/lib/git-sync/outbound/upload_client.js +141 -0
- package/dist/lib/git-sync/recovery/bundle.js +56 -0
- package/dist/lib/git-sync/recovery/crypto.js +114 -0
- package/dist/lib/git-sync/recovery/index.js +3 -0
- package/dist/lib/git-sync/recovery/store.js +55 -0
- package/dist/lib/git-sync/whitelist/index.js +4 -0
- package/dist/lib/git-sync/whitelist/matcher.js +62 -0
- package/dist/lib/git-sync/whitelist/store.js +99 -0
- package/dist/lib/git-sync/whitelist/types.js +46 -0
- package/dist/lib/git-sync/whitelist/validation.js +60 -0
- package/dist/lib/graph/answer.js +171 -0
- package/dist/lib/graph/freshness.js +29 -0
- package/dist/lib/graph/library.js +19 -0
- package/dist/lib/guards.js +106 -0
- package/dist/lib/interactive.js +12 -0
- package/dist/lib/json_output.js +8 -0
- package/dist/lib/lanes/claudeAdapter.js +135 -0
- package/dist/lib/lanes/frames.js +73 -0
- package/dist/lib/lanes/supervisor.js +247 -0
- package/dist/lib/lanes/worktree.js +33 -0
- package/dist/lib/local_store/accepted_uploads.js +49 -0
- package/dist/lib/local_store/bindings.js +163 -0
- package/dist/lib/local_store/commit_origins.js +25 -0
- package/dist/lib/local_store/database.js +71 -0
- package/dist/lib/local_store/device_state.js +72 -0
- package/dist/lib/local_store/filebeat_state.js +133 -0
- package/dist/lib/local_store/handoff_applications.js +166 -0
- package/dist/lib/local_store/history_decisions.js +48 -0
- package/dist/lib/local_store/index.js +14 -0
- package/dist/lib/local_store/migrations.js +240 -0
- package/dist/lib/local_store/paths.js +24 -0
- package/dist/lib/local_store/pending_uploads.js +76 -0
- package/dist/lib/local_store/recovery_bundles.js +66 -0
- package/dist/lib/local_store/sync_vocabulary.js +33 -0
- package/dist/lib/local_store/workspace_events_cache.js +54 -0
- package/dist/lib/loopback.js +78 -0
- package/dist/lib/manifest.js +83 -0
- package/dist/lib/nustack_client.js +547 -0
- package/dist/lib/nustack_store.js +17 -0
- package/dist/lib/onboarding/attach_existing_git.js +65 -0
- package/dist/lib/onboarding/binding.js +91 -0
- package/dist/lib/onboarding/folder_state.js +99 -0
- package/dist/lib/onboarding/git_init.js +10 -0
- package/dist/lib/onboarding/handoff_apply.js +68 -0
- package/dist/lib/onboarding/init_fresh_folder.js +100 -0
- package/dist/lib/onboarding/manifest_writer.js +161 -0
- package/dist/lib/onboarding/web_first_attach.js +133 -0
- package/dist/lib/package_manager.js +23 -0
- package/dist/lib/review/client.js +66 -0
- package/dist/lib/schema_validator.js +24 -0
- package/dist/lib/secure_file.js +10 -0
- package/dist/lib/session_telemetry.js +263 -0
- package/dist/lib/sessions/client.js +48 -0
- package/dist/lib/sessions/outsideSessions.js +54 -0
- package/dist/lib/sse_framing.js +48 -0
- package/dist/lib/telemetry/config_renderer.js +175 -0
- package/dist/lib/telemetry/health_report.js +173 -0
- package/dist/lib/telemetry/provider_paths.js +38 -0
- package/dist/lib/telemetry/redaction_policy.js +90 -0
- package/dist/lib/telemetry/reload.js +84 -0
- package/dist/lib/workspace_context.js +121 -0
- package/dist/spec_schema.json +422 -0
- package/dist/wire/auth.js +1 -0
- package/dist/wire/sync-paths.js +25 -0
- package/dist/wire/telemetry.js +1 -0
- package/package.json +49 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { assertSafeNuStackUrl } from "./guards.js";
|
|
4
|
+
import { getOrCreateDeviceId } from "./device_identity.js";
|
|
5
|
+
import { deleteDeviceCredential, readCredentialFailure, readDeviceCredential, writeDeviceCredential } from "./device_keychain.js";
|
|
6
|
+
import { openLoopbackListener } from "./loopback.js";
|
|
7
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
8
|
+
const BOOTSTRAP_TIMEOUT_MS = 25_000;
|
|
9
|
+
const ACCESS_TOKEN_SKEW_MS = 30_000;
|
|
10
|
+
const LOOPBACK_TIMEOUT_MS = 10 * 60_000;
|
|
11
|
+
const MACHINE_SCOPE = "offline_access";
|
|
12
|
+
export const CLI_CODE_PAGE_PATH = "/pages/cli-code";
|
|
13
|
+
export const DEVICE_HEADER = "X-NuStack-Device";
|
|
14
|
+
export function deadCredentialCopy(reason) {
|
|
15
|
+
if (reason === "older-schema")
|
|
16
|
+
return "This machine's sign-in predates the current NuStack CLI — run `nustack login` to sign in again.";
|
|
17
|
+
if (reason === "invalid_grant")
|
|
18
|
+
return "Your sign-in on this device is no longer valid — run `nustack login` to reconnect.";
|
|
19
|
+
return "Signed out on this device — run `nustack login` first.";
|
|
20
|
+
}
|
|
21
|
+
let cliVersion = "0";
|
|
22
|
+
export function setCliVersion(version) {
|
|
23
|
+
if (version.trim())
|
|
24
|
+
cliVersion = version.trim();
|
|
25
|
+
}
|
|
26
|
+
export function userAgent() {
|
|
27
|
+
return `nustack-cli/${cliVersion} (${os.hostname()}; ${process.platform})`;
|
|
28
|
+
}
|
|
29
|
+
let resolvedDeviceId = null;
|
|
30
|
+
let lastDeadReason = null;
|
|
31
|
+
export function knownDeviceId() {
|
|
32
|
+
return resolvedDeviceId;
|
|
33
|
+
}
|
|
34
|
+
export function signedOutReason() {
|
|
35
|
+
return lastDeadReason;
|
|
36
|
+
}
|
|
37
|
+
export function signedOutCopy() {
|
|
38
|
+
return deadCredentialCopy(lastDeadReason);
|
|
39
|
+
}
|
|
40
|
+
export async function fetchAuthConfig(nustackBaseUrl) {
|
|
41
|
+
const response = await fetch(`${nustackBaseUrl}/api/v1/auth/config`, {
|
|
42
|
+
headers: { Accept: "application/json", "User-Agent": userAgent() },
|
|
43
|
+
signal: AbortSignal.timeout(BOOTSTRAP_TIMEOUT_MS),
|
|
44
|
+
});
|
|
45
|
+
const payload = (await response.json().catch(() => null));
|
|
46
|
+
if (!response.ok || typeof payload?.auth_base_url !== "string" || typeof payload?.client_id !== "string") {
|
|
47
|
+
throw new Error(`this NuStack does not describe its auth service (HTTP ${response.status}) — it may predate machine grants`);
|
|
48
|
+
}
|
|
49
|
+
const resources = Array.isArray(payload.resources) ? payload.resources.filter((r) => typeof r === "string") : [];
|
|
50
|
+
if (resources.length === 0)
|
|
51
|
+
throw new Error("this NuStack names no resource for a machine to hold — its auth configuration is incomplete");
|
|
52
|
+
return { authBaseUrl: assertSafeNuStackUrl(payload.auth_base_url), clientId: payload.client_id, resources };
|
|
53
|
+
}
|
|
54
|
+
export async function discover(authBaseUrl) {
|
|
55
|
+
const response = await fetch(`${authBaseUrl}/api/auth/.well-known/openid-configuration`, {
|
|
56
|
+
headers: { Accept: "application/json", "User-Agent": userAgent() },
|
|
57
|
+
signal: AbortSignal.timeout(BOOTSTRAP_TIMEOUT_MS),
|
|
58
|
+
});
|
|
59
|
+
const payload = (await response.json().catch(() => null));
|
|
60
|
+
if (!response.ok || !payload?.issuer || !payload.authorization_endpoint || !payload.token_endpoint) {
|
|
61
|
+
throw new Error(`the auth service at ${authBaseUrl} published no usable discovery document (HTTP ${response.status})`);
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
issuer: payload.issuer,
|
|
65
|
+
authorizationEndpoint: payload.authorization_endpoint,
|
|
66
|
+
tokenEndpoint: payload.token_endpoint,
|
|
67
|
+
revocationEndpoint: payload.revocation_endpoint ?? `${authBaseUrl}/api/auth/oauth2/revoke`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export function mintPkce() {
|
|
71
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
72
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
73
|
+
return { verifier, challenge, state: randomBytes(16).toString("base64url") };
|
|
74
|
+
}
|
|
75
|
+
export function buildAuthorizeUrl(config, discovery, request) {
|
|
76
|
+
const url = new URL(discovery.authorizationEndpoint);
|
|
77
|
+
url.searchParams.set("response_type", "code");
|
|
78
|
+
url.searchParams.set("client_id", config.clientId);
|
|
79
|
+
url.searchParams.set("redirect_uri", request.redirectUri);
|
|
80
|
+
url.searchParams.set("scope", MACHINE_SCOPE);
|
|
81
|
+
url.searchParams.set("state", request.pkce.state);
|
|
82
|
+
url.searchParams.set("code_challenge", request.pkce.challenge);
|
|
83
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
84
|
+
for (const resource of config.resources)
|
|
85
|
+
url.searchParams.append("resource", resource);
|
|
86
|
+
if (request.force)
|
|
87
|
+
url.searchParams.set("prompt", "login");
|
|
88
|
+
return url.toString();
|
|
89
|
+
}
|
|
90
|
+
export function cliCodePageUrl(authBaseUrl) {
|
|
91
|
+
return `${authBaseUrl}${CLI_CODE_PAGE_PATH}`;
|
|
92
|
+
}
|
|
93
|
+
export async function exchangeCode(config, discovery, input) {
|
|
94
|
+
const form = new URLSearchParams({
|
|
95
|
+
grant_type: "authorization_code",
|
|
96
|
+
code: input.code,
|
|
97
|
+
code_verifier: input.verifier,
|
|
98
|
+
redirect_uri: input.redirectUri,
|
|
99
|
+
client_id: config.clientId,
|
|
100
|
+
});
|
|
101
|
+
for (const resource of config.resources)
|
|
102
|
+
form.append("resource", resource);
|
|
103
|
+
const response = await fetch(discovery.tokenEndpoint, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", "User-Agent": userAgent() },
|
|
106
|
+
body: form.toString(),
|
|
107
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
108
|
+
});
|
|
109
|
+
const payload = (await response.json().catch(() => null));
|
|
110
|
+
if (!response.ok || typeof payload?.access_token !== "string") {
|
|
111
|
+
if (payload?.error === "invalid_grant")
|
|
112
|
+
throw new Error("That code expired or was already used — run `nustack login` again.");
|
|
113
|
+
throw new Error(payload?.error_description ?? payload?.error ?? `the auth service refused the code (HTTP ${response.status})`);
|
|
114
|
+
}
|
|
115
|
+
if (typeof payload.refresh_token !== "string" || !payload.refresh_token) {
|
|
116
|
+
throw new Error("The auth service issued no refresh token — the sign-in cannot be kept on this machine.");
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
accessToken: payload.access_token,
|
|
120
|
+
refreshToken: payload.refresh_token,
|
|
121
|
+
expiresInSeconds: typeof payload.expires_in === "number" ? payload.expires_in : 0,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
export function decodeJwtPayload(token) {
|
|
125
|
+
const segment = token.split(".")[1];
|
|
126
|
+
if (!segment)
|
|
127
|
+
return null;
|
|
128
|
+
try {
|
|
129
|
+
const parsed = JSON.parse(Buffer.from(segment, "base64url").toString("utf8"));
|
|
130
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async function persistGrant(config, discovery, issued) {
|
|
137
|
+
const deviceId = await getOrCreateDeviceId();
|
|
138
|
+
resolvedDeviceId = deviceId;
|
|
139
|
+
const claims = decodeJwtPayload(issued.accessToken);
|
|
140
|
+
const userEmail = typeof claims?.email === "string" ? claims.email : "";
|
|
141
|
+
const apiResource = config.resources[0];
|
|
142
|
+
await writeDeviceCredential({
|
|
143
|
+
refresh_token: issued.refreshToken,
|
|
144
|
+
auth_base_url: config.authBaseUrl,
|
|
145
|
+
issuer: discovery.issuer,
|
|
146
|
+
device_id: deviceId,
|
|
147
|
+
user_email: userEmail,
|
|
148
|
+
granted_resources: config.resources,
|
|
149
|
+
client_id: config.clientId,
|
|
150
|
+
access_tokens: { [apiResource]: { token: issued.accessToken, expires_at_ms: Date.now() + issued.expiresInSeconds * 1000 } },
|
|
151
|
+
});
|
|
152
|
+
lastDeadReason = null;
|
|
153
|
+
return { userEmail, deviceId, authBaseUrl: config.authBaseUrl };
|
|
154
|
+
}
|
|
155
|
+
export async function prepareLogin(nustackBaseUrl) {
|
|
156
|
+
const config = await fetchAuthConfig(nustackBaseUrl);
|
|
157
|
+
const discovery = await discover(config.authBaseUrl);
|
|
158
|
+
return { config, discovery };
|
|
159
|
+
}
|
|
160
|
+
export async function loginWithPastedCode(ctx, steps) {
|
|
161
|
+
const pkce = mintPkce();
|
|
162
|
+
const redirectUri = cliCodePageUrl(ctx.config.authBaseUrl);
|
|
163
|
+
steps.onAuthorizeUrl(buildAuthorizeUrl(ctx.config, ctx.discovery, { redirectUri, pkce, force: steps.force }));
|
|
164
|
+
const code = (await steps.promptForCode()).trim();
|
|
165
|
+
if (!code)
|
|
166
|
+
throw new Error("No code was entered — run `nustack login` again.");
|
|
167
|
+
const issued = await exchangeCode(ctx.config, ctx.discovery, { code, verifier: pkce.verifier, redirectUri });
|
|
168
|
+
return persistGrant(ctx.config, ctx.discovery, issued);
|
|
169
|
+
}
|
|
170
|
+
export async function loginWithLoopback(ctx, steps) {
|
|
171
|
+
const pkce = mintPkce();
|
|
172
|
+
const listener = await openLoopbackListener(pkce.state);
|
|
173
|
+
let timer = null;
|
|
174
|
+
try {
|
|
175
|
+
steps.onAuthorizeUrl(buildAuthorizeUrl(ctx.config, ctx.discovery, { redirectUri: listener.redirectUri, pkce, force: steps.force }));
|
|
176
|
+
const timeout = new Promise((_, reject) => {
|
|
177
|
+
timer = setTimeout(() => reject(new Error("Timed out waiting for the browser — run `nustack login` again, or `nustack login --manual`.")), steps.timeoutMs ?? LOOPBACK_TIMEOUT_MS);
|
|
178
|
+
});
|
|
179
|
+
const switched = steps.switchToPaste ? steps.switchToPaste().then(() => "paste") : new Promise(() => { });
|
|
180
|
+
const outcome = await Promise.race([listener.callback.then((callback) => ({ callback })), switched, timeout]);
|
|
181
|
+
if (outcome === "paste") {
|
|
182
|
+
await listener.close();
|
|
183
|
+
return loginWithPastedCode(ctx, steps);
|
|
184
|
+
}
|
|
185
|
+
const { callback } = outcome;
|
|
186
|
+
if (callback.iss !== null && callback.iss !== ctx.discovery.issuer) {
|
|
187
|
+
throw new Error(`The sign-in came back from ${callback.iss}, not from ${ctx.discovery.issuer} — refusing it.`);
|
|
188
|
+
}
|
|
189
|
+
const issued = await exchangeCode(ctx.config, ctx.discovery, { code: callback.code, verifier: pkce.verifier, redirectUri: listener.redirectUri });
|
|
190
|
+
return persistGrant(ctx.config, ctx.discovery, issued);
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
if (timer)
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
await listener.close().catch(() => undefined);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
export function readAnswer(status, body) {
|
|
199
|
+
const record = body && typeof body === "object" ? body : null;
|
|
200
|
+
const str = (key) => {
|
|
201
|
+
const value = record?.[key];
|
|
202
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
203
|
+
};
|
|
204
|
+
if (status >= 200 && status < 300) {
|
|
205
|
+
return { kind: "tokens", hasAccessToken: str("access_token") !== null, hasRefreshToken: str("refresh_token") !== null };
|
|
206
|
+
}
|
|
207
|
+
if (status >= 500)
|
|
208
|
+
return { kind: "unusable" };
|
|
209
|
+
return { kind: "refusal", code: str("error") };
|
|
210
|
+
}
|
|
211
|
+
export function classifyAnswer(answer) {
|
|
212
|
+
switch (answer.kind) {
|
|
213
|
+
case "tokens":
|
|
214
|
+
return answer.hasAccessToken ? "signed_in" : "transport_error";
|
|
215
|
+
case "refusal":
|
|
216
|
+
if (answer.code === "invalid_grant")
|
|
217
|
+
return "credential_dead";
|
|
218
|
+
if (answer.code === "invalid_target")
|
|
219
|
+
return "needs_reauthorization";
|
|
220
|
+
return "transport_error";
|
|
221
|
+
default:
|
|
222
|
+
return "transport_error";
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
export function classifyRefresh(status, body) {
|
|
226
|
+
return classifyAnswer(readAnswer(status, body));
|
|
227
|
+
}
|
|
228
|
+
export async function refreshGrant(tokenEndpoint, refreshToken, resource, clientId) {
|
|
229
|
+
const form = new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: clientId, resource });
|
|
230
|
+
const response = await fetch(tokenEndpoint, {
|
|
231
|
+
method: "POST",
|
|
232
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", "User-Agent": userAgent() },
|
|
233
|
+
body: form.toString(),
|
|
234
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
235
|
+
});
|
|
236
|
+
const payload = (await response.json().catch(() => null));
|
|
237
|
+
switch (classifyRefresh(response.status, payload)) {
|
|
238
|
+
case "signed_in":
|
|
239
|
+
return {
|
|
240
|
+
kind: "minted",
|
|
241
|
+
accessToken: payload.access_token,
|
|
242
|
+
expiresInSeconds: typeof payload.expires_in === "number" ? payload.expires_in : 0,
|
|
243
|
+
...(typeof payload.refresh_token === "string" ? { rotatedRefreshToken: payload.refresh_token } : {}),
|
|
244
|
+
};
|
|
245
|
+
case "credential_dead":
|
|
246
|
+
return { kind: "dead", reason: "invalid_grant" };
|
|
247
|
+
case "needs_reauthorization":
|
|
248
|
+
return { kind: "needs_reauthorization", resource };
|
|
249
|
+
default:
|
|
250
|
+
throw new Error(`token refresh failed: HTTP ${response.status}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
function isAlreadyEnded(status, payload) {
|
|
254
|
+
if (status !== 400 || !payload)
|
|
255
|
+
return false;
|
|
256
|
+
if (payload.error === "invalid_grant")
|
|
257
|
+
return true;
|
|
258
|
+
return payload.error === "invalid_request" && /revoked|not found|expired|invalid/i.test(payload.error_description ?? "");
|
|
259
|
+
}
|
|
260
|
+
export async function revokeGrant(input) {
|
|
261
|
+
try {
|
|
262
|
+
const endpoint = await discover(input.authBaseUrl)
|
|
263
|
+
.then((d) => d.revocationEndpoint)
|
|
264
|
+
.catch(() => `${input.authBaseUrl}/api/auth/oauth2/revoke`);
|
|
265
|
+
const response = await fetch(endpoint, {
|
|
266
|
+
method: "POST",
|
|
267
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", "User-Agent": userAgent() },
|
|
268
|
+
body: new URLSearchParams({ token: input.refreshToken, token_type_hint: "refresh_token", client_id: input.clientId }).toString(),
|
|
269
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
270
|
+
});
|
|
271
|
+
if (response.ok)
|
|
272
|
+
return "revoked";
|
|
273
|
+
if (response.status >= 500)
|
|
274
|
+
return "unreachable";
|
|
275
|
+
const payload = (await response.json().catch(() => null));
|
|
276
|
+
return isAlreadyEnded(response.status, payload) ? "already_ended" : "refused";
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
return "unreachable";
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async function readStoredGrant() {
|
|
283
|
+
const deviceId = await getOrCreateDeviceId();
|
|
284
|
+
resolvedDeviceId = deviceId;
|
|
285
|
+
const credential = await readDeviceCredential(deviceId);
|
|
286
|
+
if (!credential) {
|
|
287
|
+
const failure = await readCredentialFailure(deviceId);
|
|
288
|
+
return { deviceId, grant: null, failure: failure === "older-schema" ? "older-schema" : null };
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
deviceId,
|
|
292
|
+
failure: null,
|
|
293
|
+
grant: {
|
|
294
|
+
deviceId,
|
|
295
|
+
refreshToken: credential.refresh_token,
|
|
296
|
+
authBaseUrl: credential.auth_base_url,
|
|
297
|
+
issuer: credential.issuer,
|
|
298
|
+
userEmail: credential.user_email,
|
|
299
|
+
grantedResources: Array.isArray(credential.granted_resources) ? credential.granted_resources : [],
|
|
300
|
+
clientId: typeof credential.client_id === "string" ? credential.client_id : null,
|
|
301
|
+
accessTokens: credential.access_tokens ?? {},
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
async function clientIdFor(grant, nustackBaseUrl) {
|
|
306
|
+
if (grant.clientId)
|
|
307
|
+
return grant.clientId;
|
|
308
|
+
if (!nustackBaseUrl)
|
|
309
|
+
throw new Error("this machine's grant names no client and no NuStack URL was given to ask");
|
|
310
|
+
return (await fetchAuthConfig(nustackBaseUrl)).clientId;
|
|
311
|
+
}
|
|
312
|
+
async function refreshAndPersist(grant, audience, nustackBaseUrl) {
|
|
313
|
+
const clientId = await clientIdFor(grant, nustackBaseUrl);
|
|
314
|
+
const refreshed = await refreshGrant(`${grant.authBaseUrl}/api/auth/oauth2/token`, grant.refreshToken, audience, clientId);
|
|
315
|
+
if (refreshed.kind !== "minted")
|
|
316
|
+
return refreshed;
|
|
317
|
+
const claims = decodeJwtPayload(refreshed.accessToken);
|
|
318
|
+
const userEmail = (typeof claims?.email === "string" && claims.email) || grant.userEmail;
|
|
319
|
+
await writeDeviceCredential({
|
|
320
|
+
refresh_token: refreshed.rotatedRefreshToken ?? grant.refreshToken,
|
|
321
|
+
auth_base_url: grant.authBaseUrl,
|
|
322
|
+
issuer: grant.issuer,
|
|
323
|
+
device_id: grant.deviceId,
|
|
324
|
+
user_email: userEmail,
|
|
325
|
+
granted_resources: grant.grantedResources,
|
|
326
|
+
client_id: clientId,
|
|
327
|
+
access_tokens: {
|
|
328
|
+
...grant.accessTokens,
|
|
329
|
+
[audience]: { token: refreshed.accessToken, expires_at_ms: Date.now() + refreshed.expiresInSeconds * 1000 },
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
return refreshed;
|
|
333
|
+
}
|
|
334
|
+
export async function resolveAccessToken(nustackBaseUrl, resource) {
|
|
335
|
+
const { deviceId, grant, failure } = await readStoredGrant();
|
|
336
|
+
if (!grant) {
|
|
337
|
+
if (failure)
|
|
338
|
+
lastDeadReason = failure;
|
|
339
|
+
return { kind: "none", reason: failure };
|
|
340
|
+
}
|
|
341
|
+
const audience = resource ?? grant.grantedResources[0];
|
|
342
|
+
if (!audience)
|
|
343
|
+
return { kind: "none", reason: null };
|
|
344
|
+
const cached = grant.accessTokens[audience];
|
|
345
|
+
if (cached && cached.expires_at_ms - ACCESS_TOKEN_SKEW_MS > Date.now()) {
|
|
346
|
+
return { kind: "ok", session: { accessToken: cached.token, userEmail: grant.userEmail, deviceId, authBaseUrl: grant.authBaseUrl } };
|
|
347
|
+
}
|
|
348
|
+
const refreshed = await refreshAndPersist(grant, audience, nustackBaseUrl);
|
|
349
|
+
if (refreshed.kind === "dead") {
|
|
350
|
+
lastDeadReason = refreshed.reason;
|
|
351
|
+
await deleteDeviceCredential(deviceId).catch(() => false);
|
|
352
|
+
return { kind: "dead", reason: refreshed.reason };
|
|
353
|
+
}
|
|
354
|
+
if (refreshed.kind === "needs_reauthorization")
|
|
355
|
+
return { kind: "needs_reauthorization", resource: audience };
|
|
356
|
+
lastDeadReason = null;
|
|
357
|
+
const claims = decodeJwtPayload(refreshed.accessToken);
|
|
358
|
+
const userEmail = (typeof claims?.email === "string" && claims.email) || grant.userEmail;
|
|
359
|
+
return { kind: "ok", session: { accessToken: refreshed.accessToken, userEmail, deviceId, authBaseUrl: grant.authBaseUrl } };
|
|
360
|
+
}
|
|
361
|
+
export async function ensureAccessToken(nustackBaseUrl, resource) {
|
|
362
|
+
const outcome = await resolveAccessToken(nustackBaseUrl, resource);
|
|
363
|
+
return outcome.kind === "ok" ? { accessToken: outcome.session.accessToken, userEmail: outcome.session.userEmail } : null;
|
|
364
|
+
}
|
|
365
|
+
export async function refreshAccessTokenAfterRefusal(staleToken, nustackBaseUrl, resource) {
|
|
366
|
+
const { deviceId, grant } = await readStoredGrant();
|
|
367
|
+
if (!grant)
|
|
368
|
+
return null;
|
|
369
|
+
const audience = resource ?? grant.grantedResources[0];
|
|
370
|
+
if (!audience)
|
|
371
|
+
return null;
|
|
372
|
+
const cached = grant.accessTokens[audience];
|
|
373
|
+
if (cached && cached.token !== staleToken)
|
|
374
|
+
return cached.token;
|
|
375
|
+
const refreshed = await refreshAndPersist(grant, audience, nustackBaseUrl);
|
|
376
|
+
if (refreshed.kind === "dead") {
|
|
377
|
+
lastDeadReason = refreshed.reason;
|
|
378
|
+
await deleteDeviceCredential(deviceId).catch(() => false);
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
if (refreshed.kind === "needs_reauthorization")
|
|
382
|
+
return null;
|
|
383
|
+
return refreshed.accessToken;
|
|
384
|
+
}
|
|
385
|
+
export async function readGrant() {
|
|
386
|
+
const { grant } = await readStoredGrant();
|
|
387
|
+
if (!grant)
|
|
388
|
+
return null;
|
|
389
|
+
return {
|
|
390
|
+
grantToken: grant.refreshToken,
|
|
391
|
+
authBaseUrl: grant.authBaseUrl,
|
|
392
|
+
issuer: grant.issuer,
|
|
393
|
+
deviceId: grant.deviceId,
|
|
394
|
+
userEmail: grant.userEmail,
|
|
395
|
+
grantedResources: grant.grantedResources,
|
|
396
|
+
clientId: grant.clientId,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import { deadCredentialCopy, ensureAccessToken, resolveAccessToken } from "./auth_grant.js";
|
|
3
|
+
import { performDeviceLogin } from "../commands/login.js";
|
|
4
|
+
import { isInteractive } from "./interactive.js";
|
|
5
|
+
let lastFailure = { code: "SIGNED_OUT", message: deadCredentialCopy(null), retryable: false };
|
|
6
|
+
export function lastAuthFailure() {
|
|
7
|
+
return lastFailure;
|
|
8
|
+
}
|
|
9
|
+
function describeFailure(outcome) {
|
|
10
|
+
if (outcome === "unreachable") {
|
|
11
|
+
return { code: "AUTH_UNAVAILABLE", message: "Couldn't reach the auth service to refresh this machine's sign-in — try again in a moment.", retryable: true };
|
|
12
|
+
}
|
|
13
|
+
if (outcome.kind === "needs_reauthorization") {
|
|
14
|
+
return { code: "REAUTHORIZATION_REQUIRED", message: `This machine isn't approved for ${outcome.resource} yet — run \`nustack login\` to approve it.`, retryable: false };
|
|
15
|
+
}
|
|
16
|
+
return { code: "SIGNED_OUT", message: deadCredentialCopy(outcome.reason), retryable: false };
|
|
17
|
+
}
|
|
18
|
+
function reconnectingCopy(outcome) {
|
|
19
|
+
if (outcome.kind === "needs_reauthorization")
|
|
20
|
+
return `This machine isn't approved for ${outcome.resource} yet. Opening the browser to sign in…`;
|
|
21
|
+
if (outcome.kind === "dead")
|
|
22
|
+
return "This machine's access was revoked. Opening the browser to sign in…";
|
|
23
|
+
if (outcome.reason === "older-schema")
|
|
24
|
+
return "This machine's sign-in predates the current NuStack CLI. Opening the browser to sign in…";
|
|
25
|
+
return "Not signed in on this machine. Opening the browser to sign in…";
|
|
26
|
+
}
|
|
27
|
+
export async function ensureAuthWithRecovery(baseUrl, json) {
|
|
28
|
+
let outcome;
|
|
29
|
+
try {
|
|
30
|
+
outcome = await resolveAccessToken(baseUrl);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
lastFailure = describeFailure("unreachable");
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (outcome.kind === "ok")
|
|
37
|
+
return { accessToken: outcome.session.accessToken, userEmail: outcome.session.userEmail };
|
|
38
|
+
lastFailure = describeFailure(outcome);
|
|
39
|
+
if (json || !isInteractive())
|
|
40
|
+
return null;
|
|
41
|
+
p.log.warn(reconnectingCopy(outcome));
|
|
42
|
+
const didLogin = await performDeviceLogin(baseUrl, process.cwd(), { reauthorize: true });
|
|
43
|
+
if (!didLogin)
|
|
44
|
+
return null;
|
|
45
|
+
return ensureAccessToken(baseUrl).catch(() => {
|
|
46
|
+
lastFailure = describeFailure("unreachable");
|
|
47
|
+
return null;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { assertSafeBrowserUrl } from "./guards.js";
|
|
3
|
+
export function launchBrowser(url) {
|
|
4
|
+
let safeUrl;
|
|
5
|
+
try {
|
|
6
|
+
safeUrl = assertSafeBrowserUrl(url);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const override = process.env.NUSTACK_BROWSER_OPENER?.trim();
|
|
12
|
+
const [command, args] = override
|
|
13
|
+
? [override, [safeUrl]]
|
|
14
|
+
: process.platform === "darwin"
|
|
15
|
+
? ["open", [safeUrl]]
|
|
16
|
+
: process.platform === "win32"
|
|
17
|
+
? ["cmd", ["/c", "start", "", safeUrl]]
|
|
18
|
+
: ["xdg-open", [safeUrl]];
|
|
19
|
+
try {
|
|
20
|
+
spawn(command, args, { detached: true, stdio: "ignore" }).unref();
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
function deviceIdFilePath() {
|
|
7
|
+
return path.join(os.homedir(), ".nustack", "device_id");
|
|
8
|
+
}
|
|
9
|
+
export async function getOrCreateDeviceId() {
|
|
10
|
+
const file = deviceIdFilePath();
|
|
11
|
+
const dir = path.dirname(file);
|
|
12
|
+
if (existsSync(file)) {
|
|
13
|
+
const existing = (await readFile(file, "utf8")).trim();
|
|
14
|
+
if (existing)
|
|
15
|
+
return existing;
|
|
16
|
+
}
|
|
17
|
+
const deviceId = `dev_${randomBytes(16).toString("hex")}`;
|
|
18
|
+
await mkdir(dir, { recursive: true });
|
|
19
|
+
await writeFile(file, `${deviceId}\n`, "utf8");
|
|
20
|
+
return deviceId;
|
|
21
|
+
}
|
|
22
|
+
export async function clearDeviceId() {
|
|
23
|
+
const file = deviceIdFilePath();
|
|
24
|
+
if (!existsSync(file))
|
|
25
|
+
return false;
|
|
26
|
+
await rm(file, { force: true });
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
const runFile = promisify(execFile);
|
|
5
|
+
const DEFAULT_SECURITY_BIN = "/usr/bin/security";
|
|
6
|
+
const SERVICE = "nustack-device";
|
|
7
|
+
export const KEYCHAIN_SCHEMA_VERSION = 3;
|
|
8
|
+
function securityBin() {
|
|
9
|
+
return process.env.NUSTACK_KEYCHAIN_BIN?.trim() || DEFAULT_SECURITY_BIN;
|
|
10
|
+
}
|
|
11
|
+
export function isKeychainAvailable() {
|
|
12
|
+
if (process.env.NUSTACK_KEYCHAIN_BIN)
|
|
13
|
+
return true;
|
|
14
|
+
return process.platform === "darwin" && existsSync(DEFAULT_SECURITY_BIN);
|
|
15
|
+
}
|
|
16
|
+
const REQUIRED_FIELDS = ["refresh_token", "auth_base_url", "issuer", "device_id", "user_email"];
|
|
17
|
+
export function decodeCredential(raw) {
|
|
18
|
+
let parsed;
|
|
19
|
+
try {
|
|
20
|
+
parsed = JSON.parse(raw.trim());
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return { ok: false, reason: "malformed" };
|
|
24
|
+
}
|
|
25
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
26
|
+
return { ok: false, reason: "malformed" };
|
|
27
|
+
const payload = parsed;
|
|
28
|
+
if (typeof payload.schema_version !== "number")
|
|
29
|
+
return { ok: false, reason: "missing-field" };
|
|
30
|
+
if (payload.schema_version > KEYCHAIN_SCHEMA_VERSION)
|
|
31
|
+
return { ok: false, reason: "future-schema" };
|
|
32
|
+
if (payload.schema_version < KEYCHAIN_SCHEMA_VERSION)
|
|
33
|
+
return { ok: false, reason: "older-schema" };
|
|
34
|
+
for (const field of REQUIRED_FIELDS) {
|
|
35
|
+
if (typeof payload[field] !== "string")
|
|
36
|
+
return { ok: false, reason: "missing-field" };
|
|
37
|
+
}
|
|
38
|
+
return { ok: true, payload };
|
|
39
|
+
}
|
|
40
|
+
export function mergeCredential(existing, update) {
|
|
41
|
+
const sameVersion = existing?.schema_version === KEYCHAIN_SCHEMA_VERSION;
|
|
42
|
+
const merged = { ...(sameVersion ? existing : {}) };
|
|
43
|
+
for (const [key, value] of Object.entries(update)) {
|
|
44
|
+
if (value === null || value === undefined)
|
|
45
|
+
continue;
|
|
46
|
+
merged[key] = value;
|
|
47
|
+
}
|
|
48
|
+
merged.schema_version = KEYCHAIN_SCHEMA_VERSION;
|
|
49
|
+
return merged;
|
|
50
|
+
}
|
|
51
|
+
export async function writeDeviceCredential(input) {
|
|
52
|
+
const stored = await readRawCredential(input.device_id);
|
|
53
|
+
const payload = mergeCredential(stored, input);
|
|
54
|
+
await runFile(securityBin(), ["add-generic-password", "-U", "-s", SERVICE, "-a", input.device_id, "-w", JSON.stringify(payload)]);
|
|
55
|
+
}
|
|
56
|
+
export async function readDeviceCredential(deviceId) {
|
|
57
|
+
const decoded = await readDecoded(deviceId);
|
|
58
|
+
return decoded?.ok ? decoded.payload : null;
|
|
59
|
+
}
|
|
60
|
+
export async function readCredentialFailure(deviceId) {
|
|
61
|
+
const decoded = await readDecoded(deviceId);
|
|
62
|
+
return decoded && !decoded.ok ? decoded.reason : null;
|
|
63
|
+
}
|
|
64
|
+
async function readRawCredential(deviceId) {
|
|
65
|
+
const decoded = await readDecoded(deviceId);
|
|
66
|
+
if (!decoded)
|
|
67
|
+
return null;
|
|
68
|
+
if (decoded.ok)
|
|
69
|
+
return decoded.payload;
|
|
70
|
+
return decoded.reason === "older-schema" || decoded.reason === "future-schema" ? decoded.stored : null;
|
|
71
|
+
}
|
|
72
|
+
async function readDecoded(deviceId) {
|
|
73
|
+
try {
|
|
74
|
+
const { stdout } = await runFile(securityBin(), ["find-generic-password", "-s", SERVICE, "-a", deviceId, "-w"]);
|
|
75
|
+
const decoded = decodeCredential(stdout.trim());
|
|
76
|
+
if (decoded.ok)
|
|
77
|
+
return decoded;
|
|
78
|
+
let stored = null;
|
|
79
|
+
try {
|
|
80
|
+
const parsed = JSON.parse(stdout.trim());
|
|
81
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
82
|
+
stored = parsed;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
stored = null;
|
|
86
|
+
}
|
|
87
|
+
return { ...decoded, stored };
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export async function deleteDeviceCredential(deviceId) {
|
|
94
|
+
try {
|
|
95
|
+
await runFile(securityBin(), ["delete-generic-password", "-s", SERVICE, "-a", deviceId]);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { appendFile, readFile } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { assertSafeEnvKey, assertSafeEnvValue } from "./guards.js";
|
|
5
|
+
import { writeFileSecure } from "./secure_file.js";
|
|
6
|
+
const BLOCK_START = "# >>> nustack:managed — do not edit by hand; the nustack CLI rewrites this block >>>";
|
|
7
|
+
const BLOCK_END = "# <<< nustack:managed <<<";
|
|
8
|
+
export async function readManagedValues(filePath) {
|
|
9
|
+
if (!existsSync(filePath))
|
|
10
|
+
return {};
|
|
11
|
+
const content = await readFile(filePath, "utf8");
|
|
12
|
+
const block = extractBlock(content);
|
|
13
|
+
if (block === null)
|
|
14
|
+
return {};
|
|
15
|
+
const values = {};
|
|
16
|
+
for (const line of block.split("\n")) {
|
|
17
|
+
const match = line.match(/^([A-Z0-9_]+)=(.*)$/);
|
|
18
|
+
if (match && match[1] !== undefined && match[2] !== undefined) {
|
|
19
|
+
values[match[1]] = match[2];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return values;
|
|
23
|
+
}
|
|
24
|
+
export async function upsertManagedValues(filePath, updates) {
|
|
25
|
+
const currentValues = await readManagedValues(filePath);
|
|
26
|
+
const mergedValues = { ...currentValues, ...updates };
|
|
27
|
+
const lines = Object.entries(mergedValues).map(([key, value]) => `${assertSafeEnvKey(key)}=${assertSafeEnvValue(key, value)}`);
|
|
28
|
+
const block = [BLOCK_START, ...lines, BLOCK_END].join("\n");
|
|
29
|
+
const existingContent = existsSync(filePath) ? await readFile(filePath, "utf8") : "";
|
|
30
|
+
await writeFileSecure(filePath, replaceBlock(existingContent, block));
|
|
31
|
+
}
|
|
32
|
+
export async function writeManagedValues(filePath, values) {
|
|
33
|
+
const lines = Object.entries(values).map(([key, value]) => `${assertSafeEnvKey(key)}=${assertSafeEnvValue(key, value)}`);
|
|
34
|
+
const block = [BLOCK_START, ...lines, BLOCK_END].join("\n");
|
|
35
|
+
const existingContent = existsSync(filePath) ? await readFile(filePath, "utf8") : "";
|
|
36
|
+
await writeFileSecure(filePath, replaceBlock(existingContent, block));
|
|
37
|
+
}
|
|
38
|
+
export async function removeManagedValues(filePath, keys) {
|
|
39
|
+
const currentValues = await readManagedValues(filePath);
|
|
40
|
+
const removedKeys = keys.filter((key) => key in currentValues);
|
|
41
|
+
if (removedKeys.length === 0)
|
|
42
|
+
return [];
|
|
43
|
+
for (const key of removedKeys)
|
|
44
|
+
delete currentValues[key];
|
|
45
|
+
await writeManagedValues(filePath, currentValues);
|
|
46
|
+
return removedKeys;
|
|
47
|
+
}
|
|
48
|
+
export async function ensureEnvGitignored(cwd) {
|
|
49
|
+
const gitignorePath = path.join(cwd, ".gitignore");
|
|
50
|
+
const content = existsSync(gitignorePath) ? await readFile(gitignorePath, "utf8") : "";
|
|
51
|
+
const hasEnvRule = content
|
|
52
|
+
.split("\n")
|
|
53
|
+
.some((line) => [".env", "*.env", ".env*"].includes(line.trim()));
|
|
54
|
+
if (hasEnvRule)
|
|
55
|
+
return false;
|
|
56
|
+
const separator = content === "" || content.endsWith("\n") ? "" : "\n";
|
|
57
|
+
await appendFile(gitignorePath, `${separator}.env\n`);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
function extractBlock(content) {
|
|
61
|
+
const start = content.indexOf(BLOCK_START);
|
|
62
|
+
const end = content.indexOf(BLOCK_END);
|
|
63
|
+
if (start === -1 || end === -1 || end < start)
|
|
64
|
+
return null;
|
|
65
|
+
return content.slice(start + BLOCK_START.length, end).trim();
|
|
66
|
+
}
|
|
67
|
+
function replaceBlock(content, block) {
|
|
68
|
+
const start = content.indexOf(BLOCK_START);
|
|
69
|
+
const end = content.indexOf(BLOCK_END);
|
|
70
|
+
if (start !== -1 && end !== -1 && end > start) {
|
|
71
|
+
return content.slice(0, start) + block + content.slice(end + BLOCK_END.length);
|
|
72
|
+
}
|
|
73
|
+
const separator = content === "" ? "" : content.endsWith("\n") ? "\n" : "\n\n";
|
|
74
|
+
return content + separator + block + "\n";
|
|
75
|
+
}
|