Skip to content

Commit 9ce3e64

Browse files
mcclurejtclaude
andauthored
feat(cli): add --env KEY=VALUE flag for inline environment variables (#111)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 720969a commit 9ce3e64

File tree

4 files changed

+180
-0
lines changed

4 files changed

+180
-0
lines changed

packages/cli/src/commands/compute/app/deploy.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
resolveDockerHubImageDigest,
3636
} from "../../../utils/dockerhub";
3737
import { isTlsEnabledFromEnvFile } from "../../../utils/tls";
38+
import { mergeInlineEnvVars } from "../../../utils/env";
3839
import type { SubmitBuildRequest } from "@layr-labs/ecloud-sdk";
3940

4041
export default class AppDeploy extends Command {
@@ -63,6 +64,11 @@ export default class AppDeploy extends Command {
6364
default: ".env",
6465
env: "ECLOUD_ENVFILE_PATH",
6566
}),
67+
env: Flags.string({
68+
required: false,
69+
description: "Inline environment variable in KEY=VALUE format (can be specified multiple times)",
70+
multiple: true,
71+
}),
6672
"log-visibility": Flags.string({
6773
required: false,
6874
description: "Log visibility setting: public, private, or off",
@@ -371,6 +377,11 @@ export default class AppDeploy extends Command {
371377
// 4. Get env file path interactively
372378
envFilePath = envFilePath ?? (await getEnvFileInteractive(flags["env-file"]));
373379

380+
// 4b. Merge inline --env KEY=VALUE vars (overrides env file values)
381+
if (flags.env && flags.env.length > 0) {
382+
envFilePath = mergeInlineEnvVars(envFilePath, flags.env);
383+
}
384+
374385
// 5. Get instance type interactively
375386
const availableTypes = await fetchAvailableInstanceTypes(
376387
environment,

packages/cli/src/commands/compute/app/upgrade.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
resolveDockerHubImageDigest,
3333
} from "../../../utils/dockerhub";
3434
import { isTlsEnabledFromEnvFile } from "../../../utils/tls";
35+
import { mergeInlineEnvVars } from "../../../utils/env";
3536
import type { SubmitBuildRequest } from "@layr-labs/ecloud-sdk";
3637

3738
export default class AppUpgrade extends Command {
@@ -62,6 +63,11 @@ export default class AppUpgrade extends Command {
6263
default: ".env",
6364
env: "ECLOUD_ENVFILE_PATH",
6465
}),
66+
env: Flags.string({
67+
required: false,
68+
description: "Inline environment variable in KEY=VALUE format (can be specified multiple times)",
69+
multiple: true,
70+
}),
6571
"log-visibility": Flags.string({
6672
required: false,
6773
description: "Log visibility setting: public, private, or off",
@@ -287,6 +293,11 @@ export default class AppUpgrade extends Command {
287293
// 4. Get env file path interactively
288294
envFilePath = envFilePath ?? (await getEnvFileInteractive(flags["env-file"]));
289295

296+
// 4b. Merge inline --env KEY=VALUE vars (overrides env file values)
297+
if (flags.env && flags.env.length > 0) {
298+
envFilePath = mergeInlineEnvVars(envFilePath, flags.env);
299+
}
300+
290301
// 5. Get current instance type (best-effort, used as default)
291302
const { publicClient, walletClient, address } = createViemClients({
292303
privateKey,
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import * as fs from "fs";
2+
import * as os from "os";
3+
import * as path from "path";
4+
import { mergeInlineEnvVars } from "../env";
5+
6+
describe("mergeInlineEnvVars", () => {
7+
let tmpDir: string;
8+
9+
beforeEach(() => {
10+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "env-test-"));
11+
});
12+
13+
afterEach(() => {
14+
fs.rmSync(tmpDir, { recursive: true, force: true });
15+
});
16+
17+
it("returns original path when no inline vars provided", () => {
18+
const envFile = path.join(tmpDir, ".env");
19+
fs.writeFileSync(envFile, "FOO=bar\n");
20+
expect(mergeInlineEnvVars(envFile, [])).toBe(envFile);
21+
});
22+
23+
it("creates a temp file with inline vars when no env file exists", () => {
24+
const result = mergeInlineEnvVars("", ["FOO=bar", "BAZ=qux"]);
25+
const content = fs.readFileSync(result, "utf-8");
26+
expect(content).toContain("FOO=bar");
27+
expect(content).toContain("BAZ=qux");
28+
});
29+
30+
it("merges inline vars with existing env file", () => {
31+
const envFile = path.join(tmpDir, ".env");
32+
fs.writeFileSync(envFile, "EXISTING=value\nOTHER=stuff\n");
33+
const result = mergeInlineEnvVars(envFile, ["NEW=added"]);
34+
const content = fs.readFileSync(result, "utf-8");
35+
expect(content).toContain("EXISTING=value");
36+
expect(content).toContain("OTHER=stuff");
37+
expect(content).toContain("NEW=added");
38+
});
39+
40+
it("inline vars override env file vars with same key", () => {
41+
const envFile = path.join(tmpDir, ".env");
42+
fs.writeFileSync(envFile, "FOO=old\nKEEP=me\n");
43+
const result = mergeInlineEnvVars(envFile, ["FOO=new"]);
44+
const content = fs.readFileSync(result, "utf-8");
45+
expect(content).not.toContain("FOO=old");
46+
expect(content).toContain("FOO=new");
47+
expect(content).toContain("KEEP=me");
48+
});
49+
50+
it("preserves comments and blank lines from env file", () => {
51+
const envFile = path.join(tmpDir, ".env");
52+
fs.writeFileSync(envFile, "# This is a comment\n\nFOO=bar\n");
53+
const result = mergeInlineEnvVars(envFile, ["BAZ=qux"]);
54+
const content = fs.readFileSync(result, "utf-8");
55+
expect(content).toContain("# This is a comment");
56+
expect(content).toContain("FOO=bar");
57+
expect(content).toContain("BAZ=qux");
58+
});
59+
60+
it("handles values containing equals signs", () => {
61+
const result = mergeInlineEnvVars("", ["URL=https://example.com?a=1&b=2"]);
62+
const content = fs.readFileSync(result, "utf-8");
63+
expect(content).toContain("URL=https://example.com?a=1&b=2");
64+
});
65+
66+
it("throws on invalid format (missing =)", () => {
67+
expect(() => mergeInlineEnvVars("", ["INVALID"])).toThrow(
68+
'Invalid --env format: "INVALID". Expected KEY=VALUE',
69+
);
70+
});
71+
72+
it("throws on empty key", () => {
73+
expect(() => mergeInlineEnvVars("", ["=value"])).toThrow(
74+
'Invalid --env format: "=value". Key cannot be empty',
75+
);
76+
});
77+
78+
it("allows empty value", () => {
79+
const result = mergeInlineEnvVars("", ["EMPTY="]);
80+
const content = fs.readFileSync(result, "utf-8");
81+
expect(content).toContain("EMPTY=");
82+
});
83+
});

packages/cli/src/utils/env.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* Utilities for merging inline --env KEY=VALUE flags with env files
3+
*/
4+
5+
import * as fs from "fs";
6+
import * as os from "os";
7+
import * as path from "path";
8+
9+
/**
10+
* Parse an inline env var string of the form KEY=VALUE.
11+
* Throws if the format is invalid.
12+
*/
13+
function parseInlineEnvVar(envVar: string): [string, string] {
14+
const eqIndex = envVar.indexOf("=");
15+
if (eqIndex === -1) {
16+
throw new Error(`Invalid --env format: "${envVar}". Expected KEY=VALUE`);
17+
}
18+
19+
const key = envVar.substring(0, eqIndex).trim();
20+
if (!key) {
21+
throw new Error(`Invalid --env format: "${envVar}". Key cannot be empty`);
22+
}
23+
24+
const value = envVar.substring(eqIndex + 1);
25+
return [key, value];
26+
}
27+
28+
/**
29+
* Merge inline env vars with an env file, writing the result to a temp file.
30+
* Inline vars override env file vars with the same key.
31+
*
32+
* Returns the path to the merged temp file, or the original env file path
33+
* if there are no inline vars to merge.
34+
*/
35+
export function mergeInlineEnvVars(envFilePath: string, inlineEnvVars: string[]): string {
36+
if (!inlineEnvVars || inlineEnvVars.length === 0) {
37+
return envFilePath;
38+
}
39+
40+
// Parse inline vars first to fail fast on bad format
41+
const inlineEntries = inlineEnvVars.map(parseInlineEnvVar);
42+
43+
// Read existing env file content (if any)
44+
let existingLines: string[] = [];
45+
if (envFilePath && fs.existsSync(envFilePath)) {
46+
existingLines = fs.readFileSync(envFilePath, "utf-8").split("\n");
47+
}
48+
49+
// Build a set of keys being overridden by inline vars
50+
const inlineKeys = new Set(inlineEntries.map(([key]) => key));
51+
52+
// Filter out lines from the env file that are overridden by inline vars
53+
const filteredLines = existingLines.filter((line) => {
54+
const trimmed = line.trim();
55+
if (!trimmed || trimmed.startsWith("#")) {
56+
return true; // keep comments and blank lines
57+
}
58+
const eqIndex = trimmed.indexOf("=");
59+
if (eqIndex === -1) {
60+
return true; // keep malformed lines
61+
}
62+
const key = trimmed.substring(0, eqIndex).trim();
63+
return !inlineKeys.has(key);
64+
});
65+
66+
// Append inline vars
67+
const inlineLines = inlineEntries.map(([key, value]) => `${key}=${value}`);
68+
const merged = [...filteredLines, ...inlineLines].join("\n");
69+
70+
// Write to temp file
71+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ecloud-env-"));
72+
const tmpFile = path.join(tmpDir, ".env");
73+
fs.writeFileSync(tmpFile, merged);
74+
return tmpFile;
75+
}

0 commit comments

Comments
 (0)