@envshipcom/cli 0.2.0

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/dist/index.js ADDED
@@ -0,0 +1,2192 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { spawn } from "node:child_process";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { chmod, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
7
+ import { homedir, tmpdir } from "node:os";
8
+ import { dirname, isAbsolute, join, normalize, parse, resolve } from "node:path";
9
+ import { Command, Option } from "commander";
10
+
11
+ // ../crypto/src/index.ts
12
+ function canonicalize(value) {
13
+ return JSON.stringify(sortJson(value));
14
+ }
15
+ async function sha256Base64url(value) {
16
+ const bytes = typeof value === "string" ? new TextEncoder().encode(value) : value;
17
+ const input = new ArrayBuffer(bytes.byteLength);
18
+ new Uint8Array(input).set(bytes);
19
+ const hash = await crypto.subtle.digest("SHA-256", input);
20
+ return encodeBase64url(new Uint8Array(hash));
21
+ }
22
+ function encodeBase64url(bytes) {
23
+ let binary = "";
24
+ for (const byte of bytes) binary += String.fromCharCode(byte);
25
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "");
26
+ }
27
+ function decodeBase64url(value) {
28
+ const normalized = value.replaceAll("-", "+").replaceAll("_", "/");
29
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
30
+ const binary = atob(padded);
31
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0));
32
+ }
33
+ function ownedBytes(bytes) {
34
+ const copy = new Uint8Array(bytes.byteLength);
35
+ copy.set(bytes);
36
+ return copy;
37
+ }
38
+ async function generateEncryptedKeyFile(label, passphrase) {
39
+ if (!passphrase) throw new Error("Add a KeyFile passphrase before creating the encrypted backup.");
40
+ const keyset = await generatePlainKeyset(label);
41
+ return { filename: `${label}.envship-keyfile.json`, keyFile: await createEncryptedKeyFileFromKeyset(keyset, passphrase), keyset };
42
+ }
43
+ async function createEncryptedKeyFileFromKeyset(keyset, passphrase) {
44
+ if (!passphrase) throw new Error("Add a KeyFile passphrase before creating the encrypted backup.");
45
+ const salt = crypto.getRandomValues(new Uint8Array(16));
46
+ const iv = crypto.getRandomValues(new Uint8Array(12));
47
+ const passphraseKey = await crypto.subtle.importKey("raw", new TextEncoder().encode(passphrase), "PBKDF2", false, ["deriveKey"]);
48
+ const wrappingKey = await crypto.subtle.deriveKey(
49
+ { name: "PBKDF2", salt, iterations: 25e4, hash: "SHA-256" },
50
+ passphraseKey,
51
+ { name: "AES-GCM", length: 256 },
52
+ false,
53
+ ["encrypt"]
54
+ );
55
+ const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, wrappingKey, new TextEncoder().encode(JSON.stringify(keyset)));
56
+ return {
57
+ type: "envship.encrypted_keyfile.v1",
58
+ keyset_id: keyset.keyset_id,
59
+ label: keyset.label,
60
+ encryption_key_id: keyset.encryption_key_id,
61
+ signing_key_id: keyset.signing_key_id,
62
+ kdf: { name: "PBKDF2", hash: "SHA-256", iterations: 25e4, salt_b64u: encodeBase64url(salt) },
63
+ cipher: { name: "AES-GCM", iv_b64u: encodeBase64url(iv), ciphertext_b64u: encodeBase64url(new Uint8Array(ciphertext)) },
64
+ public_metadata: { encryption_public_jwk: keyset.encryption_public_jwk, signing_public_jwk: keyset.signing_public_jwk },
65
+ created_at: keyset.created_at
66
+ };
67
+ }
68
+ async function generatePlainKeyset(label) {
69
+ const [encryptionPair, signingPair] = await Promise.all([
70
+ crypto.subtle.generateKey(
71
+ { name: "RSA-OAEP", modulusLength: 3072, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" },
72
+ true,
73
+ ["encrypt", "decrypt"]
74
+ ),
75
+ crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"])
76
+ ]);
77
+ const [encryptionPrivateJwk, encryptionPublicJwk, signingPrivateJwk, signingPublicJwk] = await Promise.all([
78
+ crypto.subtle.exportKey("jwk", encryptionPair.privateKey),
79
+ crypto.subtle.exportKey("jwk", encryptionPair.publicKey),
80
+ crypto.subtle.exportKey("jwk", signingPair.privateKey),
81
+ crypto.subtle.exportKey("jwk", signingPair.publicKey)
82
+ ]);
83
+ return {
84
+ type: "envship.private_keyset.v1",
85
+ keyset_id: `kst_${crypto.randomUUID()}`,
86
+ label,
87
+ encryption_key_id: `enc_${(await sha256Base64url(canonicalize(encryptionPublicJwk))).slice(0, 18)}`,
88
+ encryption_private_jwk: encryptionPrivateJwk,
89
+ encryption_public_jwk: encryptionPublicJwk,
90
+ signing_key_id: `sig_${(await sha256Base64url(canonicalize(signingPublicJwk))).slice(0, 18)}`,
91
+ signing_private_jwk: signingPrivateJwk,
92
+ signing_public_jwk: signingPublicJwk,
93
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
94
+ };
95
+ }
96
+ async function unlockKeyFile(keyFile, passphrase) {
97
+ const passphraseKey = await crypto.subtle.importKey("raw", new TextEncoder().encode(passphrase), "PBKDF2", false, ["deriveKey"]);
98
+ const wrappingKey = await crypto.subtle.deriveKey(
99
+ { name: "PBKDF2", salt: ownedBytes(decodeBase64url(keyFile.kdf.salt_b64u)), iterations: keyFile.kdf.iterations, hash: keyFile.kdf.hash },
100
+ passphraseKey,
101
+ { name: "AES-GCM", length: 256 },
102
+ false,
103
+ ["decrypt"]
104
+ );
105
+ const plaintext = await crypto.subtle.decrypt(
106
+ { name: "AES-GCM", iv: ownedBytes(decodeBase64url(keyFile.cipher.iv_b64u)) },
107
+ wrappingKey,
108
+ ownedBytes(decodeBase64url(keyFile.cipher.ciphertext_b64u))
109
+ );
110
+ return JSON.parse(new TextDecoder().decode(plaintext));
111
+ }
112
+ async function encryptEnvBundleForRecipients(env, recipients, channelRef) {
113
+ if (recipients.length === 0) throw new Error("No active recipients are available for this channel.");
114
+ const dataKey = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
115
+ const exportedDataKey = await crypto.subtle.exportKey("raw", dataKey);
116
+ const wrappedRecipients = await Promise.all(recipients.map(async (recipient) => {
117
+ const recipientPublicKey = await crypto.subtle.importKey(
118
+ "jwk",
119
+ recipient.encryption_public_jwk,
120
+ { name: "RSA-OAEP", hash: "SHA-256" },
121
+ false,
122
+ ["encrypt"]
123
+ );
124
+ const wrappedKey = await crypto.subtle.encrypt({ name: "RSA-OAEP" }, recipientPublicKey, exportedDataKey);
125
+ return {
126
+ encryption_key_id: recipient.encryption_key_id,
127
+ wrapped_key_b64u: encodeBase64url(new Uint8Array(wrappedKey))
128
+ };
129
+ }));
130
+ const iv = crypto.getRandomValues(new Uint8Array(12));
131
+ const plaintext = new TextEncoder().encode(JSON.stringify({ type: "envship.env_map.v1", env }));
132
+ const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, dataKey, plaintext);
133
+ return {
134
+ type: "envship.encrypted_env_bundle.v1",
135
+ algorithm: {
136
+ content: "AES-GCM-256",
137
+ recipient_wrap: "RSA-OAEP-SHA-256"
138
+ },
139
+ recipients: wrappedRecipients,
140
+ cipher: {
141
+ iv_b64u: encodeBase64url(iv),
142
+ ciphertext_b64u: encodeBase64url(new Uint8Array(ciphertext))
143
+ },
144
+ aad: {
145
+ channel_ref: channelRef,
146
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
147
+ }
148
+ };
149
+ }
150
+ async function decryptEnvBundle(bundle, keyset) {
151
+ if (bundle.type !== "envship.encrypted_env_bundle.v1") throw new Error("Unsupported EnvShip bundle format.");
152
+ const recipient = bundle.recipients.find((item) => item.encryption_key_id === keyset.encryption_key_id);
153
+ if (!recipient) throw new Error("This KeyFile is not included in the encrypted bundle recipients.");
154
+ const recipientPrivateKey = await crypto.subtle.importKey(
155
+ "jwk",
156
+ keyset.encryption_private_jwk,
157
+ { name: "RSA-OAEP", hash: "SHA-256" },
158
+ false,
159
+ ["decrypt"]
160
+ );
161
+ const rawDataKey = await crypto.subtle.decrypt(
162
+ { name: "RSA-OAEP" },
163
+ recipientPrivateKey,
164
+ ownedBytes(decodeBase64url(recipient.wrapped_key_b64u))
165
+ );
166
+ const dataKey = await crypto.subtle.importKey("raw", rawDataKey, { name: "AES-GCM" }, false, ["decrypt"]);
167
+ const plaintext = await crypto.subtle.decrypt(
168
+ { name: "AES-GCM", iv: ownedBytes(decodeBase64url(bundle.cipher.iv_b64u)) },
169
+ dataKey,
170
+ ownedBytes(decodeBase64url(bundle.cipher.ciphertext_b64u))
171
+ );
172
+ const decoded = JSON.parse(new TextDecoder().decode(plaintext));
173
+ if (decoded.type !== "envship.env_map.v1" || !decoded.env || typeof decoded.env !== "object") {
174
+ throw new Error("Encrypted bundle decrypted, but its plaintext format was invalid.");
175
+ }
176
+ return decoded.env;
177
+ }
178
+ async function signManifest(manifest, keyset) {
179
+ const signingKey = await crypto.subtle.importKey(
180
+ "jwk",
181
+ keyset.signing_private_jwk,
182
+ { name: "ECDSA", namedCurve: "P-256" },
183
+ false,
184
+ ["sign"]
185
+ );
186
+ const signature = await crypto.subtle.sign(
187
+ { name: "ECDSA", hash: "SHA-256" },
188
+ signingKey,
189
+ new TextEncoder().encode(canonicalize(manifest))
190
+ );
191
+ return {
192
+ ...manifest,
193
+ signature: {
194
+ algorithm: "ECDSA-P256-SHA-256",
195
+ signature_b64u: encodeBase64url(new Uint8Array(signature))
196
+ }
197
+ };
198
+ }
199
+ async function verifyRuntimeVersionDescriptorSignature(descriptor, signingPublicJwk) {
200
+ if (!descriptor.signature) return false;
201
+ const { signature, ...payload } = descriptor;
202
+ const expectedPayloadHash = await sha256Base64url(canonicalize({
203
+ type: payload.type,
204
+ package_name: payload.package_name,
205
+ release_channel: payload.release_channel,
206
+ latest_version: payload.latest_version,
207
+ recommended_version: payload.recommended_version,
208
+ minimum_supported_version: payload.minimum_supported_version,
209
+ release_notes_url: payload.release_notes_url,
210
+ generated_at: payload.generated_at,
211
+ compatibility_warnings: payload.compatibility_warnings
212
+ }));
213
+ if (expectedPayloadHash !== payload.payload_sha256_b64u) return false;
214
+ const signingKey = await crypto.subtle.importKey(
215
+ "jwk",
216
+ signingPublicJwk,
217
+ { name: "ECDSA", namedCurve: "P-256" },
218
+ false,
219
+ ["verify"]
220
+ );
221
+ return crypto.subtle.verify(
222
+ { name: "ECDSA", hash: "SHA-256" },
223
+ signingKey,
224
+ ownedBytes(decodeBase64url(signature.signature_b64u)),
225
+ new TextEncoder().encode(canonicalize(payload))
226
+ );
227
+ }
228
+ function mergeEnvOverlay(base, overlay) {
229
+ return { ...base, ...overlay };
230
+ }
231
+ async function deriveDeployTokenSnapshotKey(tokenSecret, lookupId, usages) {
232
+ const input = await crypto.subtle.importKey("raw", new TextEncoder().encode(tokenSecret), "HKDF", false, ["deriveKey"]);
233
+ return crypto.subtle.deriveKey(
234
+ { name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode(lookupId), info: new TextEncoder().encode("envship deploy token runtime snapshot v1") },
235
+ input,
236
+ { name: "AES-GCM", length: 256 },
237
+ false,
238
+ usages
239
+ );
240
+ }
241
+ async function decryptDeployTokenRuntimeSnapshot(encrypted, tokenSecret) {
242
+ const key = await deriveDeployTokenSnapshotKey(tokenSecret, encrypted.lookup_id, ["decrypt"]);
243
+ const plaintext = await crypto.subtle.decrypt(
244
+ { name: "AES-GCM", iv: ownedBytes(decodeBase64url(encrypted.cipher.iv_b64u)), additionalData: new TextEncoder().encode(canonicalize(encrypted.aad)) },
245
+ key,
246
+ ownedBytes(decodeBase64url(encrypted.cipher.ciphertext_b64u))
247
+ );
248
+ return JSON.parse(new TextDecoder().decode(plaintext));
249
+ }
250
+ async function generateMachineSigningKey(label) {
251
+ const signingPair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
252
+ const [privateJwk, publicJwk] = await Promise.all([
253
+ crypto.subtle.exportKey("jwk", signingPair.privateKey),
254
+ crypto.subtle.exportKey("jwk", signingPair.publicKey)
255
+ ]);
256
+ return {
257
+ signing_key_id: `msig_${(await sha256Base64url(canonicalize(publicJwk))).slice(0, 18)}`,
258
+ signing_private_jwk: privateJwk,
259
+ signing_public_jwk: publicJwk,
260
+ label,
261
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
262
+ };
263
+ }
264
+ async function signMachineRequest(input, signingPrivateJwk) {
265
+ const signingKey = await crypto.subtle.importKey(
266
+ "jwk",
267
+ signingPrivateJwk,
268
+ { name: "ECDSA", namedCurve: "P-256" },
269
+ false,
270
+ ["sign"]
271
+ );
272
+ const signature = await crypto.subtle.sign(
273
+ { name: "ECDSA", hash: "SHA-256" },
274
+ signingKey,
275
+ new TextEncoder().encode(canonicalize(input))
276
+ );
277
+ return encodeBase64url(new Uint8Array(signature));
278
+ }
279
+ function parseDotenv(input) {
280
+ const env = {};
281
+ for (const rawLine of input.split(/\r?\n/u)) {
282
+ const line = rawLine.trim();
283
+ if (!line || line.startsWith("#")) continue;
284
+ const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u.exec(line);
285
+ if (!match) throw new Error(`Invalid dotenv line: ${rawLine}`);
286
+ let value = match[2] ?? "";
287
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
288
+ value = value.slice(1, -1);
289
+ }
290
+ env[match[1]] = value;
291
+ }
292
+ return env;
293
+ }
294
+ function stringifyDotenv(env) {
295
+ return Object.entries(env).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `${key}=${/[\s#"']/u.test(value) ? JSON.stringify(value) : value}`).join("\n") + "\n";
296
+ }
297
+ function sortJson(value) {
298
+ if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
299
+ return value;
300
+ }
301
+ if (Array.isArray(value)) return value.map(sortJson);
302
+ if (typeof value === "object") {
303
+ return Object.fromEntries(
304
+ Object.entries(value).filter(([, item]) => item !== void 0).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, sortJson(item)])
305
+ );
306
+ }
307
+ throw new TypeError(`Unsupported canonical JSON value: ${typeof value}`);
308
+ }
309
+
310
+ // src/cliCore.ts
311
+ function channelParts(channel2) {
312
+ const [bundle, name, extra] = channel2.split("/");
313
+ if (!bundle || !name || extra) throw new Error("Use channel format bundle/channel, for example web/staging.");
314
+ if (!/^[a-z0-9][a-z0-9_-]{0,63}$/u.test(bundle) || !/^[a-z0-9][a-z0-9_-]{0,63}$/u.test(name)) {
315
+ throw new Error("Channel names must use lowercase letters, numbers, underscores, or hyphens.");
316
+ }
317
+ return { bundle, name };
318
+ }
319
+ function isLocalTestApi(apiBaseUrl) {
320
+ try {
321
+ const hostname = new URL(apiBaseUrl).hostname;
322
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname.endsWith(".test");
323
+ } catch {
324
+ return false;
325
+ }
326
+ }
327
+ function redactCliError(message) {
328
+ return message.replace(/([A-Z_]*(?:SECRET|TOKEN|KEY|COOKIE|SESSION|PASSWORD|PASSPHRASE)[A-Z0-9_]*=)[^\s]+/giu, "$1[redacted]").replace(/((?:passphrase|password|token|session|cookie)(?:=|:)\s*)[^\s]+/giu, "$1[redacted]").replace(/("(?:d|p|q|dp|dq|qi|k|private_jwk|signing_private_jwk|session|cookie|token|passphrase|password)"\s*:\s*")[^"]+/giu, "$1[redacted]");
329
+ }
330
+ function isSensitiveKey(key) {
331
+ return /(^|_)(session|cookie|token|secret|password|passphrase)$/iu.test(key) || key === "signing_private_jwk" || key === "private_jwk";
332
+ }
333
+ function looksLikeDotenvSecret(key) {
334
+ return /^[A-Z_][A-Z0-9_]*(SECRET|TOKEN|KEY|COOKIE|SESSION|PASSWORD|PASSPHRASE)[A-Z0-9_]*$/u.test(key);
335
+ }
336
+ function isShowOnceDeployToken(path, key) {
337
+ return key === "token" && path.at(-1) === "deploy_token";
338
+ }
339
+ function sanitizeCliData(value, parentKey = "", path = []) {
340
+ if (value === null || value === void 0) return value;
341
+ if (typeof value === "string") {
342
+ if (isSensitiveKey(parentKey) || looksLikeDotenvSecret(parentKey)) return "[redacted]";
343
+ return redactCliError(value);
344
+ }
345
+ if (typeof value !== "object") return value;
346
+ if (Array.isArray(value)) return value.map((item) => sanitizeCliData(item, parentKey));
347
+ const output = {};
348
+ for (const [key, item] of Object.entries(value)) {
349
+ if (isShowOnceDeployToken(path, key)) {
350
+ output[key] = item;
351
+ } else if (key === "deploy_token") {
352
+ output[key] = sanitizeCliData(item, key, [...path, key]);
353
+ } else {
354
+ output[key] = isSensitiveKey(key) || looksLikeDotenvSecret(key) ? "[redacted]" : sanitizeCliData(item, key, [...path, key]);
355
+ }
356
+ }
357
+ return output;
358
+ }
359
+ function errorCodeFromMessage(message) {
360
+ const normalized = message.toLowerCase();
361
+ if (normalized.includes("unauthenticated") || normalized.includes("401")) return "unauthenticated";
362
+ if (normalized.includes("forbidden") || normalized.includes("403")) return "forbidden";
363
+ if (normalized.includes("not found") || normalized.includes("not_found") || normalized.includes("404")) return "not_found";
364
+ if (normalized.includes("rate_limited") || normalized.includes("rate limited")) return "rate_limited";
365
+ if (normalized.includes("passphrase")) return "passphrase_required";
366
+ if (normalized.includes("keyfile")) return "keyfile_error";
367
+ return normalized.replace(/[^a-z0-9]+/gu, "_").replace(/^_|_$/gu, "").slice(0, 64) || "cli_error";
368
+ }
369
+
370
+ // src/index.ts
371
+ var CliError = class extends Error {
372
+ code;
373
+ hint;
374
+ retryAfterSeconds;
375
+ exitCode;
376
+ details;
377
+ constructor(code, message, options = {}) {
378
+ super(message);
379
+ this.code = code;
380
+ this.hint = options.hint;
381
+ this.retryAfterSeconds = options.retryAfterSeconds;
382
+ this.exitCode = options.exitCode ?? 1;
383
+ this.details = options.details;
384
+ }
385
+ };
386
+ var ChildExitError = class extends CliError {
387
+ constructor(code, signal) {
388
+ super("child_process_failed", signal ? `Command terminated by ${signal}.` : `Command exited with ${code ?? 1}.`, { exitCode: code ?? 1 });
389
+ }
390
+ };
391
+ var defaultApiBaseUrl = process.env.ENVSHIP_API_BASE_URL ?? "https://api.envship.com";
392
+ var defaultAppBaseUrl = process.env.ENVSHIP_APP_URL ?? "https://envship.com";
393
+ var configPath = join(homedir(), ".envship", "config.json");
394
+ var program = new Command();
395
+ var sessionCookieName = "__Host-envship_session";
396
+ var cliVersion = "0.2.0";
397
+ function isMutationMethod(method) {
398
+ return ["POST", "PUT", "DELETE"].includes((method ?? "GET").toUpperCase());
399
+ }
400
+ function readConfig() {
401
+ if (!existsSync(configPath)) return { apiBaseUrl: defaultApiBaseUrl, appBaseUrl: defaultAppBaseUrl };
402
+ try {
403
+ const value = JSON.parse(readFileSync(configPath, "utf8"));
404
+ return {
405
+ apiBaseUrl: process.env.ENVSHIP_API_BASE_URL ?? value.apiBaseUrl ?? defaultApiBaseUrl,
406
+ appBaseUrl: process.env.ENVSHIP_APP_URL ?? value.appBaseUrl ?? defaultAppBaseUrl,
407
+ session: process.env.ENVSHIP_SESSION ?? value.session,
408
+ workstation: value.workstation,
409
+ keyfilePath: process.env.ENVSHIP_KEYFILE ?? value.keyfilePath,
410
+ machine: value.machine,
411
+ envsync: value.envsync
412
+ };
413
+ } catch {
414
+ return { apiBaseUrl: defaultApiBaseUrl, appBaseUrl: defaultAppBaseUrl };
415
+ }
416
+ }
417
+ function localWorkstation(config) {
418
+ if (config.workstation?.id && /^[A-Za-z0-9_-]{16,96}$/u.test(config.workstation.id)) return config.workstation;
419
+ return {
420
+ id: `cw_${crypto.randomUUID().replace(/-/gu, "")}`,
421
+ label: `${process.env.COMPUTERNAME ?? process.env.HOSTNAME ?? "local"}`
422
+ };
423
+ }
424
+ function runtimeAnalyticsContext(config) {
425
+ if (config.machine?.analytics_client_id && /^acl_[A-Za-z0-9_-]{32,96}$/u.test(config.machine.analytics_client_id)) {
426
+ return { clientId: config.machine.analytics_client_id, type: "machine" };
427
+ }
428
+ if (config.workstation?.analytics_client_id && /^acl_[A-Za-z0-9_-]{32,96}$/u.test(config.workstation.analytics_client_id)) {
429
+ return { clientId: config.workstation.analytics_client_id, type: "workstation" };
430
+ }
431
+ return null;
432
+ }
433
+ function decorateRuntimeCdnUrl(parsedUrl) {
434
+ if (!["/p/", "/v/", "/rt/"].some((prefix) => parsedUrl.pathname.startsWith(prefix))) return;
435
+ const analytics = runtimeAnalyticsContext(readConfig());
436
+ if (!analytics) return;
437
+ parsedUrl.searchParams.set("es_client", analytics.clientId);
438
+ parsedUrl.searchParams.set("es_type", analytics.type);
439
+ parsedUrl.searchParams.set("es_v", "1");
440
+ parsedUrl.searchParams.set("es_cli_v", cliVersion);
441
+ }
442
+ function parseDeployTokenV2(token) {
443
+ const match = /^edt2_([A-Za-z0-9_-]{16,96})_([A-Za-z0-9_-]{32,160})$/u.exec(token);
444
+ return match ? { lookupId: match[1], secret: match[2] } : null;
445
+ }
446
+ function cdnBaseFromApi(apiBaseUrl) {
447
+ const url = new URL(apiBaseUrl);
448
+ if (url.hostname === "api-staging.envship.com") return "https://cdnstaging.envship.com";
449
+ if (url.hostname === "api.envship.com") return "https://cdn.envship.com";
450
+ return `${url.protocol}//${url.host}`;
451
+ }
452
+ function deployTokenSnapshotUrl(apiBaseUrl, lookupId) {
453
+ return `${cdnBaseFromApi(apiBaseUrl).replace(/\/+$/u, "")}/rt/deploy-tokens/${lookupId}.json`;
454
+ }
455
+ function runtimeVersionDescriptorUrl(apiBaseUrl) {
456
+ return `${cdnBaseFromApi(apiBaseUrl).replace(/\/+$/u, "")}/meta/cli/current.json`;
457
+ }
458
+ function compareVersions(a, b) {
459
+ const left = a.split(/[.-]/u).map((part) => Number.parseInt(part, 10)).map((part) => Number.isFinite(part) ? part : 0);
460
+ const right = b.split(/[.-]/u).map((part) => Number.parseInt(part, 10)).map((part) => Number.isFinite(part) ? part : 0);
461
+ const length = Math.max(left.length, right.length, 3);
462
+ for (let index = 0; index < length; index += 1) {
463
+ const delta = (left[index] ?? 0) - (right[index] ?? 0);
464
+ if (delta !== 0) return delta < 0 ? -1 : 1;
465
+ }
466
+ return 0;
467
+ }
468
+ async function runtimeVersionDescriptor(ctx) {
469
+ const response = await fetchPublicJson(ctx, runtimeVersionDescriptorUrl(ctx.apiBaseUrl));
470
+ const descriptor = response.body;
471
+ if (!descriptor || descriptor.type !== "envship.runtime_version_descriptor.v1" || descriptor.package_name !== "@envshipcom/cli") {
472
+ throw new CliError("runtime_version_descriptor_invalid", "EnvShip runtime version metadata is invalid.");
473
+ }
474
+ const publicJwk = process.env.ENVSHIP_RUNTIME_VERSION_SIGNING_PUBLIC_JWK;
475
+ if (publicJwk) {
476
+ const ok = await verifyRuntimeVersionDescriptorSignature(descriptor, JSON.parse(publicJwk));
477
+ if (!ok) throw new CliError("runtime_version_signature_invalid", "EnvShip runtime version metadata signature is invalid.");
478
+ }
479
+ return descriptor;
480
+ }
481
+ async function versionWarnings(ctx) {
482
+ try {
483
+ const descriptor = await runtimeVersionDescriptor(ctx);
484
+ const warnings = [];
485
+ if (compareVersions(cliVersion, descriptor.minimum_supported_version) < 0) {
486
+ throw new CliError("cli_version_unsupported", `EnvShip CLI ${cliVersion} is below the minimum supported version ${descriptor.minimum_supported_version}.`, {
487
+ hint: `Update @envshipcom/cli to ${descriptor.recommended_version} or newer.`
488
+ });
489
+ }
490
+ if (compareVersions(cliVersion, descriptor.recommended_version) < 0) {
491
+ warnings.push(`EnvShip CLI ${cliVersion} is behind recommended ${descriptor.recommended_version}. Run your package manager update when convenient.`);
492
+ }
493
+ return { descriptor, warnings };
494
+ } catch (error) {
495
+ if (error instanceof CliError && error.code === "cli_version_unsupported") throw error;
496
+ return { descriptor: null, warnings: [] };
497
+ }
498
+ }
499
+ async function saveConfig(config) {
500
+ await mkdir(dirname(configPath), { recursive: true, mode: 448 });
501
+ await chmod(dirname(configPath), 448).catch(() => void 0);
502
+ await writeFile(configPath, JSON.stringify(config, null, 2), { mode: 384 });
503
+ await chmod(configPath, 384).catch(() => void 0);
504
+ }
505
+ function openBrowser(url) {
506
+ const platform = process.platform;
507
+ const command2 = platform === "win32" ? "cmd" : platform === "darwin" ? "open" : "xdg-open";
508
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
509
+ spawn(command2, args, { detached: true, stdio: "ignore" }).unref();
510
+ }
511
+ function commandPath(command2) {
512
+ const names = [];
513
+ let current = command2;
514
+ while (current) {
515
+ if (current.name()) names.unshift(current.name());
516
+ current = current.parent ?? null;
517
+ }
518
+ return names.join(" ");
519
+ }
520
+ function toBoolean(value) {
521
+ return value === true;
522
+ }
523
+ function createContext(command2) {
524
+ const config = readConfig();
525
+ const options = command2.optsWithGlobals();
526
+ const apiBaseUrl = validateOriginUrl(String(options.apiUrl ?? config.apiBaseUrl), "api-url");
527
+ const appBaseUrl = validateOriginUrl(String(options.appUrl ?? config.appBaseUrl), "app-url");
528
+ return {
529
+ command: command2,
530
+ commandName: commandPath(command2),
531
+ options,
532
+ json: toBoolean(options.json),
533
+ quiet: toBoolean(options.quiet),
534
+ noInput: toBoolean(options.noInput),
535
+ yes: toBoolean(options.yes),
536
+ apiBaseUrl,
537
+ appBaseUrl,
538
+ info(message) {
539
+ if (!toBoolean(options.quiet) && !toBoolean(options.json)) process.stderr.write(`${message}
540
+ `);
541
+ },
542
+ data(message) {
543
+ process.stdout.write(`${message.endsWith("\n") ? message : `${message}
544
+ `}`);
545
+ }
546
+ };
547
+ }
548
+ function validateOriginUrl(value, label) {
549
+ let url;
550
+ try {
551
+ url = new URL(value);
552
+ } catch {
553
+ throw new CliError(`${label.replace("-", "_")}_invalid`, `${label} must be a valid http or https origin.`);
554
+ }
555
+ if (!["http:", "https:"].includes(url.protocol)) throw new CliError(`${label.replace("-", "_")}_invalid`, `${label} must use http or https.`);
556
+ if (url.username || url.password || url.search || url.hash || url.pathname && url.pathname !== "/") {
557
+ throw new CliError(`${label.replace("-", "_")}_invalid`, `${label} must be an origin only, for example https://api.envship.com.`);
558
+ }
559
+ return url.origin;
560
+ }
561
+ function validateEnvVarName(value) {
562
+ if (value && !/^[A-Z_][A-Z0-9_]{0,127}$/u.test(value)) {
563
+ throw new CliError("env_var_name_invalid", "Environment variable names must use uppercase letters, numbers, and underscores.");
564
+ }
565
+ }
566
+ function validateCliBootstrapCode(value) {
567
+ if (!/^cli_[A-Za-z0-9_-]{20,80}$/u.test(value)) {
568
+ throw new CliError("cli_bootstrap_code_invalid", "CLI setup codes must start with cli_ and contain only URL-safe characters.");
569
+ }
570
+ return value;
571
+ }
572
+ function validateSafeLocalPath(path, options) {
573
+ if (path === "-") {
574
+ if (options.allowStdout) return path;
575
+ throw new CliError(`${options.label}_invalid`, `${options.label} cannot be stdout here.`);
576
+ }
577
+ const hasUrlScheme = /^[a-z][a-z0-9+.-]*:/iu.test(path) && !/^[a-z]:[\\/]/iu.test(path);
578
+ if (path.includes("\0") || hasUrlScheme) throw new CliError(`${options.label}_invalid`, `${options.label} must be a local filesystem path.`);
579
+ const normalized = normalize(path);
580
+ const segments = normalized.split(/[\\/]+/u);
581
+ if (!options.force && !isAbsolute(normalized) && segments.includes("..")) {
582
+ throw new CliError("path_traversal_requires_force", `${options.label} uses a parent-directory segment.`, { hint: "Use an explicit output path inside this workspace, or rerun with --force after verifying the target." });
583
+ }
584
+ return resolve(path);
585
+ }
586
+ function validateChildCommand(commandName, args) {
587
+ const parsed = parse(commandName);
588
+ if (!parsed.base || parsed.dir.includes("..") || /[;&|`$<>^"\n\r]/u.test(commandName)) {
589
+ throw new CliError("command_invalid", "Command name contains unsafe shell metacharacters or path traversal.");
590
+ }
591
+ for (const arg of args) {
592
+ if (arg.includes("\0")) throw new CliError("command_arg_invalid", "Command arguments cannot contain NUL bytes.");
593
+ }
594
+ }
595
+ function shouldUseWindowsShell(commandName) {
596
+ return process.platform === "win32" && !/\.(?:exe|com)$/iu.test(commandName);
597
+ }
598
+ function spawnSafe(commandName, args, options) {
599
+ if (shouldUseWindowsShell(commandName)) {
600
+ return spawn("cmd.exe", ["/d", "/c", commandName, ...args], { ...options, shell: false });
601
+ }
602
+ return spawn(commandName, args, { ...options, shell: false });
603
+ }
604
+ function writeJson(value, stream = process.stdout) {
605
+ stream.write(`${JSON.stringify(value, null, 2)}
606
+ `);
607
+ }
608
+ function commandAction(handler) {
609
+ return async (...args) => {
610
+ const command2 = args.at(-1);
611
+ let ctx;
612
+ try {
613
+ ctx = createContext(command2);
614
+ const result = await handler(ctx, ...args.slice(0, -1));
615
+ const warnings = result?.warnings ?? [];
616
+ if (ctx.json) {
617
+ writeJson({ ok: true, command: ctx.commandName, data: sanitizeCliData(result?.data ?? null), warnings: sanitizeCliData(warnings) });
618
+ if (result?.exitCode) process.exitCode = result.exitCode;
619
+ return;
620
+ }
621
+ if (result?.stdout) ctx.data(result.stdout);
622
+ for (const warning of warnings) ctx.info(`Warning: ${warning}`);
623
+ if (result?.message) ctx.info(result.message);
624
+ if (result?.exitCode) process.exitCode = result.exitCode;
625
+ } catch (error) {
626
+ const cliError = normalizeError(error);
627
+ const json = (() => {
628
+ try {
629
+ return toBoolean(command2.optsWithGlobals().json);
630
+ } catch {
631
+ return false;
632
+ }
633
+ })();
634
+ if (json) {
635
+ writeJson({
636
+ ok: false,
637
+ error: cliError.code,
638
+ message: redactCliError(cliError.message),
639
+ hint: cliError.hint ? redactCliError(cliError.hint) : void 0,
640
+ retry_after_seconds: cliError.retryAfterSeconds
641
+ }, process.stderr);
642
+ } else {
643
+ process.stderr.write(`${redactCliError(cliError.message)}
644
+ `);
645
+ if (cliError.hint) process.stderr.write(`Hint: ${redactCliError(cliError.hint)}
646
+ `);
647
+ }
648
+ process.exitCode = cliError.exitCode;
649
+ }
650
+ };
651
+ }
652
+ function normalizeError(error) {
653
+ if (error instanceof CliError) return error;
654
+ const message = error instanceof Error ? error.message : String(error);
655
+ return new CliError(errorCodeFromMessage(message), message);
656
+ }
657
+ function addGlobalOptions(command2) {
658
+ return command2.option("--json", "write a stable JSON response").option("--quiet", "suppress human progress messages").option("--no-input", "disable interactive prompts").option("--yes", "confirm destructive or risky actions").option("--api-url <url>", "EnvShip API origin").option("--app-url <url>", "EnvShip app origin");
659
+ }
660
+ function addKeyFileOptions(command2) {
661
+ return command2.addOption(new Option("--keyfile <path>", "encrypted credential path").hideHelp()).addOption(new Option("--passphrase-env <name>", "environment variable containing the credential passphrase").hideHelp()).addOption(new Option("--passphrase <value>", "credential passphrase").hideHelp());
662
+ }
663
+ function withExamples(command2, examples) {
664
+ command2.addHelpText("after", `
665
+ Examples:
666
+ ${examples.map((item) => ` ${item}`).join("\n")}
667
+
668
+ Docs: https://envship.com/docs
669
+ Support: https://github.com/envshipcom/envship/issues
670
+ `);
671
+ return command2;
672
+ }
673
+ async function api(ctx, path, init = {}) {
674
+ const config = readConfig();
675
+ const headers = new Headers(init.headers);
676
+ if (isLocalTestApi(ctx.apiBaseUrl) && !headers.has("Origin")) headers.set("Origin", ctx.appBaseUrl);
677
+ if (!headers.has("Content-Type") && init.body) headers.set("Content-Type", "application/json");
678
+ if (config.session) {
679
+ headers.set("Cookie", `${sessionCookieName}=${config.session}`);
680
+ if (isMutationMethod(init.method)) headers.set("X-EnvShip-CSRF", "1");
681
+ }
682
+ const response = await fetch(`${ctx.apiBaseUrl}${path}`, { ...init, headers });
683
+ const body = await response.json().catch(() => ({}));
684
+ if (!response.ok) {
685
+ throw new CliError(body.error ?? `http_${response.status}`, body.error ?? `EnvShip API failed: ${response.status}`, {
686
+ retryAfterSeconds: body.retry_after_seconds,
687
+ hint: response.status === 401 ? "Run envship auth login, or set ENVSHIP_SESSION for automation." : void 0,
688
+ details: body
689
+ });
690
+ }
691
+ return body;
692
+ }
693
+ async function machineHeaders(path, method = "GET", body = "") {
694
+ const config = readConfig();
695
+ if (!config.machine) return new Headers();
696
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
697
+ const nonce = crypto.randomUUID();
698
+ const bodyHash = await sha256Base64url(body);
699
+ const signature = await signMachineRequest({
700
+ method: method.toUpperCase(),
701
+ path,
702
+ body_sha256_b64u: bodyHash,
703
+ timestamp,
704
+ nonce
705
+ }, config.machine.signing_private_jwk);
706
+ const headers = new Headers();
707
+ headers.set("X-EnvShip-Key-Id", config.machine.signing_key_id);
708
+ headers.set("X-EnvShip-Timestamp", timestamp);
709
+ headers.set("X-EnvShip-Nonce", nonce);
710
+ headers.set("X-EnvShip-Body-SHA256", bodyHash);
711
+ headers.set("X-EnvShip-Signature", signature);
712
+ return headers;
713
+ }
714
+ async function reportRuntimeClient(ctx, input = {}) {
715
+ const config = readConfig();
716
+ const analytics = runtimeAnalyticsContext(config);
717
+ if (!analytics) return;
718
+ const workspaceId = config.workstation?.workspace_id ?? config.machine?.workspace_id ?? null;
719
+ const body = {
720
+ analytics_client_id: analytics.clientId,
721
+ workspace_id: workspaceId,
722
+ client_version: cliVersion,
723
+ last_deploy_sync_at: input.lastDeploySyncAt,
724
+ autoupdate_enabled: analytics.type === "machine" ? config.machine?.autoupdate_enabled !== false : config.workstation?.autoupdate_enabled !== false,
725
+ envsync_mode: input.envsyncMode,
726
+ envsync_interval_seconds: input.envsyncIntervalSeconds
727
+ };
728
+ if (!body.workspace_id) return;
729
+ const headers = analytics.type === "machine" ? await machineHeaders("/v1/runtime-clients/report", "POST", JSON.stringify(body)) : new Headers();
730
+ try {
731
+ await api(ctx, "/v1/runtime-clients/report", { method: "POST", headers, body: JSON.stringify(body) });
732
+ } catch {
733
+ }
734
+ }
735
+ async function fetchJson(ctx, url) {
736
+ const parsedUrl = new URL(url);
737
+ if (parsedUrl.hostname === "api.envship.com" && ctx.apiBaseUrl !== "https://api.envship.com") {
738
+ const localBase = new URL(ctx.apiBaseUrl);
739
+ parsedUrl.protocol = localBase.protocol;
740
+ parsedUrl.host = localBase.host;
741
+ }
742
+ decorateRuntimeCdnUrl(parsedUrl);
743
+ const headers = parsedUrl.pathname.startsWith("/v1/bundle-objects/") ? await machineHeaders(`${parsedUrl.pathname}${parsedUrl.search}`) : new Headers();
744
+ if (isLocalTestApi(ctx.apiBaseUrl) && !headers.has("Origin")) headers.set("Origin", ctx.appBaseUrl);
745
+ const config = readConfig();
746
+ if (config.session) {
747
+ headers.set("Cookie", `${sessionCookieName}=${config.session}`);
748
+ }
749
+ const response = await fetch(parsedUrl.toString(), { headers });
750
+ const body = await response.json().catch(() => ({}));
751
+ if (!response.ok) throw new CliError(body.error ?? `http_${response.status}`, body.error ?? `EnvShip download failed: ${response.status}`);
752
+ return body;
753
+ }
754
+ async function fetchPublicJson(ctx, url, headers) {
755
+ const parsedUrl = new URL(url);
756
+ if (parsedUrl.hostname === "api.envship.com" && ctx.apiBaseUrl !== "https://api.envship.com") {
757
+ const localBase = new URL(ctx.apiBaseUrl);
758
+ parsedUrl.protocol = localBase.protocol;
759
+ parsedUrl.host = localBase.host;
760
+ }
761
+ decorateRuntimeCdnUrl(parsedUrl);
762
+ const response = await fetch(parsedUrl.toString(), { headers });
763
+ if (response.status === 304) {
764
+ return { status: 304, body: null, etag: response.headers.get("etag"), lastModified: response.headers.get("last-modified") };
765
+ }
766
+ const body = await response.json().catch(() => null);
767
+ if (!response.ok) {
768
+ const maybeError = body && typeof body === "object" && "error" in body ? String(body.error) : `http_${response.status}`;
769
+ throw new CliError(maybeError, `EnvShip CDN fetch failed: ${response.status}`);
770
+ }
771
+ return { status: response.status, body, etag: response.headers.get("etag"), lastModified: response.headers.get("last-modified") };
772
+ }
773
+ async function promptLine(message) {
774
+ process.stderr.write(message);
775
+ return new Promise((resolvePromise) => {
776
+ process.stdin.resume();
777
+ process.stdin.once("data", (data) => {
778
+ process.stdin.pause();
779
+ resolvePromise(String(data).trim());
780
+ });
781
+ });
782
+ }
783
+ async function promptHidden(message) {
784
+ if (!process.stdin.isTTY || !process.stdin.setRawMode) return promptLine(message);
785
+ process.stderr.write(message);
786
+ process.stdin.setRawMode(true);
787
+ process.stdin.resume();
788
+ return new Promise((resolvePromise, reject) => {
789
+ let value = "";
790
+ const onData = (buffer) => {
791
+ const text = buffer.toString("utf8");
792
+ if (text === "") {
793
+ process.stdin.setRawMode(false);
794
+ process.stdin.pause();
795
+ process.stderr.write("\n");
796
+ reject(new CliError("cancelled", "Cancelled."));
797
+ return;
798
+ }
799
+ if (text === "\r" || text === "\n") {
800
+ process.stdin.setRawMode(false);
801
+ process.stdin.pause();
802
+ process.stdin.off("data", onData);
803
+ process.stderr.write("\n");
804
+ resolvePromise(value);
805
+ return;
806
+ }
807
+ if (text === "\x7F") value = value.slice(0, -1);
808
+ else value += text;
809
+ };
810
+ process.stdin.on("data", onData);
811
+ });
812
+ }
813
+ async function resolvePassphrase(ctx, options) {
814
+ validateEnvVarName(options.passphraseEnv);
815
+ if (options.passphraseEnv) {
816
+ const value = process.env[options.passphraseEnv];
817
+ if (!value) throw new CliError("passphrase_env_empty", `Environment variable ${options.passphraseEnv} is not set.`, { hint: "Set it or use an interactive terminal." });
818
+ return value;
819
+ }
820
+ if (options.passphrase) return options.passphrase;
821
+ if (process.env.ENVSHIP_KEYFILE_PASSPHRASE) return process.env.ENVSHIP_KEYFILE_PASSPHRASE;
822
+ if (ctx.noInput || !process.stdin.isTTY) {
823
+ throw new CliError("passphrase_required", "A KeyFile passphrase is required.", { hint: "Use --passphrase-env ENVSHIP_KEYFILE_PASSPHRASE or set ENVSHIP_KEYFILE_PASSPHRASE." });
824
+ }
825
+ return promptHidden("KeyFile passphrase: ");
826
+ }
827
+ async function loadKeyFile(ctx, options) {
828
+ const config = readConfig();
829
+ const keyfilePath = options.keyfile ?? config.keyfilePath;
830
+ if (!keyfilePath) throw new CliError("keyfile_required", "A KeyFile path is required.", { hint: "Use --keyfile or set ENVSHIP_KEYFILE." });
831
+ const keyFile = JSON.parse(await readFile(validateSafeLocalPath(keyfilePath, { label: "keyfile" }), "utf8"));
832
+ const keyset = await unlockKeyFile(keyFile, await resolvePassphrase(ctx, options));
833
+ return { keyFile, keyset, keyfilePath: resolve(keyfilePath) };
834
+ }
835
+ async function readKeyFile(path) {
836
+ const config = readConfig();
837
+ const keyfilePath = path ?? config.keyfilePath;
838
+ if (!keyfilePath) throw new CliError("keyfile_required", "A KeyFile path is required.", { hint: "Use --keyfile or set ENVSHIP_KEYFILE." });
839
+ const resolved = validateSafeLocalPath(keyfilePath, { label: "keyfile" });
840
+ return { keyFile: JSON.parse(await readFile(resolved, "utf8")), keyfilePath: resolved };
841
+ }
842
+ async function findChannel(ctx, channel2) {
843
+ channelParts(channel2);
844
+ const state = await dashboardState(ctx);
845
+ const found = state.channels.find((item) => item.channel_ref === channel2);
846
+ if (!found) throw new CliError("channel_not_found", `Channel ${channel2} was not found or is not visible to this session.`);
847
+ return found;
848
+ }
849
+ async function dashboardState(ctx) {
850
+ return api(ctx, "/v1/dashboard/state");
851
+ }
852
+ async function resolveChannel(ctx, channel2) {
853
+ const path = `/v1/channel-resolve?channel=${encodeURIComponent(channel2)}`;
854
+ const config = readConfig();
855
+ const headers = config.machine ? await machineHeaders(path) : new Headers();
856
+ return api(ctx, path, { headers });
857
+ }
858
+ async function publicPullDescriptor(ctx, url) {
859
+ return fetchJson(ctx, url);
860
+ }
861
+ async function publicManifest(ctx, url) {
862
+ return fetchJson(ctx, url);
863
+ }
864
+ async function deployTokenRuntimeSnapshot(ctx, token) {
865
+ const parsed = parseDeployTokenV2(token);
866
+ if (!parsed) throw new CliError("deploy_token_v2_required", "Offline deploy-token bootstrap requires a v2 deploy token.");
867
+ const raw = await fetchJson(ctx, deployTokenSnapshotUrl(ctx.apiBaseUrl, parsed.lookupId));
868
+ if (raw.type === "envship.deploy_token_runtime_snapshot_revoked.v1") {
869
+ throw new CliError("deploy_token_revoked", "Deploy token has been revoked.");
870
+ }
871
+ if (raw.type !== "envship.deploy_token_runtime_snapshot.v1") {
872
+ throw new CliError("runtime_snapshot_invalid", "Deploy token runtime snapshot format is invalid.");
873
+ }
874
+ const encrypted = raw;
875
+ const secretHash = await sha256Base64url(parsed.secret);
876
+ const snapshot = await decryptDeployTokenRuntimeSnapshot(encrypted, secretHash);
877
+ if (snapshot.status !== "active" || snapshot.subject_type !== "deploy_token") {
878
+ throw new CliError("runtime_snapshot_revoked", "Deploy token runtime snapshot is not active.");
879
+ }
880
+ return { snapshot, snapshotUrl: deployTokenSnapshotUrl(ctx.apiBaseUrl, parsed.lookupId) };
881
+ }
882
+ async function resolutionFromRuntimeChannels(ctx, channel2, channels) {
883
+ const cached = channels.find((item) => item.channel_ref === channel2);
884
+ if (!cached) throw new CliError("channel_not_authorized", `Channel ${channel2} is not available in the local runtime snapshot.`);
885
+ const descriptor = await publicPullDescriptor(ctx, cached.descriptor_url);
886
+ const manifest = await publicManifest(ctx, descriptor.manifest_url);
887
+ return {
888
+ channel_ref: descriptor.channel_ref,
889
+ version_id: descriptor.current_version_id,
890
+ version: descriptor.version,
891
+ manifest,
892
+ bundle_sha256_b64u: descriptor.bundle_sha256_b64u,
893
+ bundle_url: descriptor.bundle_url,
894
+ public_descriptor_url: cached.descriptor_url,
895
+ public_bundle_url: descriptor.bundle_url,
896
+ snapshot_updated_at: descriptor.updated_at
897
+ };
898
+ }
899
+ async function identityOverlay(ctx, channel2, keyset) {
900
+ const config = readConfig();
901
+ const identity = config.machine ? { type: "machine", id: config.machine.machine_id, workspaceId: config.machine.workspace_id ?? null } : config.workstation ? { type: "workstation", id: config.workstation.id, workspaceId: config.workstation.workspace_id ?? null } : null;
902
+ if (!identity?.workspaceId) return null;
903
+ const path = `/v1/channel-overlays?channel=${encodeURIComponent(channel2)}&workspace_id=${encodeURIComponent(identity.workspaceId)}&identity_type=${identity.type}&identity_id=${encodeURIComponent(identity.id)}`;
904
+ const headers = config.machine ? await machineHeaders(path) : new Headers();
905
+ const response = await api(ctx, path, { headers });
906
+ if (!response.overlay) return null;
907
+ const hash = await sha256Base64url(canonicalize(response.overlay.overlay_bundle));
908
+ if (hash !== response.overlay.overlay_sha256_b64u) throw new CliError("identity_overlay_hash_mismatch", "Identity overlay hash mismatch. Refusing to merge overlay.");
909
+ const env = await decryptEnvBundle(response.overlay.overlay_bundle, keyset);
910
+ return { env, version: response.overlay.version };
911
+ }
912
+ async function channelRecipients(ctx, channelId) {
913
+ return api(ctx, `/v1/channels/${channelId}/recipients`);
914
+ }
915
+ async function pullEnv(ctx, channel2, options) {
916
+ const { keyset } = await loadKeyFile(ctx, options);
917
+ const currentConfig = readConfig();
918
+ let resolution;
919
+ let runtimeSource = "control_plane_handoff";
920
+ const runtimeChannels = currentConfig.machine?.runtime?.channels ?? [];
921
+ if (runtimeChannels.length) {
922
+ try {
923
+ resolution = await resolutionFromRuntimeChannels(ctx, channel2, runtimeChannels);
924
+ runtimeSource = "r2_cdn";
925
+ } catch (error) {
926
+ const cliError = normalizeError(error);
927
+ if (currentConfig.machine && cliError.code === "channel_not_authorized") {
928
+ throw cliError;
929
+ }
930
+ resolution = await resolveChannel(ctx, channel2);
931
+ }
932
+ } else {
933
+ resolution = await resolveChannel(ctx, channel2);
934
+ }
935
+ const descriptor = resolution.public_descriptor_url ? await publicPullDescriptor(ctx, resolution.public_descriptor_url).catch(() => null) : null;
936
+ const config = readConfig();
937
+ if (config.machine && !descriptor && !isLocalTestApi(ctx.apiBaseUrl) && process.env.ENVSHIP_ALLOW_WORKER_PULL_FALLBACK !== "true") {
938
+ throw new CliError("public_pull_descriptor_required", "Production machine pulls require the public encrypted CDN descriptor.", {
939
+ hint: "Publish the channel and wait for deploy status to become ready, or set ENVSHIP_ALLOW_WORKER_PULL_FALLBACK=true only for local/debug use."
940
+ });
941
+ }
942
+ const bundleUrl = descriptor?.bundle_url ?? resolution.bundle_url;
943
+ const expectedHash = descriptor?.bundle_sha256_b64u ?? resolution.bundle_sha256_b64u;
944
+ const encryptedBundle = await fetchJson(ctx, bundleUrl);
945
+ const actualHash = await sha256Base64url(canonicalize(encryptedBundle));
946
+ if (actualHash !== expectedHash) {
947
+ throw new CliError("bundle_hash_mismatch", "Encrypted bundle hash mismatch. Refusing to decrypt.");
948
+ }
949
+ if (config.machine && resolution.public_descriptor_url && descriptor) {
950
+ await saveConfig({
951
+ ...config,
952
+ machine: {
953
+ ...config.machine,
954
+ runtime: {
955
+ ...config.machine.runtime,
956
+ channels: [
957
+ ...(config.machine.runtime?.channels ?? []).filter((item) => item.channel_ref !== resolution.channel_ref),
958
+ {
959
+ channel_ref: resolution.channel_ref,
960
+ descriptor_url: resolution.public_descriptor_url,
961
+ version_id: descriptor.current_version_id,
962
+ version: descriptor.version,
963
+ bundle_sha256_b64u: descriptor.bundle_sha256_b64u,
964
+ updated_at: descriptor.updated_at
965
+ }
966
+ ],
967
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
968
+ }
969
+ }
970
+ });
971
+ }
972
+ const baseEnv = await decryptEnvBundle(encryptedBundle, keyset);
973
+ const overlay = await identityOverlay(ctx, channel2, keyset).catch((error) => {
974
+ const cliError = normalizeError(error);
975
+ if (cliError.code === "identity_overlay_hash_mismatch") throw cliError;
976
+ return null;
977
+ });
978
+ const env = overlay ? mergeEnvOverlay(baseEnv, overlay.env) : baseEnv;
979
+ void reportRuntimeClient(ctx, { lastDeploySyncAt: (/* @__PURE__ */ new Date()).toISOString() });
980
+ return { env, resolution, runtimeSource };
981
+ }
982
+ async function readInput(path) {
983
+ if (path === "-") {
984
+ return new Promise((resolvePromise, reject) => {
985
+ let body = "";
986
+ process.stdin.setEncoding("utf8");
987
+ process.stdin.on("data", (chunk) => {
988
+ body += chunk;
989
+ });
990
+ process.stdin.on("end", () => resolvePromise(body));
991
+ process.stdin.on("error", reject);
992
+ });
993
+ }
994
+ return readFile(validateSafeLocalPath(path, { allowStdout: true, force: true, label: "input" }), "utf8");
995
+ }
996
+ async function writeOutput(path, value, force = false) {
997
+ if (path === "-") {
998
+ process.stdout.write(value);
999
+ return;
1000
+ }
1001
+ const outPath = validateSafeLocalPath(path, { allowStdout: true, force, label: "output" });
1002
+ if (!force && existsSync(outPath)) {
1003
+ throw new CliError("output_exists", `${outPath} already exists.`, { hint: "Use --force to overwrite it." });
1004
+ }
1005
+ await writeFile(outPath, value);
1006
+ }
1007
+ async function writeAtomicOutput(path, value, force = false) {
1008
+ const outPath = validateSafeLocalPath(path, { allowStdout: false, force, label: "output" });
1009
+ if (!force && existsSync(outPath)) {
1010
+ throw new CliError("output_exists", `${outPath} already exists.`, { hint: "Use --force to overwrite it." });
1011
+ }
1012
+ await mkdir(dirname(outPath), { recursive: true });
1013
+ const tempPath = join(dirname(outPath), `.envship-${parse(outPath).base}-${crypto.randomUUID()}.tmp`);
1014
+ await writeFile(tempPath, value, { mode: 384 });
1015
+ await chmod(tempPath, 384).catch(() => void 0);
1016
+ await rename(tempPath, outPath);
1017
+ await chmod(outPath, 384).catch(() => void 0);
1018
+ }
1019
+ async function confirmAction(ctx, message) {
1020
+ if (ctx.yes) return;
1021
+ if (ctx.noInput || !process.stdin.isTTY) {
1022
+ throw new CliError("confirmation_required", message, { hint: "Rerun with --yes after verifying the target." });
1023
+ }
1024
+ const answer = (await promptLine(`${message} Type yes to continue: `)).toLowerCase();
1025
+ if (answer !== "yes") throw new CliError("cancelled", "Cancelled.");
1026
+ }
1027
+ function table(rows, columns) {
1028
+ if (rows.length === 0) return "";
1029
+ const widths = columns.map((column) => Math.max(column.length, ...rows.map((row) => String(row[column] ?? "").length)));
1030
+ const line = (values) => values.map((value, index) => value.padEnd(widths[index] ?? 0)).join(" ").trimEnd();
1031
+ return [line(columns), line(columns.map((column, index) => "-".repeat(widths[index] ?? column.length))), ...rows.map((row) => line(columns.map((column) => String(row[column] ?? ""))))].join("\n");
1032
+ }
1033
+ function safeConfig(config) {
1034
+ return {
1035
+ apiBaseUrl: config.apiBaseUrl,
1036
+ appBaseUrl: config.appBaseUrl,
1037
+ keyfilePath: config.keyfilePath ?? null,
1038
+ workstation: config.workstation ? { id: config.workstation.id, label: config.workstation.label } : null,
1039
+ authenticated: Boolean(config.session),
1040
+ machine: config.machine ? {
1041
+ machine_id: config.machine.machine_id,
1042
+ signing_key_id: config.machine.signing_key_id,
1043
+ channels: config.machine.channels,
1044
+ public_pull_base_url: config.machine.public_pull_base_url ?? null
1045
+ } : null,
1046
+ envsync: config.envsync ? {
1047
+ services: Object.fromEntries(Object.entries(config.envsync.services ?? {}).map(([name, service]) => [name, {
1048
+ name: service.name,
1049
+ channel: service.channel,
1050
+ out: service.out,
1051
+ interval_seconds: service.interval_seconds,
1052
+ mode: service.mode,
1053
+ hook_configured: Boolean(service.hook?.length),
1054
+ last_status: service.last_status ?? null
1055
+ }]))
1056
+ } : null
1057
+ };
1058
+ }
1059
+ function activeWorkspaceId(state, workspace) {
1060
+ const id = workspace ?? state.active_workspace_id;
1061
+ if (!id) throw new CliError("workspace_required", "No active workspace was found.", { hint: "Run envship setup first or pass --workspace." });
1062
+ return id;
1063
+ }
1064
+ async function registerKeyFile(ctx, options) {
1065
+ const state = await dashboardState(ctx);
1066
+ const workspaceId = activeWorkspaceId(state, options.workspace);
1067
+ const { keyFile } = await readKeyFile(options.keyfile);
1068
+ const result = await api(ctx, "/v1/keysets/register", {
1069
+ method: "POST",
1070
+ body: JSON.stringify({
1071
+ workspace_id: workspaceId,
1072
+ keyset_id: keyFile.keyset_id,
1073
+ label: keyFile.label,
1074
+ encryption_key_id: keyFile.encryption_key_id,
1075
+ encryption_public_jwk: keyFile.public_metadata.encryption_public_jwk,
1076
+ signing_key_id: keyFile.signing_key_id,
1077
+ signing_public_jwk: keyFile.public_metadata.signing_public_jwk,
1078
+ created_at: keyFile.created_at
1079
+ })
1080
+ });
1081
+ return { workspaceId, keyset_id: keyFile.keyset_id, status: result.status };
1082
+ }
1083
+ async function publishChannel(ctx, channel2, options) {
1084
+ const target = await findChannel(ctx, channel2);
1085
+ const { keyset } = await loadKeyFile(ctx, options);
1086
+ const env = parseDotenv(await readInput(options.in));
1087
+ const recipientResponse = await channelRecipients(ctx, target.id);
1088
+ const recipients = recipientResponse.recipients.length ? recipientResponse.recipients : [{ encryption_key_id: keyset.encryption_key_id, encryption_public_jwk: keyset.encryption_public_jwk }];
1089
+ const encryptedBundle = await encryptEnvBundleForRecipients(env, recipients, channel2);
1090
+ const bundleHash = await sha256Base64url(canonicalize(encryptedBundle));
1091
+ const manifest = await signManifest({
1092
+ type: "envship.signed_manifest.v1",
1093
+ channel_ref: channel2,
1094
+ bundle_sha256_b64u: bundleHash,
1095
+ publisher_signing_key_id: keyset.signing_key_id,
1096
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
1097
+ }, keyset);
1098
+ const planned = {
1099
+ channel: channel2,
1100
+ channel_id: target.id,
1101
+ expected_current_version_id: recipientResponse.expected_current_version_id ?? target.current_version_id ?? null,
1102
+ recipient_count: recipients.length,
1103
+ key_count: Object.keys(env).length,
1104
+ bundle_sha256_b64u: bundleHash
1105
+ };
1106
+ if (options.dryRun) return { planned };
1107
+ let result;
1108
+ try {
1109
+ result = await api(ctx, "/v1/versions/publish", {
1110
+ method: "POST",
1111
+ body: JSON.stringify({
1112
+ channel_id: target.id,
1113
+ expected_current_version_id: planned.expected_current_version_id,
1114
+ publisher_signing_key_id: keyset.signing_key_id,
1115
+ manifest,
1116
+ encrypted_bundle: encryptedBundle,
1117
+ bundle_sha256_b64u: bundleHash
1118
+ })
1119
+ });
1120
+ } catch (error) {
1121
+ const cliError = normalizeError(error);
1122
+ if (cliError.code === "version_conflict") throw publishConflictError(cliError, channel2);
1123
+ throw error;
1124
+ }
1125
+ return { ...planned, ...result };
1126
+ }
1127
+ function publishConflictError(error, channel2) {
1128
+ const details = error.details;
1129
+ const latest = details?.latest;
1130
+ const publisher = latest?.publisher;
1131
+ const name = [publisher?.first_name, publisher?.last_name].filter(Boolean).join(" ").trim();
1132
+ const byline = publisher?.email ? `${name ? `${name} ` : ""}<${publisher.email}>` : name || "another user";
1133
+ const versionText = latest?.version ? ` version ${latest.version}` : " a newer version";
1134
+ return new CliError(
1135
+ "version_conflict",
1136
+ `Cannot publish ${channel2}: ${byline} published${versionText}${latest?.published_at ? ` at ${latest.published_at}` : ""}. Pull the latest encrypted version, review your local diff, then retry.`,
1137
+ { hint: `Run envship channel pull ${channel2} --out <review-file> and merge your changes before publishing again.`, details: error.details }
1138
+ );
1139
+ }
1140
+ async function rewrapChannel(ctx, channel2, options) {
1141
+ const { env, resolution } = await pullEnv(ctx, channel2, options);
1142
+ const target = await findChannel(ctx, channel2);
1143
+ const { keyset } = await loadKeyFile(ctx, options);
1144
+ const recipientResponse = await channelRecipients(ctx, target.id);
1145
+ const encryptedBundle = await encryptEnvBundleForRecipients(env, recipientResponse.recipients, channel2);
1146
+ const bundleHash = await sha256Base64url(canonicalize(encryptedBundle));
1147
+ const planned = {
1148
+ channel: channel2,
1149
+ channel_id: target.id,
1150
+ expected_current_version_id: recipientResponse.expected_current_version_id ?? resolution.version_id,
1151
+ recipient_count: recipientResponse.recipients.length,
1152
+ key_count: Object.keys(env).length,
1153
+ bundle_sha256_b64u: bundleHash
1154
+ };
1155
+ if (options.dryRun) return { planned };
1156
+ const manifest = await signManifest({
1157
+ type: "envship.signed_manifest.v1",
1158
+ channel_ref: channel2,
1159
+ bundle_sha256_b64u: bundleHash,
1160
+ publisher_signing_key_id: keyset.signing_key_id,
1161
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
1162
+ }, keyset);
1163
+ let result;
1164
+ try {
1165
+ result = await api(ctx, "/v1/versions/publish", {
1166
+ method: "POST",
1167
+ body: JSON.stringify({
1168
+ channel_id: target.id,
1169
+ expected_current_version_id: planned.expected_current_version_id,
1170
+ publisher_signing_key_id: keyset.signing_key_id,
1171
+ manifest,
1172
+ encrypted_bundle: encryptedBundle,
1173
+ bundle_sha256_b64u: bundleHash
1174
+ })
1175
+ });
1176
+ } catch (error) {
1177
+ const cliError = normalizeError(error);
1178
+ if (cliError.code === "version_conflict") throw publishConflictError(cliError, channel2);
1179
+ throw error;
1180
+ }
1181
+ return { ...planned, ...result };
1182
+ }
1183
+ function parseRunCommand(cmd, options) {
1184
+ const keyFileOptions = { ...options };
1185
+ const commandParts = [];
1186
+ let inChildCommand = false;
1187
+ for (let index = 0; index < cmd.length; index += 1) {
1188
+ const part = cmd[index];
1189
+ if (!inChildCommand && part === "--") {
1190
+ inChildCommand = true;
1191
+ continue;
1192
+ }
1193
+ if (!inChildCommand && (part === "--keyfile" || part === "--passphrase-env" || part === "--passphrase")) {
1194
+ const value = cmd[index + 1];
1195
+ if (!value) throw new CliError("option_value_required", `${part} requires a value.`);
1196
+ if (part === "--keyfile") keyFileOptions.keyfile = value;
1197
+ if (part === "--passphrase-env") keyFileOptions.passphraseEnv = value;
1198
+ if (part === "--passphrase") keyFileOptions.passphrase = value;
1199
+ index += 1;
1200
+ continue;
1201
+ }
1202
+ commandParts.push(part);
1203
+ }
1204
+ return { commandParts, keyFileOptions };
1205
+ }
1206
+ function parseDurationSeconds(value, fallback) {
1207
+ if (!value) return fallback;
1208
+ const match = /^(\d+)(s|m|h)?$/iu.exec(value.trim());
1209
+ if (!match) throw new CliError("interval_invalid", "Intervals must look like 30s, 5m, or 1h.");
1210
+ const amount = Number(match[1]);
1211
+ const unit = (match[2] ?? "s").toLowerCase();
1212
+ const seconds = amount * (unit === "h" ? 3600 : unit === "m" ? 60 : 1);
1213
+ if (!Number.isSafeInteger(seconds) || seconds <= 0) throw new CliError("interval_invalid", "Interval must be a positive duration.");
1214
+ return seconds;
1215
+ }
1216
+ function envSyncPlan() {
1217
+ const plan = String(process.env.ENVSHIP_PLAN ?? process.env.ENVSHIP_ENVSYNC_PLAN ?? "pro").toLowerCase();
1218
+ if (["free", "freeenv"].includes(plan)) return "free";
1219
+ if (["team", "business"].includes(plan)) return "team";
1220
+ if (plan === "enterprise") return "enterprise";
1221
+ return "pro";
1222
+ }
1223
+ function envSyncLimits(mode) {
1224
+ const plan = envSyncPlan();
1225
+ if (plan === "free") {
1226
+ if (mode === "daemon") throw new CliError("envsync_daemon_unavailable", "EnvSync daemon is not available on the Free/FreeEnv baseline after trial.", { hint: "Use envsync watch with a 300s or higher interval, or upgrade to EnvShip Pro." });
1227
+ return { min: 300, recommended: 600, max: 3600, plan };
1228
+ }
1229
+ if (plan === "team") return mode === "watch" ? { min: 15, recommended: 30, max: 600, plan } : { min: 30, recommended: 60, max: 600, plan };
1230
+ if (plan === "enterprise") return { min: 15, recommended: mode === "watch" ? 30 : 60, max: 600, plan };
1231
+ return mode === "watch" ? { min: 30, recommended: 60, max: 900, plan } : { min: 60, recommended: 120, max: 900, plan };
1232
+ }
1233
+ function enforceEnvSyncInterval(mode, value) {
1234
+ const limits = envSyncLimits(mode);
1235
+ const seconds = parseDurationSeconds(value, limits.recommended);
1236
+ if (seconds < limits.min) throw new CliError("envsync_interval_too_low", `EnvSync ${mode} interval must be at least ${limits.min}s for the current plan.`, { hint: `Recommended interval: ${limits.recommended}s.` });
1237
+ if (seconds > limits.max) throw new CliError("envsync_interval_too_high", `EnvSync ${mode} interval must be ${limits.max}s or lower.`, { hint: "Use a value inside the supported plan range." });
1238
+ return { seconds, limits };
1239
+ }
1240
+ function jitteredDelayMs(seconds, attempt) {
1241
+ const backoffSeconds = Math.min(seconds * 2 ** Math.max(0, attempt - 1), 15 * 60);
1242
+ const jitter = 0.9 + Math.random() * 0.2;
1243
+ return Math.round(backoffSeconds * jitter * 1e3);
1244
+ }
1245
+ function envSyncServices(config = readConfig()) {
1246
+ return config.envsync?.services ?? {};
1247
+ }
1248
+ function defaultEnvSyncName(channel2) {
1249
+ return channel2.replace(/[^A-Za-z0-9_-]+/gu, "-").replace(/^-|-$/gu, "") || "default";
1250
+ }
1251
+ function envSyncConfigPath(name) {
1252
+ const safeName = name.replace(/[^A-Za-z0-9_-]/gu, "-");
1253
+ return join(homedir(), ".envship", "envsync", `${safeName}.json`);
1254
+ }
1255
+ function envSyncServiceFilePath(name) {
1256
+ const safeName = name.replace(/[^A-Za-z0-9_-]/gu, "-");
1257
+ const base = join(homedir(), ".envship", "envsync");
1258
+ if (process.platform === "win32") return join(base, `${safeName}.service.json`);
1259
+ if (process.platform === "darwin") return join(base, `com.envship.${safeName}.plist`);
1260
+ return join(base, `envship-${safeName}.service`);
1261
+ }
1262
+ function validateEnvSyncName(value) {
1263
+ if (!/^[A-Za-z0-9_-]{1,64}$/u.test(value)) throw new CliError("envsync_name_invalid", "EnvSync names may contain letters, numbers, dashes, and underscores.");
1264
+ return value;
1265
+ }
1266
+ function parseHook(parts, daemon = false) {
1267
+ const cleanParts = parts?.[0] === "--" ? parts.slice(1) : parts;
1268
+ if (!cleanParts?.length) return void 0;
1269
+ const [commandName, ...args] = cleanParts;
1270
+ if (!commandName) return void 0;
1271
+ if (daemon && !isAbsolute(commandName)) {
1272
+ throw new CliError("envsync_hook_absolute_required", "EnvSync daemon hooks must use an absolute executable path.");
1273
+ }
1274
+ validateChildCommand(commandName, args);
1275
+ return [commandName, ...args];
1276
+ }
1277
+ async function runEnvSyncHook(hook, metadata, timeoutMs = 3e4) {
1278
+ if (!hook?.length) return "not_configured";
1279
+ const [commandName, ...args] = hook;
1280
+ validateChildCommand(commandName, args);
1281
+ return new Promise((resolvePromise) => {
1282
+ const child = spawnSafe(commandName, args, {
1283
+ stdio: "ignore",
1284
+ env: {
1285
+ ...process.env,
1286
+ ENVSHIP_ENVSYNC_CHANNEL: metadata.channel,
1287
+ ENVSHIP_ENVSYNC_VERSION: metadata.version,
1288
+ ENVSHIP_ENVSYNC_VERSION_ID: metadata.version_id,
1289
+ ENVSHIP_ENVSYNC_BUNDLE_SHA256: metadata.bundle_sha256_b64u,
1290
+ ENVSHIP_ENVSYNC_OUTPUT: metadata.out
1291
+ }
1292
+ });
1293
+ const timer = setTimeout(() => {
1294
+ child.kill();
1295
+ resolvePromise("timeout");
1296
+ }, timeoutMs);
1297
+ child.on("error", () => {
1298
+ clearTimeout(timer);
1299
+ resolvePromise("failed");
1300
+ });
1301
+ child.on("exit", (code) => {
1302
+ clearTimeout(timer);
1303
+ resolvePromise(code === 0 ? "passed" : "failed");
1304
+ });
1305
+ });
1306
+ }
1307
+ function serviceFileBody(config) {
1308
+ const args = ["envsync", "watch", "--channel", config.channel, "--out", config.out, "--interval", `${config.interval_seconds}s`, "--name", config.name];
1309
+ if (config.hook?.length) args.push("--on-change", "--", ...config.hook);
1310
+ if (process.platform === "win32") return JSON.stringify({ service: "EnvShip EnvSync", name: config.name, command: "envship", args }, null, 2);
1311
+ if (process.platform === "darwin") return `<?xml version="1.0" encoding="UTF-8"?>
1312
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1313
+ <plist version="1.0"><dict>
1314
+ <key>Label</key><string>com.envship.${config.name}</string>
1315
+ <key>ProgramArguments</key><array>${["envship", ...args].map((item) => `<string>${item.replace(/&/gu, "&amp;").replace(/</gu, "&lt;")}</string>`).join("")}</array>
1316
+ <key>RunAtLoad</key><true/>
1317
+ <key>KeepAlive</key><true/>
1318
+ </dict></plist>
1319
+ `;
1320
+ return `[Unit]
1321
+ Description=EnvShip EnvSync ${config.name}
1322
+ After=network-online.target
1323
+
1324
+ [Service]
1325
+ Type=simple
1326
+ ExecStart=envship ${args.map((item) => item.replace(/(["\\$`])/gu, "\\$1")).join(" ")}
1327
+ Restart=on-failure
1328
+ RestartSec=30
1329
+ StartLimitBurst=5
1330
+ NoNewPrivileges=true
1331
+ PrivateTmp=true
1332
+
1333
+ [Install]
1334
+ WantedBy=default.target
1335
+ `;
1336
+ }
1337
+ async function loadEnvSyncService(name) {
1338
+ const service = envSyncServices()[name];
1339
+ if (!service) throw new CliError("envsync_service_not_found", `EnvSync service ${name} is not configured.`);
1340
+ return service;
1341
+ }
1342
+ async function saveEnvSyncService(service) {
1343
+ const config = readConfig();
1344
+ const services = { ...config.envsync?.services ?? {}, [service.name]: service };
1345
+ await saveConfig({ ...config, envsync: { services } });
1346
+ await mkdir(dirname(envSyncConfigPath(service.name)), { recursive: true, mode: 448 });
1347
+ await writeFile(envSyncConfigPath(service.name), JSON.stringify(service, null, 2), { mode: 384 });
1348
+ await chmod(envSyncConfigPath(service.name), 384).catch(() => void 0);
1349
+ }
1350
+ async function removeEnvSyncService(name) {
1351
+ const config = readConfig();
1352
+ const services = { ...config.envsync?.services ?? {} };
1353
+ const service = services[name];
1354
+ delete services[name];
1355
+ await saveConfig({ ...config, envsync: { services } });
1356
+ await rm(envSyncConfigPath(name), { force: true });
1357
+ if (service?.service_file) await rm(service.service_file, { force: true });
1358
+ }
1359
+ async function envSyncRuntimeCheck(ctx, service, options) {
1360
+ const runtimeChannels = readConfig().machine?.runtime?.channels ?? [];
1361
+ const cached = runtimeChannels.find((item) => item.channel_ref === service.channel);
1362
+ if (!cached) throw new CliError("runtime_profile_required", "EnvSync requires a scoped local runtime profile for this channel.", { hint: "Install a machine or deploy token for the channel first." });
1363
+ const headers = new Headers();
1364
+ if (service.etag) headers.set("If-None-Match", service.etag);
1365
+ if (service.last_modified) headers.set("If-Modified-Since", service.last_modified);
1366
+ const descriptorResponse = await fetchPublicJson(ctx, cached.descriptor_url, headers);
1367
+ if (descriptorResponse.status === 304) {
1368
+ return {
1369
+ service: { ...service, updated_at: (/* @__PURE__ */ new Date()).toISOString() },
1370
+ status: {
1371
+ state: "not_modified",
1372
+ channel: service.channel,
1373
+ checked_at: (/* @__PURE__ */ new Date()).toISOString(),
1374
+ version: cached.version ?? void 0,
1375
+ version_id: cached.version_id ?? void 0,
1376
+ runtime_source: "r2_cdn",
1377
+ descriptor_url: cached.descriptor_url,
1378
+ hook_status: "not_configured"
1379
+ }
1380
+ };
1381
+ }
1382
+ const descriptor = descriptorResponse.body;
1383
+ if (!descriptor || descriptor.type !== "envship.public_pull_descriptor.v1" || descriptor.channel_ref !== service.channel) {
1384
+ throw new CliError("descriptor_invalid", "EnvSync descriptor did not match the expected channel.");
1385
+ }
1386
+ const previousUnchanged = descriptor.current_version_id === cached.version_id && descriptor.bundle_sha256_b64u === cached.bundle_sha256_b64u;
1387
+ if (previousUnchanged) {
1388
+ return {
1389
+ service: { ...service, etag: descriptorResponse.etag, last_modified: descriptorResponse.lastModified, updated_at: (/* @__PURE__ */ new Date()).toISOString() },
1390
+ status: {
1391
+ state: "not_modified",
1392
+ channel: service.channel,
1393
+ checked_at: (/* @__PURE__ */ new Date()).toISOString(),
1394
+ version: descriptor.version,
1395
+ version_id: descriptor.current_version_id,
1396
+ runtime_source: "r2_cdn",
1397
+ descriptor_url: cached.descriptor_url,
1398
+ hook_status: "not_configured"
1399
+ }
1400
+ };
1401
+ }
1402
+ const { keyset } = await loadKeyFile(ctx, options);
1403
+ const encryptedBundle = await fetchJson(ctx, descriptor.bundle_url);
1404
+ const actualHash = await sha256Base64url(canonicalize(encryptedBundle));
1405
+ if (actualHash !== descriptor.bundle_sha256_b64u) throw new CliError("bundle_hash_mismatch", "Encrypted bundle hash mismatch. Refusing to update EnvSync output.");
1406
+ const baseEnv = await decryptEnvBundle(encryptedBundle, keyset);
1407
+ const overlay = await identityOverlay(ctx, service.channel, keyset).catch((error) => {
1408
+ const cliError = normalizeError(error);
1409
+ if (cliError.code === "identity_overlay_hash_mismatch") throw cliError;
1410
+ return null;
1411
+ });
1412
+ const env = overlay ? mergeEnvOverlay(baseEnv, overlay.env) : baseEnv;
1413
+ await writeAtomicOutput(service.out, stringifyDotenv(env), options.force ?? true);
1414
+ const hookStatus = await runEnvSyncHook(service.hook, {
1415
+ channel: service.channel,
1416
+ version: String(descriptor.version),
1417
+ version_id: descriptor.current_version_id,
1418
+ bundle_sha256_b64u: descriptor.bundle_sha256_b64u,
1419
+ out: service.out
1420
+ });
1421
+ const nextConfig = readConfig();
1422
+ if (nextConfig.machine?.runtime?.channels) {
1423
+ await saveConfig({
1424
+ ...nextConfig,
1425
+ machine: {
1426
+ ...nextConfig.machine,
1427
+ runtime: {
1428
+ ...nextConfig.machine.runtime,
1429
+ channels: [
1430
+ ...nextConfig.machine.runtime.channels.filter((item) => item.channel_ref !== service.channel),
1431
+ {
1432
+ channel_ref: service.channel,
1433
+ descriptor_url: cached.descriptor_url,
1434
+ version_id: descriptor.current_version_id,
1435
+ version: descriptor.version,
1436
+ bundle_sha256_b64u: descriptor.bundle_sha256_b64u,
1437
+ updated_at: descriptor.updated_at
1438
+ }
1439
+ ],
1440
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1441
+ }
1442
+ }
1443
+ });
1444
+ }
1445
+ return {
1446
+ service: { ...service, etag: descriptorResponse.etag, last_modified: descriptorResponse.lastModified, updated_at: (/* @__PURE__ */ new Date()).toISOString() },
1447
+ status: {
1448
+ state: hookStatus === "failed" || hookStatus === "timeout" ? "hook_failed" : "bundle_updated",
1449
+ channel: service.channel,
1450
+ checked_at: (/* @__PURE__ */ new Date()).toISOString(),
1451
+ version: descriptor.version,
1452
+ version_id: descriptor.current_version_id,
1453
+ runtime_source: "r2_cdn",
1454
+ descriptor_url: cached.descriptor_url,
1455
+ hook_status: hookStatus
1456
+ }
1457
+ };
1458
+ }
1459
+ async function setupWorkspace(ctx, options) {
1460
+ const result = await api(ctx, "/v1/dashboard/setup", {
1461
+ method: "POST",
1462
+ body: JSON.stringify({
1463
+ workspace_name: options.workspace,
1464
+ project_name: options.project,
1465
+ bundle_name: options.bundle,
1466
+ channels: options.channels.split(",").map((item) => item.trim()).filter(Boolean)
1467
+ })
1468
+ });
1469
+ const data = { ...result };
1470
+ const passphraseSupplied = Boolean(options.passphrase || options.passphraseEnv || process.env.ENVSHIP_KEYFILE_PASSPHRASE);
1471
+ if (options.registerKeyfile || options.publishEmpty || passphraseSupplied) {
1472
+ let keyfilePath = options.keyfile;
1473
+ if (!keyfilePath) {
1474
+ const passphrase = await resolvePassphrase(ctx, options);
1475
+ const generated = await generateEncryptedKeyFile(options.label, passphrase);
1476
+ keyfilePath = resolve(generated.filename);
1477
+ if (!options.force && existsSync(keyfilePath)) throw new CliError("output_exists", `${keyfilePath} already exists.`, { hint: "Use --force or --keyfile." });
1478
+ await writeFile(keyfilePath, JSON.stringify(generated.keyFile, null, 2));
1479
+ await saveConfig({ ...readConfig(), keyfilePath });
1480
+ data.keyfile_path = keyfilePath;
1481
+ data.keyset_id = generated.keyFile.keyset_id;
1482
+ }
1483
+ const registered = await registerKeyFile(ctx, { workspace: result.workspace_id, keyfile: keyfilePath });
1484
+ data.keyset_id = registered.keyset_id;
1485
+ const primaryChannel = `${options.bundle}/${options.channels.split(",").map((item) => item.trim()).filter(Boolean)[0] ?? "staging"}`;
1486
+ const grant2 = await createGrant(ctx, primaryChannel, { keyset: registered.keyset_id, preset: "editor" });
1487
+ data.initial_grant = grant2;
1488
+ if (options.publishEmpty) {
1489
+ const emptyPath = join(tmpdir(), `envship-empty-${crypto.randomUUID()}.env`);
1490
+ await writeFile(emptyPath, "");
1491
+ data.initial_publish = await publishChannel(ctx, primaryChannel, { ...options, in: emptyPath, keyfile: keyfilePath });
1492
+ await rm(emptyPath, { force: true });
1493
+ }
1494
+ }
1495
+ return data;
1496
+ }
1497
+ async function createGrant(ctx, channel2, options) {
1498
+ const target = await findChannel(ctx, channel2);
1499
+ const payload = { channel_id: target.id, keyset_id: options.keyset, preset: options.preset };
1500
+ if (options.dryRun) return { dry_run: true, channel: channel2, ...payload };
1501
+ return api(ctx, "/v1/channel-grants", {
1502
+ method: "POST",
1503
+ body: JSON.stringify(payload)
1504
+ });
1505
+ }
1506
+ program.name("envship").description("EnvShip encrypts, ships, and runs environment bundles without sending plaintext secrets to the cloud.").version(cliVersion).enablePositionalOptions().showHelpAfterError().showSuggestionAfterError().configureHelp({ sortSubcommands: true, sortOptions: true });
1507
+ addGlobalOptions(program);
1508
+ program.addHelpText("after", `
1509
+ Examples:
1510
+ envship auth login
1511
+ envship auth connect --code cli_xxxxx
1512
+ envship setup --workspace Acme --project Web --bundle web --channels staging,production
1513
+ envship machine install --name web-production --channel web/production
1514
+ envship machine deploy-token-create --project <project-id> --channel web/production
1515
+ envship machine install --deploy-token "$ENVSHIP_DEPLOY_TOKEN" --name web-production
1516
+ envship envsync watch --channel web/production --out .env
1517
+ envship update check
1518
+ envship channel run web/production -- npm start
1519
+
1520
+ Next step: run envship auth login, then envship setup.
1521
+ Docs: https://envship.com/docs
1522
+ Support: https://github.com/envshipcom/envship/issues
1523
+ `);
1524
+ function command(parent, signature, description, examples) {
1525
+ const created = parent.command(signature).description(description).showHelpAfterError();
1526
+ addGlobalOptions(created);
1527
+ return withExamples(created, examples);
1528
+ }
1529
+ function group(name, description) {
1530
+ const created = program.command(name).description(description).enablePositionalOptions().showHelpAfterError();
1531
+ addGlobalOptions(created);
1532
+ return withExamples(created, [`envship ${name} --help`]);
1533
+ }
1534
+ var auth = group("auth", "Authenticate and inspect the local EnvShip session");
1535
+ command(auth, "whoami", "Show the current EnvShip session", ["envship auth whoami", "envship auth whoami --json"]).action(commandAction(async (ctx) => {
1536
+ const me = await api(ctx, "/v1/me");
1537
+ const config = readConfig();
1538
+ const data = { user: me.user, api: ctx.apiBaseUrl, machine: config.machine ? { machine_id: config.machine.machine_id, signing_key_id: config.machine.signing_key_id, channels: config.machine.channels } : null };
1539
+ return { data, stdout: table([{ email: me.user?.email ?? "unknown", api: ctx.apiBaseUrl, machine: config.machine?.machine_id ?? "none" }], ["email", "api", "machine"]) };
1540
+ }));
1541
+ command(auth, "logout", "Remove the local EnvShip session and machine credential", ["envship auth logout"]).action(commandAction(async () => {
1542
+ const config = readConfig();
1543
+ try {
1544
+ if (config.session) await fetch(`${config.apiBaseUrl}/v1/logout`, { method: "POST", headers: { Cookie: `${sessionCookieName}=${config.session}`, "X-EnvShip-CSRF": "1" } });
1545
+ } catch {
1546
+ }
1547
+ await saveConfig({ apiBaseUrl: config.apiBaseUrl, appBaseUrl: config.appBaseUrl, keyfilePath: config.keyfilePath });
1548
+ return { data: { logged_out: true }, message: "Local EnvShip session removed." };
1549
+ }));
1550
+ command(auth, "login", "Authorize this device with EnvShip", ["envship auth login", "envship auth login --headless"]).option("--headless", "print a 1-hour approval code instead of opening a browser").action(commandAction(async (ctx, options) => {
1551
+ const machineKey = await generateMachineSigningKey("envship-cli-device");
1552
+ const code = await api(ctx, "/v1/device-codes", {
1553
+ method: "POST",
1554
+ body: JSON.stringify({ signing_key_id: machineKey.signing_key_id, public_key: machineKey.signing_public_jwk, device_name: "EnvShip CLI" })
1555
+ });
1556
+ const approvalUrl = `${ctx.appBaseUrl}/authorize-code?code=${encodeURIComponent(code.user_code)}`;
1557
+ if (options.headless) ctx.info(`Go to ${code.verification_uri} and enter code ${code.user_code}`);
1558
+ else {
1559
+ openBrowser(approvalUrl);
1560
+ ctx.info(`Opened ${approvalUrl}`);
1561
+ ctx.info(`Headless fallback: ${code.verification_uri} code ${code.user_code}`);
1562
+ }
1563
+ const deadline = Date.now() + 60 * 60 * 1e3;
1564
+ const pollStartedAt = Date.now();
1565
+ while (Date.now() < deadline) {
1566
+ const elapsedMs = Date.now() - pollStartedAt;
1567
+ const pollIntervalSeconds = elapsedMs < 6e4 ? code.interval_seconds : elapsedMs < 5 * 6e4 ? Math.max(code.interval_seconds, 10) : 30;
1568
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, pollIntervalSeconds * 1e3));
1569
+ const poll = await api(ctx, `/v1/device-codes/${encodeURIComponent(code.device_code)}`);
1570
+ if (poll.status === "expired") throw new CliError("device_code_expired", "Device code expired before approval.");
1571
+ if (poll.status === "approved") {
1572
+ await saveConfig({
1573
+ ...readConfig(),
1574
+ apiBaseUrl: ctx.apiBaseUrl,
1575
+ appBaseUrl: ctx.appBaseUrl,
1576
+ session: poll.session,
1577
+ machine: poll.machine ? {
1578
+ machine_id: poll.machine.machine_id,
1579
+ signing_key_id: machineKey.signing_key_id,
1580
+ signing_private_jwk: machineKey.signing_private_jwk,
1581
+ channels: poll.machine.channels
1582
+ } : void 0
1583
+ });
1584
+ return { data: { approved: true, machine: poll.machine ?? null }, message: "EnvShip device approved and saved locally." };
1585
+ }
1586
+ }
1587
+ throw new CliError("device_code_timeout", "Timed out waiting for device approval.");
1588
+ }));
1589
+ command(auth, "connect", "Connect this development environment with a Dashboard setup code", ["envship auth connect --code cli_xxxxx"]).requiredOption("--code <code>", "pre-generated Dashboard CLI setup code").action(commandAction(async (ctx, options) => {
1590
+ const code = validateCliBootstrapCode(options.code.trim());
1591
+ const currentConfig = readConfig();
1592
+ const workstation = localWorkstation(currentConfig);
1593
+ const data = await api(ctx, "/v1/cli-bootstrap-codes/consume", {
1594
+ method: "POST",
1595
+ body: JSON.stringify({ code, workstation_id: workstation.id, label: workstation.label })
1596
+ });
1597
+ await saveConfig({
1598
+ ...currentConfig,
1599
+ apiBaseUrl: ctx.apiBaseUrl,
1600
+ appBaseUrl: ctx.appBaseUrl,
1601
+ session: data.session,
1602
+ workstation: {
1603
+ ...workstation,
1604
+ analytics_client_id: data.cli_workstation?.analytics_client_id ?? workstation.analytics_client_id ?? null,
1605
+ workspace_id: data.workspace_id ?? workstation.workspace_id ?? null,
1606
+ project_id: data.project_id ?? workstation.project_id ?? null,
1607
+ autoupdate_enabled: workstation.autoupdate_enabled ?? true
1608
+ }
1609
+ });
1610
+ void reportRuntimeClient(ctx);
1611
+ return {
1612
+ data: {
1613
+ connected: true,
1614
+ user_email: data.user?.email ?? null,
1615
+ workspace_id: data.workspace_id ?? null,
1616
+ project_id: data.project_id ?? null,
1617
+ source: data.source ?? null,
1618
+ workstation: { ...workstation, analytics_client_id: data.cli_workstation?.analytics_client_id ?? workstation.analytics_client_id ?? null, workspace_id: data.workspace_id ?? null, project_id: data.project_id ?? null },
1619
+ cli_workstations: data.cli_workstations ?? null
1620
+ },
1621
+ message: `CLI Workstation connected${data.user?.email ? ` for ${data.user.email}` : ""}.`
1622
+ };
1623
+ }));
1624
+ var setup = command(program, "setup", "Create workspace/project/bundle/channel setup", ["envship setup --workspace Acme --project Web --bundle web --channels staging,production"]).option("--workspace <name>", "workspace name", "My Workspace").option("--project <name>", "project name", "My First Project").option("--bundle <name>", "bundle name", "web").option("--channels <csv>", "comma-separated channels", "staging,production").option("--label <label>", "KeyFile label when generating one", "envship-cli-key").option("--register-keyfile", "register the supplied or generated KeyFile public metadata").option("--publish-empty", "publish an initial encrypted empty version after registering and granting").option("--force", "overwrite generated KeyFile output if needed");
1625
+ addKeyFileOptions(setup);
1626
+ setup.action(commandAction(async (ctx, options) => {
1627
+ const data = await setupWorkspace(ctx, options);
1628
+ return { data, message: `Workspace ready: ${data.workspace_id}` };
1629
+ }));
1630
+ var keyfile = program.command("keyfile", { hidden: true }).description("Compatibility commands for local encrypted credential files").enablePositionalOptions().showHelpAfterError();
1631
+ addGlobalOptions(keyfile);
1632
+ withExamples(keyfile, ["envship keyfile --help"]);
1633
+ var keyfileCreate = command(keyfile, "create", "Create an encrypted private KeyFile locally", ["envship keyfile create --label laptop --passphrase-env ENVSHIP_KEYFILE_PASSPHRASE"]).option("--label <label>", "KeyFile label", "envship-cli-key").option("--out <path>", "output path").option("--force", "overwrite output path");
1634
+ addKeyFileOptions(keyfileCreate);
1635
+ keyfileCreate.action(commandAction(async (ctx, options) => {
1636
+ const passphrase = await resolvePassphrase(ctx, options);
1637
+ const generated = await generateEncryptedKeyFile(options.label, passphrase);
1638
+ const outPath = resolve(options.out ?? generated.filename);
1639
+ if (!options.force && existsSync(outPath)) throw new CliError("output_exists", `${outPath} already exists.`, { hint: "Use --force to overwrite it." });
1640
+ await writeFile(outPath, JSON.stringify(generated.keyFile, null, 2));
1641
+ await saveConfig({ ...readConfig(), keyfilePath: outPath });
1642
+ return { data: { keyfile_path: outPath, keyset_id: generated.keyFile.keyset_id, encryption_key_id: generated.keyFile.encryption_key_id, signing_key_id: generated.keyFile.signing_key_id }, message: `Encrypted KeyFile written to ${outPath}` };
1643
+ }));
1644
+ command(keyfile, "register", "Register public KeyFile metadata with the active workspace", ["envship keyfile register --keyfile laptop.envship-keyfile.json"]).option("--workspace <workspaceId>", "workspace id").option("--keyfile <path>", "encrypted KeyFile path").action(commandAction(async (ctx, options) => {
1645
+ const data = await registerKeyFile(ctx, options);
1646
+ return { data, message: `Registered KeyFile ${data.keyset_id}: ${data.status}` };
1647
+ }));
1648
+ var keyfileUnlock = command(keyfile, "unlock", "Verify a local encrypted KeyFile can be unlocked", ["envship keyfile unlock --keyfile laptop.envship-keyfile.json"]);
1649
+ addKeyFileOptions(keyfileUnlock);
1650
+ keyfileUnlock.action(commandAction(async (ctx, options) => {
1651
+ const { keyset, keyfilePath } = await loadKeyFile(ctx, options);
1652
+ return { data: { keyfile_path: keyfilePath, keyset_id: keyset.keyset_id, label: keyset.label, signing_key_id: keyset.signing_key_id }, message: `Unlocked KeyFile ${keyset.label}` };
1653
+ }));
1654
+ command(keyfile, "status", "List registered KeyFiles for the active workspace", ["envship keyfile status", "envship keyfile status --json"]).action(commandAction(async (ctx) => {
1655
+ const state = await dashboardState(ctx);
1656
+ const rows = state.keysets.map((item) => ({ keyset: item.keyset_id, label: item.label, status: item.status, signing: item.signing_key_id }));
1657
+ return { data: { keysets: state.keysets }, stdout: table(rows, ["keyset", "label", "status", "signing"]) || "No KeyFiles registered." };
1658
+ }));
1659
+ for (const [name, status] of [["mark-lost", "lost"], ["revoke", "revoked"]]) {
1660
+ command(keyfile, `${name} <keysetId>`, `${name === "mark-lost" ? "Mark" : "Revoke"} a registered KeyFile`, [`envship keyfile ${name} kst_example --workspace <workspace-id> --yes`]).requiredOption("--workspace <workspaceId>", "workspace id").action(commandAction(async (ctx, keysetId, options) => {
1661
+ await confirmAction(ctx, `${name === "mark-lost" ? "Mark" : "Revoke"} KeyFile ${keysetId}?`);
1662
+ const data = await api(ctx, `/v1/keysets/${encodeURIComponent(keysetId)}/status`, { method: "POST", body: JSON.stringify({ workspace_id: options.workspace, status }) });
1663
+ return { data, message: `KeyFile ${keysetId} ${status}.` };
1664
+ }));
1665
+ }
1666
+ var channel = group("channel", "Publish, pull, run, edit, and inspect channel versions");
1667
+ command(channel, "list", "List channels visible to the current session", ["envship channel list"]).action(commandAction(async (ctx) => {
1668
+ const state = await dashboardState(ctx);
1669
+ const rows = state.channels.map((item) => ({ channel: item.channel_ref, version: item.current_version ?? "none" }));
1670
+ return { data: { channels: state.channels }, stdout: table(rows, ["channel", "version"]) || "No channels found." };
1671
+ }));
1672
+ command(channel, "versions <channel>", "List immutable versions for a channel", ["envship channel versions web/staging"]).action(commandAction(async (ctx, channelRef) => {
1673
+ const target = await findChannel(ctx, channelRef);
1674
+ const response = await api(ctx, `/v1/channels/${target.id}/versions`);
1675
+ const rows = response.versions.map((item) => ({ version: item.version, id: item.id, current: item.is_current ? "yes" : "", created: item.created_at }));
1676
+ return { data: response, stdout: table(rows, ["version", "id", "current", "created"]) || "No versions found." };
1677
+ }));
1678
+ var channelPublish = command(channel, "publish <channel>", "Encrypt and publish an env file to a channel", ["envship channel publish web/production --in .env.production", "cat .env | envship channel publish web/staging --in -"]);
1679
+ channelPublish.option("--in <path>", "dotenv input path or - for stdin", ".env").option("--dry-run", "validate and show publish plan without writing");
1680
+ addKeyFileOptions(channelPublish);
1681
+ channelPublish.action(commandAction(async (ctx, channelRef, options) => {
1682
+ const data = await publishChannel(ctx, channelRef, options);
1683
+ return { data, message: options.dryRun ? `Publish dry-run ready for ${channelRef}.` : `Published ${channelRef} version ${data.version}.` };
1684
+ }));
1685
+ var channelPull = command(channel, "pull <channel>", "Fetch, verify, decrypt, and write dotenv output", ["envship channel pull web/staging --out .env.local", "envship channel pull web/staging --out -"]);
1686
+ channelPull.option("--out <path>", "output path or - for stdout", ".env").option("--force", "overwrite output path");
1687
+ addKeyFileOptions(channelPull);
1688
+ channelPull.action(commandAction(async (ctx, channelRef, options) => {
1689
+ if (ctx.json && options.out === "-") {
1690
+ throw new CliError("json_stdout_conflict", "Cannot combine --json with --out - because decrypted dotenv output also uses stdout.", { hint: "Write dotenv output to a file, or omit --json." });
1691
+ }
1692
+ const { env, resolution, runtimeSource } = await pullEnv(ctx, channelRef, options);
1693
+ const versionCheck = await versionWarnings(ctx);
1694
+ await writeOutput(options.out, stringifyDotenv(env), options.force);
1695
+ return {
1696
+ data: { channel: channelRef, version: resolution.version, out: options.out, runtime_source: runtimeSource },
1697
+ warnings: [
1698
+ ...runtimeSource === "control_plane_handoff" ? ["Used control-plane metadata to refresh the local runtime profile; future pulls should use the R2/CDN descriptor while it remains current."] : [],
1699
+ ...versionCheck.warnings
1700
+ ],
1701
+ message: options.out === "-" ? void 0 : `Resolved ${channelRef} version ${resolution.version} to ${options.out} via ${runtimeSource === "r2_cdn" ? "R2/CDN runtime profile" : "control-plane handoff"}`
1702
+ };
1703
+ }));
1704
+ var channelRun = command(channel, "run <channel> [cmd...]", "Run a command with locally decrypted EnvShip env values", ["envship channel run web/production -- npm start"]);
1705
+ channelRun.allowUnknownOption(true).passThroughOptions();
1706
+ addKeyFileOptions(channelRun);
1707
+ channelRun.action(commandAction(async (ctx, channelRef, cmd, options) => {
1708
+ const parsed = parseRunCommand(cmd, options);
1709
+ const { env, runtimeSource } = await pullEnv(ctx, channelRef, parsed.keyFileOptions);
1710
+ const versionCheck = await versionWarnings(ctx);
1711
+ const [commandName, ...args] = parsed.commandParts;
1712
+ if (!commandName) throw new CliError("command_required", "Pass a command after the channel.", { hint: "Example: envship channel run web/staging -- npm start" });
1713
+ validateChildCommand(commandName, args);
1714
+ const child = spawnSafe(commandName, args, { stdio: "inherit", env: { ...process.env, ...env, ENVSHIP_CHANNEL: channelRef } });
1715
+ await new Promise((resolvePromise, reject) => {
1716
+ child.on("error", reject);
1717
+ child.on("exit", (code, signal) => code === 0 ? resolvePromise() : reject(new ChildExitError(code, signal)));
1718
+ });
1719
+ return {
1720
+ data: { channel: channelRef, command: [commandName, ...args], runtime_source: runtimeSource },
1721
+ warnings: [
1722
+ ...runtimeSource === "control_plane_handoff" ? ["Used control-plane metadata to refresh the local runtime profile before running the command."] : [],
1723
+ ...versionCheck.warnings
1724
+ ]
1725
+ };
1726
+ }));
1727
+ var channelEdit = command(channel, "edit <channel>", "Open a channel in the local editor, then encrypt and publish", ["envship channel edit web/staging"]);
1728
+ channelEdit.option("--keep-temp-on-error", "keep temporary dotenv file if edit/publish fails");
1729
+ addKeyFileOptions(channelEdit);
1730
+ channelEdit.action(commandAction(async (ctx, channelRef, options) => {
1731
+ const dir = await mkdtemp(join(tmpdir(), "envship-"));
1732
+ await chmod(dir, 448).catch(() => void 0);
1733
+ const file = join(dir, `${channelRef.replace("/", "-")}.env`);
1734
+ try {
1735
+ const { env } = await pullEnv(ctx, channelRef, options);
1736
+ await writeFile(file, stringifyDotenv(env), { mode: 384 });
1737
+ const editor = process.env.EDITOR ?? (process.platform === "win32" ? "notepad" : "vi");
1738
+ validateChildCommand(editor, [file]);
1739
+ await new Promise((resolvePromise, reject) => {
1740
+ const child = spawnSafe(editor, [file], { stdio: "inherit" });
1741
+ child.on("error", reject);
1742
+ child.on("exit", (code) => code === 0 ? resolvePromise() : reject(new CliError("editor_failed", `${editor} exited with ${code}.`)));
1743
+ });
1744
+ const tempPublishFile = file;
1745
+ const data = await publishChannel(ctx, channelRef, { ...options, in: tempPublishFile });
1746
+ await rm(dir, { recursive: true, force: true });
1747
+ return { data, message: `Edited and published ${channelRef} version ${data.version}.` };
1748
+ } catch (error) {
1749
+ if (!options.keepTempOnError) await rm(dir, { recursive: true, force: true });
1750
+ else ctx.info(`Kept temporary edit file at ${file}`);
1751
+ throw error;
1752
+ }
1753
+ }));
1754
+ var channelRewrap = command(channel, "rewrap <channel>", "Update encrypted access for active recipients", ["envship channel rewrap web/staging"]);
1755
+ channelRewrap.option("--dry-run", "validate and show rewrap plan without writing");
1756
+ addKeyFileOptions(channelRewrap);
1757
+ channelRewrap.action(commandAction(async (ctx, channelRef, options) => {
1758
+ const data = await rewrapChannel(ctx, channelRef, options);
1759
+ return { data, message: options.dryRun ? `Rewrap dry-run ready for ${channelRef}.` : `Rewrapped ${channelRef} version ${data.version}.` };
1760
+ }));
1761
+ command(channel, "rollback <channel>", "Move a channel pointer back to a previous immutable version", ["envship channel rollback web/staging --version-id <id> --yes"]).requiredOption("--version-id <id>", "bundle version id to make current").option("--dry-run", "show rollback target without writing").action(commandAction(async (ctx, channelRef, options) => {
1762
+ const target = await findChannel(ctx, channelRef);
1763
+ const planned = { channel: channelRef, channel_id: target.id, version_id: options.versionId };
1764
+ if (options.dryRun) return { data: planned, message: `Rollback dry-run ready for ${channelRef}.` };
1765
+ await confirmAction(ctx, `Rollback ${channelRef} to ${options.versionId}?`);
1766
+ const data = await api(ctx, `/v1/channels/${target.id}/rollback`, { method: "POST", body: JSON.stringify({ version_id: options.versionId }) });
1767
+ return { data, message: `Rolled ${channelRef} back.` };
1768
+ }));
1769
+ var envsync = group("envsync", "Watch encrypted env changes from R2/CDN runtime descriptors");
1770
+ var envsyncWatch = command(envsync, "watch [cmd...]", "Foreground autopull for a scoped channel", ["envship envsync watch --channel web/production --out .env", "envship envsync watch --channel web/production --out .env --on-change -- npm run reload"]).requiredOption("--channel <channel>", "bundle/channel ref to watch").requiredOption("--out <path>", "dotenv output path").option("--interval <duration>", "poll interval, for example 60s or 5m").option("--name <name>", "EnvSync service/status name").option("--on-change", "run the command after -- when a verified update is written").option("--force", "overwrite output path").addOption(new Option("--once", "run one check and exit").hideHelp());
1771
+ envsyncWatch.allowUnknownOption(true).passThroughOptions();
1772
+ addKeyFileOptions(envsyncWatch);
1773
+ envsyncWatch.action(commandAction(async (ctx, cmd, options) => {
1774
+ channelParts(options.channel);
1775
+ const { seconds } = enforceEnvSyncInterval("watch", options.interval);
1776
+ const name = validateEnvSyncName(options.name ?? defaultEnvSyncName(options.channel));
1777
+ const hook = options.onChange ? parseHook(cmd) : void 0;
1778
+ if (options.onChange && !hook) throw new CliError("envsync_hook_required", "Pass a command after -- when using --on-change.");
1779
+ const existing = envSyncServices()[name];
1780
+ let service = {
1781
+ ...existing ?? {},
1782
+ name,
1783
+ channel: options.channel,
1784
+ out: validateSafeLocalPath(options.out, { label: "output", force: true }),
1785
+ interval_seconds: seconds,
1786
+ mode: "watch",
1787
+ hook,
1788
+ created_at: existing?.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
1789
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1790
+ };
1791
+ let attempt = 0;
1792
+ while (true) {
1793
+ try {
1794
+ const result = await envSyncRuntimeCheck(ctx, service, { ...options, force: options.force ?? true });
1795
+ service = { ...result.service, last_status: result.status };
1796
+ await saveEnvSyncService(service);
1797
+ void reportRuntimeClient(ctx, { lastDeploySyncAt: result.status.state === "bundle_updated" ? result.status.checked_at : void 0, envsyncMode: "watch", envsyncIntervalSeconds: service.interval_seconds });
1798
+ attempt = 0;
1799
+ if (ctx.json || options.once) {
1800
+ return { data: { service, status: result.status }, message: result.status.state === "bundle_updated" ? `EnvSync updated ${service.channel} version ${result.status.version}.` : `EnvSync checked ${service.channel}: ${result.status.state}.` };
1801
+ }
1802
+ ctx.info(`EnvSync ${result.status.state}: ${service.channel}${result.status.version ? ` v${result.status.version}` : ""}`);
1803
+ } catch (error) {
1804
+ attempt += 1;
1805
+ const cliError = normalizeError(error);
1806
+ const status = {
1807
+ state: cliError.code === "descriptor_invalid" ? "descriptor_invalid" : cliError.code.includes("signature") ? "signature_failed" : "control_plane_unavailable_read_only",
1808
+ channel: service.channel,
1809
+ checked_at: (/* @__PURE__ */ new Date()).toISOString(),
1810
+ runtime_source: "r2_cdn",
1811
+ error: cliError.code
1812
+ };
1813
+ service = { ...service, last_status: status, updated_at: (/* @__PURE__ */ new Date()).toISOString() };
1814
+ await saveEnvSyncService(service);
1815
+ if (ctx.json || options.once) return { data: { service, status }, warnings: [cliError.message], exitCode: 1 };
1816
+ ctx.info(`Warning: EnvSync ${status.state}: ${cliError.message}`);
1817
+ }
1818
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, jitteredDelayMs(seconds, attempt)));
1819
+ }
1820
+ }));
1821
+ var envsyncInstall = command(envsync, "install [cmd...]", "Create an EnvSync daemon service config", ["envship envsync install --channel web/production --out /etc/envship/web.env --interval 60s", "envship envsync install --channel web/production --out /etc/envship/web.env --on-change -- /usr/bin/systemctl reload app"]).requiredOption("--channel <channel>", "bundle/channel ref to watch").requiredOption("--out <path>", "dotenv output path").option("--interval <duration>", "poll interval, for example 120s or 5m").option("--name <name>", "EnvSync service name").option("--on-change", "run the command after -- when a verified update is written").option("--force", "overwrite existing EnvSync service config");
1822
+ envsyncInstall.allowUnknownOption(true).passThroughOptions();
1823
+ envsyncInstall.action(commandAction(async (_ctx, cmd, options) => {
1824
+ channelParts(options.channel);
1825
+ const { seconds, limits } = enforceEnvSyncInterval("daemon", options.interval);
1826
+ const name = validateEnvSyncName(options.name ?? defaultEnvSyncName(options.channel));
1827
+ const hook = options.onChange ? parseHook(cmd, true) : void 0;
1828
+ if (options.onChange && !hook) throw new CliError("envsync_hook_required", "Pass a command after -- when using --on-change.");
1829
+ const services = envSyncServices();
1830
+ if (services[name] && !options.force) throw new CliError("envsync_service_exists", `EnvSync service ${name} already exists.`, { hint: "Use --force to replace it." });
1831
+ const serviceFile = envSyncServiceFilePath(name);
1832
+ const service = {
1833
+ name,
1834
+ channel: options.channel,
1835
+ out: validateSafeLocalPath(options.out, { label: "output", force: true }),
1836
+ interval_seconds: seconds,
1837
+ mode: "daemon",
1838
+ hook,
1839
+ service_file: serviceFile,
1840
+ created_at: services[name]?.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
1841
+ updated_at: (/* @__PURE__ */ new Date()).toISOString(),
1842
+ last_status: services[name]?.last_status ?? {
1843
+ state: "not_checked",
1844
+ channel: options.channel,
1845
+ checked_at: (/* @__PURE__ */ new Date()).toISOString(),
1846
+ runtime_source: "r2_cdn",
1847
+ hook_status: hook ? void 0 : "not_configured"
1848
+ }
1849
+ };
1850
+ await saveEnvSyncService(service);
1851
+ await mkdir(dirname(serviceFile), { recursive: true, mode: 448 });
1852
+ await writeFile(serviceFile, serviceFileBody(service), { mode: 384 });
1853
+ await chmod(serviceFile, 384).catch(() => void 0);
1854
+ return {
1855
+ data: { service, limits, service_file: serviceFile },
1856
+ message: `EnvSync daemon config written to ${serviceFile}. Install it with your OS service manager when ready.`
1857
+ };
1858
+ }));
1859
+ command(envsync, "status", "Show EnvSync watcher/daemon status", ["envship envsync status", "envship envsync status --name web-production"]).option("--name <name>", "EnvSync service name").action(commandAction(async (_ctx, options) => {
1860
+ const services = envSyncServices();
1861
+ const rows = Object.values(services).filter((service) => !options.name || service.name === options.name).map((service) => ({
1862
+ name: service.name,
1863
+ channel: service.channel,
1864
+ mode: service.mode,
1865
+ interval: `${service.interval_seconds}s`,
1866
+ state: service.last_status?.state ?? "not_checked",
1867
+ version: service.last_status?.version ?? ""
1868
+ }));
1869
+ if (options.name && rows.length === 0) throw new CliError("envsync_service_not_found", `EnvSync service ${options.name} is not configured.`);
1870
+ return { data: { services: Object.values(services).filter((service) => !options.name || service.name === options.name) }, stdout: table(rows, ["name", "channel", "mode", "interval", "state", "version"]) || "No EnvSync services configured." };
1871
+ }));
1872
+ command(envsync, "uninstall", "Remove an EnvSync daemon service config", ["envship envsync uninstall --name web-production --yes"]).requiredOption("--name <name>", "EnvSync service name").action(commandAction(async (ctx, options) => {
1873
+ const name = validateEnvSyncName(options.name);
1874
+ const service = await loadEnvSyncService(name);
1875
+ await confirmAction(ctx, `Remove EnvSync service ${name}?`);
1876
+ await removeEnvSyncService(name);
1877
+ return { data: { removed: true, service }, message: `EnvSync service removed: ${name}` };
1878
+ }));
1879
+ var grant = group("grant", "Create, inspect, update, and revoke channel grants");
1880
+ command(grant, "create <channel>", "Grant channel access to a registered user key", ["envship grant create web/staging --keyset kst_example --preset editor"]).requiredOption("--keyset <keysetId>", "target registered keyset id").option("--preset <preset>", "metadata-only, pull-only, editor, publisher, channel-admin", "editor").option("--dry-run", "show grant payload without writing").action(commandAction(async (ctx, channelRef, options) => {
1881
+ const data = await createGrant(ctx, channelRef, options);
1882
+ return { data, message: options.dryRun ? `Grant dry-run ready for ${channelRef}.` : `Granted ${channelRef}.` };
1883
+ }));
1884
+ command(grant, "list", "List channel grants in the active workspace", ["envship grant list"]).action(commandAction(async (ctx) => {
1885
+ const state = await dashboardState(ctx);
1886
+ const rows = state.grants.map((item) => ({ id: item.id, channel: item.channel_ref, keyset: item.keyset_id, status: item.grant_status, capabilities: item.capabilities.join(",") }));
1887
+ return { data: { grants: state.grants }, stdout: table(rows, ["id", "channel", "keyset", "status", "capabilities"]) || "No grants found." };
1888
+ }));
1889
+ command(grant, "update <grantId>", "Update a channel grant preset", ["envship grant update <grant-id> --preset pull-only"]).requiredOption("--preset <preset>", "metadata-only, pull-only, editor, publisher, channel-admin").action(commandAction(async (ctx, grantId, options) => {
1890
+ const data = await api(ctx, `/v1/channel-grants/${grantId}/update`, { method: "POST", body: JSON.stringify({ preset: options.preset }) });
1891
+ return { data, message: `Updated grant ${grantId}.` };
1892
+ }));
1893
+ command(grant, "revoke <grantId>", "Revoke a channel grant", ["envship grant revoke <grant-id> --yes"]).action(commandAction(async (ctx, grantId) => {
1894
+ await confirmAction(ctx, `Revoke grant ${grantId}?`);
1895
+ const data = await api(ctx, `/v1/channel-grants/${grantId}/revoke`, { method: "POST" });
1896
+ return { data, message: `Revoked grant ${grantId}.` };
1897
+ }));
1898
+ var machine = group("machine", "Manage machine identities and reusable deploy tokens that share Runtime slots");
1899
+ command(machine, "install", "Install a scoped machine identity on CI/VPS", ["envship machine install --name github-actions --channel web/production"]).option("--workspace <workspaceId>", "workspace id").option("--name <name>", "machine name", "envship-machine").option("--channel <channel...>", "allowed channel refs").option("--deploy-token <token>", "reusable deploy token for fresh machine installs and autoscaling").action(commandAction(async (ctx, options) => {
1900
+ const machineKey = await generateMachineSigningKey(options.name);
1901
+ if (options.deployToken) {
1902
+ if (!/^(edt|edt2)_[A-Za-z0-9_-]{16,256}$/u.test(options.deployToken)) throw new CliError("deploy_token_invalid", "Deploy token format is invalid.");
1903
+ try {
1904
+ const data2 = await api(ctx, "/v1/deploy-token-install", {
1905
+ method: "POST",
1906
+ body: JSON.stringify({ token: options.deployToken, name: options.name, signing_key_id: machineKey.signing_key_id, signing_public_jwk: machineKey.signing_public_jwk })
1907
+ });
1908
+ await saveConfig({ ...readConfig(), machine: { machine_id: data2.machine_id, workspace_id: data2.workspace_id ?? null, project_id: data2.project_id ?? null, signing_key_id: machineKey.signing_key_id, signing_private_jwk: machineKey.signing_private_jwk, channels: data2.channels, analytics_client_id: data2.runtime?.analytics_client_id ?? null, autoupdate_enabled: true, runtime: { snapshot_url: data2.runtime?.machine_snapshot_url ?? null, channels: data2.runtime?.channels ?? [], updated_at: (/* @__PURE__ */ new Date()).toISOString() } } });
1909
+ void reportRuntimeClient(ctx);
1910
+ return { data: { ...data2, signing_key_id: machineKey.signing_key_id }, message: `Machine installed from deploy token: ${data2.machine_id}` };
1911
+ } catch (error) {
1912
+ if (!parseDeployTokenV2(options.deployToken)) throw error;
1913
+ const { snapshot, snapshotUrl } = await deployTokenRuntimeSnapshot(ctx, options.deployToken);
1914
+ const machineId = `pending_${machineKey.signing_key_id}`;
1915
+ await saveConfig({
1916
+ ...readConfig(),
1917
+ machine: {
1918
+ machine_id: machineId,
1919
+ workspace_id: snapshot.workspace_id,
1920
+ project_id: snapshot.project_id ?? null,
1921
+ signing_key_id: machineKey.signing_key_id,
1922
+ signing_private_jwk: machineKey.signing_private_jwk,
1923
+ channels: snapshot.channels.map((item) => item.channel_ref),
1924
+ analytics_client_id: snapshot.analytics_client_id ?? null,
1925
+ autoupdate_enabled: true,
1926
+ runtime: {
1927
+ snapshot_url: snapshotUrl,
1928
+ channels: snapshot.channels,
1929
+ updated_at: (/* @__PURE__ */ new Date()).toISOString(),
1930
+ pending_offline_install: true
1931
+ }
1932
+ }
1933
+ });
1934
+ void reportRuntimeClient(ctx);
1935
+ return {
1936
+ data: { machine_id: machineId, channels: snapshot.channels.map((item) => item.channel_ref), signing_key_id: machineKey.signing_key_id, runtime_snapshot_url: snapshotUrl, pending_offline_install: true },
1937
+ warnings: ["Control plane was unavailable; installed from encrypted CDN deploy-token snapshot and will reconcile later."],
1938
+ message: `Machine installed from deploy-token snapshot: ${machineId}`
1939
+ };
1940
+ }
1941
+ }
1942
+ const state = await dashboardState(ctx);
1943
+ const workspaceId = activeWorkspaceId(state, options.workspace);
1944
+ const data = await api(ctx, "/v1/machines", {
1945
+ method: "POST",
1946
+ body: JSON.stringify({ workspace_id: workspaceId, name: options.name, signing_key_id: machineKey.signing_key_id, signing_public_jwk: machineKey.signing_public_jwk, channels: options.channel ?? [] })
1947
+ });
1948
+ await saveConfig({ ...readConfig(), machine: { machine_id: data.machine_id, workspace_id: data.workspace_id ?? workspaceId, project_id: data.project_id ?? null, signing_key_id: machineKey.signing_key_id, signing_private_jwk: machineKey.signing_private_jwk, channels: options.channel ?? [], analytics_client_id: data.runtime?.analytics_client_id ?? null, autoupdate_enabled: true, runtime: { snapshot_url: data.runtime?.machine_snapshot_url ?? null, channels: data.runtime?.channels ?? [], updated_at: (/* @__PURE__ */ new Date()).toISOString() } } });
1949
+ void reportRuntimeClient(ctx);
1950
+ return { data: { ...data, signing_key_id: machineKey.signing_key_id, channels: options.channel ?? [] }, message: `Machine installed: ${data.machine_id}` };
1951
+ }));
1952
+ command(machine, "status", "Show the local machine credential", ["envship machine status"]).action(commandAction(async () => {
1953
+ const config = readConfig();
1954
+ if (!config.machine) throw new CliError("machine_not_installed", "No local machine credential is installed.");
1955
+ const machine2 = { machine_id: config.machine.machine_id, signing_key_id: config.machine.signing_key_id, channels: config.machine.channels };
1956
+ return { data: { machine: machine2 }, stdout: table([{ machine: machine2.machine_id, signing: machine2.signing_key_id, channels: machine2.channels.join(",") || "none" }], ["machine", "signing", "channels"]) };
1957
+ }));
1958
+ command(machine, "deploy-token-create", "Create a reusable deploy token for fresh installs/autoscaling", ["envship machine deploy-token-create --project <project-id> --channel web/production"]).requiredOption("--project <projectId>", "project id").option("--name <name>", "deploy token name", "Deploy token").option("--channel <channel...>", "allowed channel refs").action(commandAction(async (ctx, options) => {
1959
+ const data = await api(ctx, `/v1/projects/${options.project}/deploy-tokens`, {
1960
+ method: "POST",
1961
+ body: JSON.stringify({ name: options.name, channels: options.channel ?? [] })
1962
+ });
1963
+ return { data, message: "Deploy token created. Store it now; EnvShip will not show the token again." };
1964
+ }));
1965
+ command(machine, "deploy-token-list", "List reusable deploy tokens for a project without showing token values", ["envship machine deploy-token-list --project <project-id>"]).requiredOption("--project <projectId>", "project id").action(commandAction(async (ctx, options) => {
1966
+ const data = await api(ctx, `/v1/projects/${options.project}/deploy-tokens`);
1967
+ return { data, stdout: table(data.deploy_tokens.map((token) => ({ id: token.id, name: token.name, status: token.status, channels: token.channels.join(",") })), ["id", "name", "status", "channels"]) || "No deploy tokens." };
1968
+ }));
1969
+ for (const action of ["revoke", "rotate"]) {
1970
+ command(machine, `deploy-token-${action} <tokenId>`, `${action === "revoke" ? "Revoke" : "Rotate"} a reusable deploy token`, [`envship machine deploy-token-${action} <token-id> --yes`]).action(commandAction(async (ctx, tokenId) => {
1971
+ if (action === "revoke") await confirmAction(ctx, `Revoke deploy token ${tokenId}?`);
1972
+ const data = await api(ctx, `/v1/deploy-tokens/${tokenId}/${action}`, { method: "POST" });
1973
+ return { data, message: `Deploy token ${action === "revoke" ? "revoked" : "rotated"}: ${tokenId}` };
1974
+ }));
1975
+ }
1976
+ var update = group("update", "Check CLI/runtime compatibility and AutoUpdate preferences");
1977
+ command(update, "check", "Check the signed EnvShip CLI runtime version descriptor", ["envship update check"]).action(commandAction(async (ctx) => {
1978
+ const { descriptor, warnings } = await versionWarnings(ctx);
1979
+ if (!descriptor) throw new CliError("runtime_version_unavailable", "Runtime version metadata is temporarily unavailable.");
1980
+ const rows = [{
1981
+ installed: cliVersion,
1982
+ recommended: descriptor.recommended_version,
1983
+ minimum: descriptor.minimum_supported_version,
1984
+ latest: descriptor.latest_version,
1985
+ channel: descriptor.release_channel
1986
+ }];
1987
+ return {
1988
+ data: { installed_version: cliVersion, descriptor },
1989
+ stdout: table(rows, ["installed", "recommended", "minimum", "latest", "channel"]),
1990
+ warnings,
1991
+ message: warnings.length ? void 0 : "EnvShip CLI is compatible."
1992
+ };
1993
+ }));
1994
+ command(update, "autoupdate <state>", "Set AutoUpdate preference for this workstation or machine", ["envship update autoupdate on", "envship update autoupdate off"]).action(commandAction(async (ctx, state) => {
1995
+ if (!["on", "off"].includes(state)) throw new CliError("autoupdate_state_invalid", "Use 'on' or 'off'.");
1996
+ const config = readConfig();
1997
+ const enabled = state === "on";
1998
+ if (config.machine) {
1999
+ await saveConfig({ ...config, machine: { ...config.machine, autoupdate_enabled: enabled } });
2000
+ } else {
2001
+ const workstation = localWorkstation(config);
2002
+ await saveConfig({ ...config, workstation: { ...workstation, autoupdate_enabled: enabled } });
2003
+ }
2004
+ void reportRuntimeClient(ctx);
2005
+ return {
2006
+ data: { autoupdate_enabled: enabled, scope: config.machine ? "machine" : "workstation" },
2007
+ warnings: enabled ? [] : ["Pinned or disabled AutoUpdate can cause future compatibility warnings when EnvShip raises the minimum supported runtime version."],
2008
+ message: `AutoUpdate ${enabled ? "enabled" : "disabled"} for this ${config.machine ? "machine" : "CLI Workstation"}.`
2009
+ };
2010
+ }));
2011
+ command(machine, "revoke", "Revoke a machine identity through the API", ["envship machine revoke --yes", "envship machine revoke --id <machine-id> --yes"]).option("--id <machineId>", "machine id; defaults to the locally installed machine").action(commandAction(async (ctx, options) => {
2012
+ const config = readConfig();
2013
+ const machineId = options.id ?? config.machine?.machine_id;
2014
+ if (!machineId) throw new CliError("machine_id_required", "Pass --id or install a local machine first.");
2015
+ await confirmAction(ctx, `Revoke machine ${machineId}?`);
2016
+ const data = await api(ctx, `/v1/machines/${machineId}/revoke`, { method: "POST" });
2017
+ if (config.machine?.machine_id === machineId) await saveConfig({ ...config, machine: void 0 });
2018
+ return { data, message: `Machine revoked: ${machineId}` };
2019
+ }));
2020
+ var invite = group("invite", "Manage workspace invitations");
2021
+ command(invite, "create", "Create a workspace invite", ["envship invite create --email teammate@example.com --role viewer"]).option("--workspace <workspaceId>", "workspace id").requiredOption("--email <email>", "invitee email").option("--role <role>", "owner, admin, developer, or viewer", "viewer").option("--send-email", "send invite email if email provider is configured").action(commandAction(async (ctx, options) => {
2022
+ const state = await dashboardState(ctx);
2023
+ const workspaceId = activeWorkspaceId(state, options.workspace);
2024
+ const data = await api(ctx, `/v1/workspaces/${workspaceId}/invitations`, { method: "POST", body: JSON.stringify({ email: options.email, role: options.role, send_email: Boolean(options.sendEmail) }) });
2025
+ return { data, message: `Invite created for ${options.email}.` };
2026
+ }));
2027
+ command(invite, "list", "List pending workspace invitations", ["envship invite list"]).option("--workspace <workspaceId>", "workspace id").action(commandAction(async (ctx, options) => {
2028
+ const state = await dashboardState(ctx);
2029
+ const workspaceId = activeWorkspaceId(state, options.workspace);
2030
+ const data = await api(ctx, `/v1/workspaces/${workspaceId}/invitations`);
2031
+ const rows = data.invitations.map((item) => ({ id: item.id, email: item.email, role: item.role, status: item.status, expires: item.expires_at }));
2032
+ return { data, stdout: table(rows, ["id", "email", "role", "status", "expires"]) || "No pending invites." };
2033
+ }));
2034
+ for (const action of ["resend", "revoke"]) {
2035
+ command(invite, `${action} <inviteId>`, `${action === "resend" ? "Resend" : "Revoke"} an invite`, [`envship invite ${action} <invite-id> --workspace <workspace-id>${action === "revoke" ? " --yes" : ""}`]).requiredOption("--workspace <workspaceId>", "workspace id").action(commandAction(async (ctx, inviteId, options) => {
2036
+ if (action === "revoke") await confirmAction(ctx, `Revoke invite ${inviteId}?`);
2037
+ const data = await api(ctx, `/v1/workspaces/${options.workspace}/invitations/${inviteId}/${action}`, { method: "POST" });
2038
+ return { data, message: `Invite ${action === "resend" ? "resent" : "revoked"}: ${inviteId}` };
2039
+ }));
2040
+ }
2041
+ var member = group("member", "Inspect and manage workspace members");
2042
+ command(member, "list", "List workspace members", ["envship member list"]).option("--workspace <workspaceId>", "workspace id").action(commandAction(async (ctx, options) => {
2043
+ const state = await dashboardState(ctx);
2044
+ const workspaceId = activeWorkspaceId(state, options.workspace);
2045
+ const data = await api(ctx, `/v1/workspaces/${workspaceId}/members`);
2046
+ const rows = data.members.map((item) => ({ user: item.user_id, email: item.email, role: item.role, status: item.status }));
2047
+ return { data, stdout: table(rows, ["user", "email", "role", "status"]) || "No members found." };
2048
+ }));
2049
+ command(member, "role <userId>", "Update a workspace member role", ["envship member role <user-id> --role developer --workspace <workspace-id>"]).requiredOption("--workspace <workspaceId>", "workspace id").requiredOption("--role <role>", "owner, admin, developer, or viewer").action(commandAction(async (ctx, userId, options) => {
2050
+ const data = await api(ctx, `/v1/workspaces/${options.workspace}/members/${userId}/role`, { method: "POST", body: JSON.stringify({ role: options.role }) });
2051
+ return { data, message: `Updated member ${userId}.` };
2052
+ }));
2053
+ command(member, "remove <userId>", "Deactivate a workspace member", ["envship member remove <user-id> --workspace <workspace-id> --yes"]).requiredOption("--workspace <workspaceId>", "workspace id").action(commandAction(async (ctx, userId, options) => {
2054
+ await confirmAction(ctx, `Remove member ${userId}?`);
2055
+ const data = await api(ctx, `/v1/workspaces/${options.workspace}/members/${userId}/deactivate`, { method: "POST" });
2056
+ return { data, message: `Removed member ${userId}.` };
2057
+ }));
2058
+ var project = group("project", "Inspect and manage project assignments");
2059
+ command(project, "list", "List visible projects", ["envship project list"]).action(commandAction(async (ctx) => {
2060
+ const state = await dashboardState(ctx);
2061
+ const rows = state.projects.map((item) => ({ id: item.id, name: item.name, slug: item.slug, role: item.role ?? "" }));
2062
+ return { data: { projects: state.projects }, stdout: table(rows, ["id", "name", "slug", "role"]) || "No projects found." };
2063
+ }));
2064
+ command(project, "assign <userId>", "Assign a member to a project", ["envship project assign <user-id> --project <project-id> --role developer"]).requiredOption("--project <projectId>", "project id").option("--role <role>", "manager, developer, or viewer", "developer").action(commandAction(async (ctx, userId, options) => {
2065
+ const data = await api(ctx, `/v1/projects/${options.project}/assignments`, { method: "POST", body: JSON.stringify({ user_id: userId, role: options.role }) });
2066
+ return { data, message: `Assigned ${userId} to project ${options.project}.` };
2067
+ }));
2068
+ command(project, "remove <userId>", "Remove a member project assignment", ["envship project remove <user-id> --project <project-id>"]).requiredOption("--project <projectId>", "project id").action(commandAction(async (ctx, userId, options) => {
2069
+ const data = await api(ctx, `/v1/projects/${options.project}/assignments/${userId}/remove`, { method: "POST" });
2070
+ return { data, message: `Removed ${userId} from project ${options.project}.` };
2071
+ }));
2072
+ var audit = group("audit", "Inspect and export audit history");
2073
+ command(audit, "list", "List recent audit events", ["envship audit list"]).option("--workspace <workspaceId>", "workspace id").action(commandAction(async (ctx, options) => {
2074
+ const state = await dashboardState(ctx);
2075
+ const workspaceId = activeWorkspaceId(state, options.workspace);
2076
+ const data = await api(ctx, `/v1/workspaces/${workspaceId}/audit`);
2077
+ const rows = data.audit_events.map((item) => ({ id: item.id, action: item.action, subject: `${item.subject_type}:${item.subject_id ?? ""}`, created: item.created_at }));
2078
+ return { data, stdout: table(rows, ["id", "action", "subject", "created"]) || "No audit events." };
2079
+ }));
2080
+ command(audit, "export", "Export audit history as JSON", ["envship audit export --out audit.json"]).option("--workspace <workspaceId>", "workspace id").option("--out <path>", "output path or - for stdout", "-").option("--force", "overwrite output path").action(commandAction(async (ctx, options) => {
2081
+ const state = await dashboardState(ctx);
2082
+ const workspaceId = activeWorkspaceId(state, options.workspace);
2083
+ const data = await api(ctx, `/v1/workspaces/${workspaceId}/audit/export`, { method: "POST" });
2084
+ await writeOutput(options.out, JSON.stringify(data, null, 2), options.force);
2085
+ return { data, message: options.out === "-" ? void 0 : `Audit export written to ${options.out}.` };
2086
+ }));
2087
+ var billing = group("billing", "Inspect and open PayPal subscription billing actions");
2088
+ command(billing, "status", "Show workspace billing status", ["envship billing status"]).option("--workspace <workspaceId>", "workspace id").action(commandAction(async (ctx, options) => {
2089
+ const state = await dashboardState(ctx);
2090
+ const workspaceId = activeWorkspaceId(state, options.workspace);
2091
+ const data = await api(ctx, `/v1/workspaces/${workspaceId}/billing`);
2092
+ return {
2093
+ data,
2094
+ stdout: table([{
2095
+ plan: data.plan,
2096
+ paypal: data.paypal_configured ? "configured" : "setup_blocked",
2097
+ receiver: data.paypal_receiver_email ?? "not_configured",
2098
+ cli_workstations: `${data.cli_workstation_billing?.workstations_included_per_user ?? "?"}/user`
2099
+ }], ["plan", "paypal", "receiver", "cli_workstations"])
2100
+ };
2101
+ }));
2102
+ command(billing, "checkout", "Open a PayPal subscription checkout link", ["envship billing checkout --workspace <workspace-id> --plan pro", "envship billing checkout --workspace <workspace-id> --addon cli_workstations_5 --user <user-id>"]).option("--workspace <workspaceId>", "workspace id").option("--plan <plan>", "paid plan", "pro").option("--addon <addon>", "add-on type, for example cli_workstations_5").option("--user <userId>", "user id for per-user add-ons").action(commandAction(async (ctx, options) => {
2103
+ const state = await dashboardState(ctx);
2104
+ const workspaceId = activeWorkspaceId(state, options.workspace);
2105
+ const data = await api(ctx, `/v1/workspaces/${workspaceId}/billing/checkout`, { method: "POST", body: JSON.stringify(options.addon ? { addon: options.addon, user_id: options.user } : { plan: options.plan }) });
2106
+ return { data, message: "PayPal subscription checkout link created." };
2107
+ }));
2108
+ var configCommand = group("config", "Inspect and update local CLI config");
2109
+ command(configCommand, "path", "Print the local config path", ["envship config path"]).action(commandAction(async () => ({ data: { path: configPath }, stdout: configPath })));
2110
+ command(configCommand, "get [key]", "Read safe local CLI config", ["envship config get", "envship config get apiBaseUrl"]).action(commandAction(async (_ctx, key) => {
2111
+ const config = safeConfig(readConfig());
2112
+ const allowed = new Set(Object.keys(config));
2113
+ if (key && !allowed.has(key)) throw new CliError("config_key_unsafe", `Config key ${key} is not available through config get.`, { hint: `Safe keys: ${Array.from(allowed).join(", ")}.` });
2114
+ const data = key ? { key, value: config[key] ?? null } : config;
2115
+ return { data, stdout: JSON.stringify(sanitizeCliData(data), null, 2) };
2116
+ }));
2117
+ command(configCommand, "set <key> <value>", "Write a local CLI config value", ["envship config set apiBaseUrl https://api.envship.com"]).action(commandAction(async (_ctx, key, value) => {
2118
+ const allowed = /* @__PURE__ */ new Set(["apiBaseUrl", "appBaseUrl", "keyfilePath"]);
2119
+ if (!allowed.has(key)) throw new CliError("config_key_unknown", `Unknown config key ${key}.`, { hint: "Allowed keys: apiBaseUrl, appBaseUrl, keyfilePath." });
2120
+ if (key === "apiBaseUrl" || key === "appBaseUrl") value = validateOriginUrl(value, key);
2121
+ if (key === "keyfilePath") value = validateSafeLocalPath(value, { label: "keyfilePath" });
2122
+ const next = { ...readConfig(), [key]: value };
2123
+ await saveConfig(next);
2124
+ return { data: { key, value }, message: `Config ${key} updated.` };
2125
+ }));
2126
+ command(program, "doctor", "Check API, auth, workspace, CLI Workstation, and machine readiness", ["envship doctor", "envship doctor --json"]).addOption(new Option("--keyfile <path>", "encrypted credential path").hideHelp()).addOption(new Option("--passphrase-env <name>", "environment variable containing the credential passphrase").hideHelp()).addOption(new Option("--passphrase <value>", "credential passphrase").hideHelp()).action(commandAction(async (ctx, options) => {
2127
+ const checks = [];
2128
+ try {
2129
+ await fetch(`${ctx.apiBaseUrl}/health`);
2130
+ checks.push({ name: "api", ok: true, message: ctx.apiBaseUrl });
2131
+ } catch {
2132
+ checks.push({ name: "api", ok: false, message: "API is not reachable." });
2133
+ }
2134
+ try {
2135
+ const me = await api(ctx, "/v1/me");
2136
+ checks.push({ name: "auth", ok: true, message: me.user?.email ?? "signed in" });
2137
+ } catch (error) {
2138
+ checks.push({ name: "auth", ok: false, message: normalizeError(error).message });
2139
+ }
2140
+ try {
2141
+ const state = await dashboardState(ctx);
2142
+ checks.push({ name: "workspace", ok: Boolean(state.active_workspace_id), message: state.active_workspace_id ?? "No active workspace." });
2143
+ } catch (error) {
2144
+ checks.push({ name: "workspace", ok: false, message: normalizeError(error).message });
2145
+ }
2146
+ try {
2147
+ if (options.keyfile || readConfig().keyfilePath) {
2148
+ const loaded = await loadKeyFile(ctx, options);
2149
+ checks.push({ name: "keyfile", ok: true, message: loaded.keyset.label });
2150
+ } else {
2151
+ checks.push({ name: "keyfile", ok: false, message: "No local KeyFile configured." });
2152
+ }
2153
+ } catch (error) {
2154
+ checks.push({ name: "keyfile", ok: false, message: normalizeError(error).message });
2155
+ }
2156
+ const config = readConfig();
2157
+ checks.push({ name: "machine", ok: Boolean(config.machine), message: config.machine?.machine_id ?? "No local machine credential." });
2158
+ const ok = checks.every((check) => check.ok);
2159
+ return { data: { ok, checks }, stdout: table(checks.map((check) => ({ check: check.name, ok: check.ok ? "yes" : "no", message: check.message })), ["check", "ok", "message"]), exitCode: ok ? 0 : 1 };
2160
+ }));
2161
+ var removedTopLevelCommands = /* @__PURE__ */ new Set(["login", "init", "publish", "pull", "run", "edit", "rewrap", "rollback", "whoami", "logout", "channels", "versions"]);
2162
+ if (removedTopLevelCommands.has(process.argv[2] ?? "")) {
2163
+ const replacements = {
2164
+ login: "envship auth login",
2165
+ init: "envship setup",
2166
+ publish: "envship channel publish",
2167
+ pull: "envship channel pull",
2168
+ run: "envship channel run",
2169
+ edit: "envship channel edit",
2170
+ rewrap: "envship channel rewrap",
2171
+ rollback: "envship channel rollback",
2172
+ whoami: "envship auth whoami",
2173
+ logout: "envship auth logout",
2174
+ channels: "envship channel list",
2175
+ versions: "envship channel versions"
2176
+ };
2177
+ const commandName = process.argv[2] ?? "";
2178
+ process.stderr.write(`Unknown command '${commandName}'. Use '${replacements[commandName]}' instead.
2179
+ `);
2180
+ process.exitCode = 1;
2181
+ } else {
2182
+ program.parseAsync().catch((error) => {
2183
+ const cliError = normalizeError(error);
2184
+ process.stderr.write(`${redactCliError(cliError.message)}
2185
+ `);
2186
+ if (cliError.hint) process.stderr.write(`Hint: ${redactCliError(cliError.hint)}
2187
+ `);
2188
+ process.exitCode = cliError.exitCode;
2189
+ }).finally(() => {
2190
+ if (process.env.ENVSHIP_CLI_FORCE_EXIT === "1") process.exit(process.exitCode ?? 0);
2191
+ });
2192
+ }