Allow configuring environmental variables in panel (#626)
- [ ] Add test cases
This commit is contained in:
62
e2e-tests/env_var.spec.ts
Normal file
62
e2e-tests/env_var.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./helpers/test_helper";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
test("env var", async ({ po }) => {
|
||||
await po.sendPrompt("tc=1");
|
||||
const appPath = await po.getCurrentAppPath();
|
||||
|
||||
await po.selectPreviewMode("configure");
|
||||
|
||||
// Create a new env var
|
||||
await po.page
|
||||
.getByRole("button", { name: "Add Environment Variable" })
|
||||
.click();
|
||||
await po.page.getByRole("textbox", { name: "Key" }).click();
|
||||
await po.page.getByRole("textbox", { name: "Key" }).fill("aKey");
|
||||
|
||||
await po.page.getByRole("textbox", { name: "Value" }).click();
|
||||
await po.page.getByRole("textbox", { name: "Value" }).fill("aValue");
|
||||
|
||||
await po.page.getByRole("button", { name: "Save" }).click();
|
||||
await snapshotEnvVar({ appPath, name: "create-aKey" });
|
||||
|
||||
// Create second env var
|
||||
await po.page
|
||||
.getByRole("button", { name: "Add Environment Variable" })
|
||||
.click();
|
||||
await po.page.getByRole("textbox", { name: "Key" }).click();
|
||||
await po.page.getByRole("textbox", { name: "Key" }).fill("bKey");
|
||||
|
||||
await po.page.getByRole("textbox", { name: "Value" }).click();
|
||||
await po.page.getByRole("textbox", { name: "Value" }).fill("bValue");
|
||||
|
||||
await po.page.getByRole("button", { name: "Save" }).click();
|
||||
await snapshotEnvVar({ appPath, name: "create-bKey" });
|
||||
|
||||
// Edit second env var
|
||||
await po.page.getByTestId("edit-env-var-bKey").click();
|
||||
await po.page.getByRole("textbox", { name: "Value" }).click();
|
||||
await po.page.getByRole("textbox", { name: "Value" }).fill("bValue2");
|
||||
await po.page.getByTestId("save-edit-env-var").click();
|
||||
await snapshotEnvVar({ appPath, name: "edit-bKey" });
|
||||
|
||||
// Delete first env var
|
||||
await po.page.getByTestId("delete-env-var-aKey").click();
|
||||
await snapshotEnvVar({ appPath, name: "delete-aKey" });
|
||||
});
|
||||
|
||||
async function snapshotEnvVar({
|
||||
appPath,
|
||||
name,
|
||||
}: {
|
||||
appPath: string;
|
||||
name: string;
|
||||
}) {
|
||||
expect(() => {
|
||||
const envFile = path.join(appPath, ".env.local");
|
||||
const envFileContent = fs.readFileSync(envFile, "utf8");
|
||||
expect(envFileContent).toMatchSnapshot({ name });
|
||||
}).toPass();
|
||||
}
|
||||
@@ -422,7 +422,7 @@ export class PageObject {
|
||||
// Preview panel
|
||||
////////////////////////////////
|
||||
|
||||
async selectPreviewMode(mode: "code" | "problems" | "preview") {
|
||||
async selectPreviewMode(mode: "code" | "problems" | "preview" | "configure") {
|
||||
await this.page.getByTestId(`${mode}-mode-button`).click();
|
||||
}
|
||||
|
||||
|
||||
1
e2e-tests/snapshots/env_var.spec.ts_create-aKey
Normal file
1
e2e-tests/snapshots/env_var.spec.ts_create-aKey
Normal file
@@ -0,0 +1 @@
|
||||
aKey=aValue
|
||||
2
e2e-tests/snapshots/env_var.spec.ts_create-bKey
Normal file
2
e2e-tests/snapshots/env_var.spec.ts_create-bKey
Normal file
@@ -0,0 +1,2 @@
|
||||
aKey=aValue
|
||||
bKey=bValue
|
||||
1
e2e-tests/snapshots/env_var.spec.ts_delete-aKey
Normal file
1
e2e-tests/snapshots/env_var.spec.ts_delete-aKey
Normal file
@@ -0,0 +1 @@
|
||||
bKey=bValue2
|
||||
2
e2e-tests/snapshots/env_var.spec.ts_edit-bKey
Normal file
2
e2e-tests/snapshots/env_var.spec.ts_edit-bKey
Normal file
@@ -0,0 +1,2 @@
|
||||
aKey=aValue
|
||||
bKey=bValue2
|
||||
534
src/__tests__/app_env_vars_utils.test.ts
Normal file
534
src/__tests__/app_env_vars_utils.test.ts
Normal file
@@ -0,0 +1,534 @@
|
||||
import { parseEnvFile, serializeEnvFile } from "@/ipc/utils/app_env_var_utils";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
describe("parseEnvFile", () => {
|
||||
it("should parse basic key=value pairs", () => {
|
||||
const content = `API_KEY=abc123
|
||||
DATABASE_URL=postgres://localhost:5432/mydb
|
||||
PORT=3000`;
|
||||
|
||||
const result = parseEnvFile(content);
|
||||
expect(result).toEqual([
|
||||
{ key: "API_KEY", value: "abc123" },
|
||||
{ key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" },
|
||||
{ key: "PORT", value: "3000" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle quoted values and remove quotes", () => {
|
||||
const content = `API_KEY="abc123"
|
||||
DATABASE_URL='postgres://localhost:5432/mydb'
|
||||
MESSAGE="Hello World"`;
|
||||
|
||||
const result = parseEnvFile(content);
|
||||
expect(result).toEqual([
|
||||
{ key: "API_KEY", value: "abc123" },
|
||||
{ key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" },
|
||||
{ key: "MESSAGE", value: "Hello World" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should skip empty lines", () => {
|
||||
const content = `API_KEY=abc123
|
||||
|
||||
DATABASE_URL=postgres://localhost:5432/mydb
|
||||
|
||||
|
||||
PORT=3000`;
|
||||
|
||||
const result = parseEnvFile(content);
|
||||
expect(result).toEqual([
|
||||
{ key: "API_KEY", value: "abc123" },
|
||||
{ key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" },
|
||||
{ key: "PORT", value: "3000" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should skip comment lines", () => {
|
||||
const content = `# This is a comment
|
||||
API_KEY=abc123
|
||||
# Another comment
|
||||
DATABASE_URL=postgres://localhost:5432/mydb
|
||||
# PORT=3000 (commented out)
|
||||
DEBUG=true`;
|
||||
|
||||
const result = parseEnvFile(content);
|
||||
expect(result).toEqual([
|
||||
{ key: "API_KEY", value: "abc123" },
|
||||
{ key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" },
|
||||
{ key: "DEBUG", value: "true" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle values with spaces", () => {
|
||||
const content = `MESSAGE="Hello World"
|
||||
DESCRIPTION='This is a long description'
|
||||
TITLE=My App Title`;
|
||||
|
||||
const result = parseEnvFile(content);
|
||||
expect(result).toEqual([
|
||||
{ key: "MESSAGE", value: "Hello World" },
|
||||
{ key: "DESCRIPTION", value: "This is a long description" },
|
||||
{ key: "TITLE", value: "My App Title" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle values with special characters", () => {
|
||||
const content = `PASSWORD="p@ssw0rd!#$%"
|
||||
URL="https://example.com/api?key=123&secret=456"
|
||||
REGEX="^[a-zA-Z0-9]+$"`;
|
||||
|
||||
const result = parseEnvFile(content);
|
||||
expect(result).toEqual([
|
||||
{ key: "PASSWORD", value: "p@ssw0rd!#$%" },
|
||||
{ key: "URL", value: "https://example.com/api?key=123&secret=456" },
|
||||
{ key: "REGEX", value: "^[a-zA-Z0-9]+$" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle empty values", () => {
|
||||
const content = `EMPTY_VAR=
|
||||
QUOTED_EMPTY=""
|
||||
ANOTHER_VAR=value`;
|
||||
|
||||
const result = parseEnvFile(content);
|
||||
expect(result).toEqual([
|
||||
{ key: "EMPTY_VAR", value: "" },
|
||||
{ key: "QUOTED_EMPTY", value: "" },
|
||||
{ key: "ANOTHER_VAR", value: "value" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle values with equals signs", () => {
|
||||
const content = `EQUATION="2+2=4"
|
||||
CONNECTION_STRING="server=localhost;user=admin;password=secret"`;
|
||||
|
||||
const result = parseEnvFile(content);
|
||||
expect(result).toEqual([
|
||||
{ key: "EQUATION", value: "2+2=4" },
|
||||
{
|
||||
key: "CONNECTION_STRING",
|
||||
value: "server=localhost;user=admin;password=secret",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should trim whitespace around keys and values", () => {
|
||||
const content = ` API_KEY = abc123
|
||||
DATABASE_URL = "postgres://localhost:5432/mydb"
|
||||
PORT = 3000 `;
|
||||
|
||||
const result = parseEnvFile(content);
|
||||
expect(result).toEqual([
|
||||
{ key: "API_KEY", value: "abc123" },
|
||||
{ key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" },
|
||||
{ key: "PORT", value: "3000" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should skip malformed lines without equals sign", () => {
|
||||
const content = `API_KEY=abc123
|
||||
MALFORMED_LINE
|
||||
DATABASE_URL=postgres://localhost:5432/mydb
|
||||
ANOTHER_MALFORMED
|
||||
PORT=3000`;
|
||||
|
||||
const result = parseEnvFile(content);
|
||||
expect(result).toEqual([
|
||||
{ key: "API_KEY", value: "abc123" },
|
||||
{ key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" },
|
||||
{ key: "PORT", value: "3000" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should skip lines with equals sign at the beginning", () => {
|
||||
const content = `API_KEY=abc123
|
||||
=invalid_line
|
||||
DATABASE_URL=postgres://localhost:5432/mydb`;
|
||||
|
||||
const result = parseEnvFile(content);
|
||||
expect(result).toEqual([
|
||||
{ key: "API_KEY", value: "abc123" },
|
||||
{ key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle mixed quote types in values", () => {
|
||||
const content = `MESSAGE="He said 'Hello World'"
|
||||
COMMAND='echo "Hello World"'`;
|
||||
|
||||
const result = parseEnvFile(content);
|
||||
expect(result).toEqual([
|
||||
{ key: "MESSAGE", value: "He said 'Hello World'" },
|
||||
{ key: "COMMAND", value: 'echo "Hello World"' },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle empty content", () => {
|
||||
const result = parseEnvFile("");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle content with only comments and empty lines", () => {
|
||||
const content = `# Comment 1
|
||||
|
||||
# Comment 2
|
||||
|
||||
# Comment 3`;
|
||||
|
||||
const result = parseEnvFile(content);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle values that start with hash symbol when quoted", () => {
|
||||
const content = `HASH_VALUE="#hashtag"
|
||||
COMMENT_LIKE="# This looks like a comment but it's a value"
|
||||
ACTUAL_COMMENT=value
|
||||
# This is an actual comment`;
|
||||
|
||||
const result = parseEnvFile(content);
|
||||
expect(result).toEqual([
|
||||
{ key: "HASH_VALUE", value: "#hashtag" },
|
||||
{
|
||||
key: "COMMENT_LIKE",
|
||||
value: "# This looks like a comment but it's a value",
|
||||
},
|
||||
{ key: "ACTUAL_COMMENT", value: "value" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should skip comments that look like key=value pairs", () => {
|
||||
const content = `API_KEY=abc123
|
||||
# SECRET_KEY=should_be_ignored
|
||||
DATABASE_URL=postgres://localhost:5432/mydb
|
||||
# PORT=3000
|
||||
DEBUG=true`;
|
||||
|
||||
const result = parseEnvFile(content);
|
||||
expect(result).toEqual([
|
||||
{ key: "API_KEY", value: "abc123" },
|
||||
{ key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" },
|
||||
{ key: "DEBUG", value: "true" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle values containing comment symbols", () => {
|
||||
const content = `GIT_COMMIT_MSG="feat: add new feature # closes #123"
|
||||
SQL_QUERY="SELECT * FROM users WHERE id = 1 # Get user by ID"
|
||||
MARKDOWN_HEADING="# Main Title"
|
||||
SHELL_COMMENT="echo 'hello' # prints hello"`;
|
||||
|
||||
const result = parseEnvFile(content);
|
||||
expect(result).toEqual([
|
||||
{ key: "GIT_COMMIT_MSG", value: "feat: add new feature # closes #123" },
|
||||
{
|
||||
key: "SQL_QUERY",
|
||||
value: "SELECT * FROM users WHERE id = 1 # Get user by ID",
|
||||
},
|
||||
{ key: "MARKDOWN_HEADING", value: "# Main Title" },
|
||||
{ key: "SHELL_COMMENT", value: "echo 'hello' # prints hello" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle inline comments after key=value pairs", () => {
|
||||
const content = `API_KEY=abc123 # This is the API key
|
||||
DATABASE_URL=postgres://localhost:5432/mydb # Database connection
|
||||
PORT=3000 # Server port
|
||||
DEBUG=true # Enable debug mode`;
|
||||
|
||||
const result = parseEnvFile(content);
|
||||
expect(result).toEqual([
|
||||
{ key: "API_KEY", value: "abc123 # This is the API key" },
|
||||
{
|
||||
key: "DATABASE_URL",
|
||||
value: "postgres://localhost:5432/mydb # Database connection",
|
||||
},
|
||||
{ key: "PORT", value: "3000 # Server port" },
|
||||
{ key: "DEBUG", value: "true # Enable debug mode" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle quoted values with inline comments", () => {
|
||||
const content = `MESSAGE="Hello World" # Greeting message
|
||||
PASSWORD="secret#123" # Password with hash
|
||||
URL="https://example.com#section" # URL with fragment`;
|
||||
|
||||
const result = parseEnvFile(content);
|
||||
expect(result).toEqual([
|
||||
{ key: "MESSAGE", value: "Hello World" },
|
||||
{ key: "PASSWORD", value: "secret#123" },
|
||||
{ key: "URL", value: "https://example.com#section" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle complex mixed comment scenarios", () => {
|
||||
const content = `# Configuration file
|
||||
API_KEY=abc123
|
||||
# Database settings
|
||||
DATABASE_URL="postgres://localhost:5432/mydb"
|
||||
# PORT=5432 (commented out)
|
||||
DATABASE_NAME=myapp
|
||||
|
||||
# Feature flags
|
||||
FEATURE_A=true # Enable feature A
|
||||
FEATURE_B="false" # Disable feature B
|
||||
# FEATURE_C=true (disabled)
|
||||
|
||||
# URLs with fragments
|
||||
HOMEPAGE="https://example.com#home"
|
||||
DOCS_URL=https://docs.example.com#getting-started # Documentation link`;
|
||||
|
||||
const result = parseEnvFile(content);
|
||||
expect(result).toEqual([
|
||||
{ key: "API_KEY", value: "abc123" },
|
||||
{ key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" },
|
||||
{ key: "DATABASE_NAME", value: "myapp" },
|
||||
{ key: "FEATURE_A", value: "true # Enable feature A" },
|
||||
{ key: "FEATURE_B", value: "false" },
|
||||
{ key: "HOMEPAGE", value: "https://example.com#home" },
|
||||
{
|
||||
key: "DOCS_URL",
|
||||
value: "https://docs.example.com#getting-started # Documentation link",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializeEnvFile", () => {
|
||||
it("should serialize basic key=value pairs", () => {
|
||||
const envVars = [
|
||||
{ key: "API_KEY", value: "abc123" },
|
||||
{ key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" },
|
||||
{ key: "PORT", value: "3000" },
|
||||
];
|
||||
|
||||
const result = serializeEnvFile(envVars);
|
||||
expect(result).toBe(`API_KEY=abc123
|
||||
DATABASE_URL=postgres://localhost:5432/mydb
|
||||
PORT=3000`);
|
||||
});
|
||||
|
||||
it("should quote values with spaces", () => {
|
||||
const envVars = [
|
||||
{ key: "MESSAGE", value: "Hello World" },
|
||||
{ key: "DESCRIPTION", value: "This is a long description" },
|
||||
{ key: "SIMPLE", value: "no_spaces" },
|
||||
];
|
||||
|
||||
const result = serializeEnvFile(envVars);
|
||||
expect(result).toBe(`MESSAGE="Hello World"
|
||||
DESCRIPTION="This is a long description"
|
||||
SIMPLE=no_spaces`);
|
||||
});
|
||||
|
||||
it("should quote values with special characters", () => {
|
||||
const envVars = [
|
||||
{ key: "PASSWORD", value: "p@ssw0rd!#$%" },
|
||||
{ key: "URL", value: "https://example.com/api?key=123&secret=456" },
|
||||
{ key: "SIMPLE", value: "simple123" },
|
||||
];
|
||||
|
||||
const result = serializeEnvFile(envVars);
|
||||
expect(result).toBe(`PASSWORD="p@ssw0rd!#$%"
|
||||
URL="https://example.com/api?key=123&secret=456"
|
||||
SIMPLE=simple123`);
|
||||
});
|
||||
|
||||
it("should escape quotes in values", () => {
|
||||
const envVars = [
|
||||
{ key: "MESSAGE", value: 'He said "Hello World"' },
|
||||
{ key: "COMMAND", value: 'echo "test"' },
|
||||
];
|
||||
|
||||
const result = serializeEnvFile(envVars);
|
||||
expect(result).toBe(`MESSAGE="He said \\"Hello World\\""
|
||||
COMMAND="echo \\"test\\""`);
|
||||
});
|
||||
|
||||
it("should handle empty values", () => {
|
||||
const envVars = [
|
||||
{ key: "EMPTY_VAR", value: "" },
|
||||
{ key: "ANOTHER_VAR", value: "value" },
|
||||
{ key: "ALSO_EMPTY", value: "" },
|
||||
];
|
||||
|
||||
const result = serializeEnvFile(envVars);
|
||||
expect(result).toBe(`EMPTY_VAR=
|
||||
ANOTHER_VAR=value
|
||||
ALSO_EMPTY=`);
|
||||
});
|
||||
|
||||
it("should quote values with hash symbols", () => {
|
||||
const envVars = [
|
||||
{ key: "PASSWORD", value: "secret#123" },
|
||||
{ key: "COMMENT", value: "This has # in it" },
|
||||
];
|
||||
|
||||
const result = serializeEnvFile(envVars);
|
||||
expect(result).toBe(`PASSWORD="secret#123"
|
||||
COMMENT="This has # in it"`);
|
||||
});
|
||||
|
||||
it("should quote values with single quotes", () => {
|
||||
const envVars = [
|
||||
{ key: "MESSAGE", value: "Don't worry" },
|
||||
{ key: "SQL", value: "SELECT * FROM 'users'" },
|
||||
];
|
||||
|
||||
const result = serializeEnvFile(envVars);
|
||||
expect(result).toBe(`MESSAGE="Don't worry"
|
||||
SQL="SELECT * FROM 'users'"`);
|
||||
});
|
||||
|
||||
it("should handle values with equals signs", () => {
|
||||
const envVars = [
|
||||
{ key: "EQUATION", value: "2+2=4" },
|
||||
{
|
||||
key: "CONNECTION_STRING",
|
||||
value: "server=localhost;user=admin;password=secret",
|
||||
},
|
||||
];
|
||||
|
||||
const result = serializeEnvFile(envVars);
|
||||
expect(result).toBe(`EQUATION="2+2=4"
|
||||
CONNECTION_STRING="server=localhost;user=admin;password=secret"`);
|
||||
});
|
||||
|
||||
it("should handle mixed scenarios", () => {
|
||||
const envVars = [
|
||||
{ key: "SIMPLE", value: "value" },
|
||||
{ key: "WITH_SPACES", value: "hello world" },
|
||||
{ key: "WITH_QUOTES", value: 'say "hello"' },
|
||||
{ key: "EMPTY", value: "" },
|
||||
{ key: "SPECIAL_CHARS", value: "p@ssw0rd!#$%" },
|
||||
];
|
||||
|
||||
const result = serializeEnvFile(envVars);
|
||||
expect(result).toBe(`SIMPLE=value
|
||||
WITH_SPACES="hello world"
|
||||
WITH_QUOTES="say \\"hello\\""
|
||||
EMPTY=
|
||||
SPECIAL_CHARS="p@ssw0rd!#$%"`);
|
||||
});
|
||||
|
||||
it("should handle empty array", () => {
|
||||
const result = serializeEnvFile([]);
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("should handle complex escaped quotes", () => {
|
||||
const envVars = [
|
||||
{ key: "COMPLEX", value: "This is \"complex\" with 'mixed' quotes" },
|
||||
];
|
||||
|
||||
const result = serializeEnvFile(envVars);
|
||||
expect(result).toBe(`COMPLEX="This is \\"complex\\" with 'mixed' quotes"`);
|
||||
});
|
||||
|
||||
it("should handle values that start with hash symbol", () => {
|
||||
const envVars = [
|
||||
{ key: "HASHTAG", value: "#trending" },
|
||||
{ key: "COMMENT_LIKE", value: "# This looks like a comment" },
|
||||
{ key: "MARKDOWN_HEADING", value: "# Main Title" },
|
||||
{ key: "NORMAL_VALUE", value: "no_hash_here" },
|
||||
];
|
||||
|
||||
const result = serializeEnvFile(envVars);
|
||||
expect(result).toBe(`HASHTAG="#trending"
|
||||
COMMENT_LIKE="# This looks like a comment"
|
||||
MARKDOWN_HEADING="# Main Title"
|
||||
NORMAL_VALUE=no_hash_here`);
|
||||
});
|
||||
|
||||
it("should handle values containing comment symbols", () => {
|
||||
const envVars = [
|
||||
{ key: "GIT_COMMIT", value: "feat: add feature # closes #123" },
|
||||
{ key: "SQL_QUERY", value: "SELECT * FROM users # Get all users" },
|
||||
{ key: "SHELL_CMD", value: "echo 'hello' # prints hello" },
|
||||
];
|
||||
|
||||
const result = serializeEnvFile(envVars);
|
||||
expect(result).toBe(`GIT_COMMIT="feat: add feature # closes #123"
|
||||
SQL_QUERY="SELECT * FROM users # Get all users"
|
||||
SHELL_CMD="echo 'hello' # prints hello"`);
|
||||
});
|
||||
|
||||
it("should handle URLs with fragments that contain hash symbols", () => {
|
||||
const envVars = [
|
||||
{ key: "HOMEPAGE", value: "https://example.com#home" },
|
||||
{ key: "DOCS_URL", value: "https://docs.example.com#getting-started" },
|
||||
{ key: "API_ENDPOINT", value: "https://api.example.com/v1#section" },
|
||||
];
|
||||
|
||||
const result = serializeEnvFile(envVars);
|
||||
expect(result).toBe(`HOMEPAGE="https://example.com#home"
|
||||
DOCS_URL="https://docs.example.com#getting-started"
|
||||
API_ENDPOINT="https://api.example.com/v1#section"`);
|
||||
});
|
||||
|
||||
it("should handle values with hash symbols and other special characters", () => {
|
||||
const envVars = [
|
||||
{ key: "COMPLEX_PASSWORD", value: "p@ssw0rd#123!&" },
|
||||
{ key: "REGEX_PATTERN", value: "^[a-zA-Z0-9#]+$" },
|
||||
{
|
||||
key: "MARKDOWN_CONTENT",
|
||||
value: "# Title\n\nSome content with = and & symbols",
|
||||
},
|
||||
];
|
||||
|
||||
const result = serializeEnvFile(envVars);
|
||||
expect(result).toBe(`COMPLEX_PASSWORD="p@ssw0rd#123!&"
|
||||
REGEX_PATTERN="^[a-zA-Z0-9#]+$"
|
||||
MARKDOWN_CONTENT="# Title\n\nSome content with = and & symbols"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseEnvFile and serializeEnvFile integration", () => {
|
||||
it("should be able to parse what it serializes", () => {
|
||||
const originalEnvVars = [
|
||||
{ key: "API_KEY", value: "abc123" },
|
||||
{ key: "MESSAGE", value: "Hello World" },
|
||||
{ key: "PASSWORD", value: 'secret"123' },
|
||||
{ key: "EMPTY", value: "" },
|
||||
{ key: "SPECIAL", value: "p@ssw0rd!#$%" },
|
||||
];
|
||||
|
||||
const serialized = serializeEnvFile(originalEnvVars);
|
||||
const parsed = parseEnvFile(serialized);
|
||||
|
||||
expect(parsed).toEqual(originalEnvVars);
|
||||
});
|
||||
|
||||
it("should handle round-trip with complex values", () => {
|
||||
const originalEnvVars = [
|
||||
{ key: "URL", value: "https://example.com/api?key=123&secret=456" },
|
||||
{ key: "REGEX", value: "^[a-zA-Z0-9]+$" },
|
||||
{ key: "COMMAND", value: 'echo "Hello World"' },
|
||||
{ key: "EQUATION", value: "2+2=4" },
|
||||
];
|
||||
|
||||
const serialized = serializeEnvFile(originalEnvVars);
|
||||
const parsed = parseEnvFile(serialized);
|
||||
|
||||
expect(parsed).toEqual(originalEnvVars);
|
||||
});
|
||||
|
||||
it("should handle round-trip with comment-like values", () => {
|
||||
const originalEnvVars = [
|
||||
{ key: "HASHTAG", value: "#trending" },
|
||||
{
|
||||
key: "COMMENT_LIKE",
|
||||
value: "# This looks like a comment but it's a value",
|
||||
},
|
||||
{ key: "GIT_COMMIT", value: "feat: add feature # closes #123" },
|
||||
{ key: "URL_WITH_FRAGMENT", value: "https://example.com#section" },
|
||||
{ key: "MARKDOWN_HEADING", value: "# Main Title" },
|
||||
{ key: "COMPLEX_VALUE", value: "password#123=secret&token=abc" },
|
||||
];
|
||||
|
||||
const serialized = serializeEnvFile(originalEnvVars);
|
||||
const parsed = parseEnvFile(serialized);
|
||||
|
||||
expect(parsed).toEqual(originalEnvVars);
|
||||
});
|
||||
});
|
||||
@@ -228,7 +228,7 @@ export function AICreditStatus({ userBudget }: { userBudget: UserBudgetInfo }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="text-xs mt-0.5">{remaining} credits left</div>
|
||||
<div className="text-xs mt-0.5">{remaining} credits</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div>
|
||||
|
||||
@@ -7,7 +7,9 @@ export const selectedAppIdAtom = atom<number | null>(null);
|
||||
export const appsListAtom = atom<App[]>([]);
|
||||
export const appBasePathAtom = atom<string>("");
|
||||
export const versionsListAtom = atom<Version[]>([]);
|
||||
export const previewModeAtom = atom<"preview" | "code" | "problems">("preview");
|
||||
export const previewModeAtom = atom<
|
||||
"preview" | "code" | "problems" | "configure"
|
||||
>("preview");
|
||||
export const selectedVersionIdAtom = atom<string | null>(null);
|
||||
export const appOutputAtom = atom<AppOutput[]>([]);
|
||||
export const appUrlAtom = atom<
|
||||
|
||||
401
src/components/preview_panel/ConfigurePanel.tsx
Normal file
401
src/components/preview_panel/ConfigurePanel.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
Trash2,
|
||||
Edit2,
|
||||
Plus,
|
||||
Save,
|
||||
X,
|
||||
HelpCircle,
|
||||
ArrowRight,
|
||||
} from "lucide-react";
|
||||
import { showError, showSuccess } from "@/lib/toast";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
const EnvironmentVariablesTitle = () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold">Environment Variables</span>
|
||||
<span className="text-sm text-muted-foreground font-normal">Local</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle size={16} className="text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
To modify environment variables for Supabase or production,
|
||||
<br />
|
||||
access your hosting provider's console and update them there.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ConfigurePanel = () => {
|
||||
const selectedAppId = useAtomValue(selectedAppIdAtom);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [editingKeyValue, setEditingKeyValue] = useState("");
|
||||
const [editingValue, setEditingValue] = useState("");
|
||||
const [newKey, setNewKey] = useState("");
|
||||
const [newValue, setNewValue] = useState("");
|
||||
const [isAddingNew, setIsAddingNew] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Query to get environment variables
|
||||
const {
|
||||
data: envVars = [],
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["app-env-vars", selectedAppId],
|
||||
queryFn: async () => {
|
||||
if (!selectedAppId) return [];
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
return await ipcClient.getAppEnvVars({ appId: selectedAppId });
|
||||
},
|
||||
enabled: !!selectedAppId,
|
||||
});
|
||||
|
||||
// Mutation to save environment variables
|
||||
const saveEnvVarsMutation = useMutation({
|
||||
mutationFn: async (newEnvVars: { key: string; value: string }[]) => {
|
||||
if (!selectedAppId) throw new Error("No app selected");
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
return await ipcClient.setAppEnvVars({
|
||||
appId: selectedAppId,
|
||||
envVars: newEnvVars,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["app-env-vars", selectedAppId],
|
||||
});
|
||||
showSuccess("Environment variables saved");
|
||||
},
|
||||
onError: (error) => {
|
||||
showError(`Failed to save environment variables: ${error}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAdd = useCallback(() => {
|
||||
if (!newKey.trim() || !newValue.trim()) {
|
||||
showError("Both key and value are required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicate keys
|
||||
if (envVars.some((envVar) => envVar.key === newKey.trim())) {
|
||||
showError("Environment variable with this key already exists");
|
||||
return;
|
||||
}
|
||||
|
||||
const newEnvVars = [
|
||||
...envVars,
|
||||
{ key: newKey.trim(), value: newValue.trim() },
|
||||
];
|
||||
saveEnvVarsMutation.mutate(newEnvVars);
|
||||
setNewKey("");
|
||||
setNewValue("");
|
||||
setIsAddingNew(false);
|
||||
}, [newKey, newValue, envVars, saveEnvVarsMutation]);
|
||||
|
||||
const handleEdit = useCallback((envVar: { key: string; value: string }) => {
|
||||
setEditingKey(envVar.key);
|
||||
setEditingKeyValue(envVar.key);
|
||||
setEditingValue(envVar.value);
|
||||
}, []);
|
||||
|
||||
const handleSaveEdit = useCallback(() => {
|
||||
if (!editingKeyValue.trim() || !editingValue.trim()) {
|
||||
showError("Both key and value are required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicate keys (excluding the current one being edited)
|
||||
if (
|
||||
envVars.some(
|
||||
(envVar) =>
|
||||
envVar.key === editingKeyValue.trim() && envVar.key !== editingKey,
|
||||
)
|
||||
) {
|
||||
showError("Environment variable with this key already exists");
|
||||
return;
|
||||
}
|
||||
|
||||
const newEnvVars = envVars.map((envVar) =>
|
||||
envVar.key === editingKey
|
||||
? { key: editingKeyValue.trim(), value: editingValue.trim() }
|
||||
: envVar,
|
||||
);
|
||||
saveEnvVarsMutation.mutate(newEnvVars);
|
||||
setEditingKey(null);
|
||||
setEditingKeyValue("");
|
||||
setEditingValue("");
|
||||
}, [editingKey, editingKeyValue, editingValue, envVars, saveEnvVarsMutation]);
|
||||
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
setEditingKey(null);
|
||||
setEditingKeyValue("");
|
||||
setEditingValue("");
|
||||
}, []);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(key: string) => {
|
||||
const newEnvVars = envVars.filter((envVar) => envVar.key !== key);
|
||||
saveEnvVarsMutation.mutate(newEnvVars);
|
||||
},
|
||||
[envVars, saveEnvVarsMutation],
|
||||
);
|
||||
|
||||
const handleCancelAdd = useCallback(() => {
|
||||
setIsAddingNew(false);
|
||||
setNewKey("");
|
||||
setNewValue("");
|
||||
}, []);
|
||||
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<EnvironmentVariablesTitle />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-8">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading environment variables...
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<EnvironmentVariablesTitle />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-8">
|
||||
<div className="text-sm text-red-500">
|
||||
Error loading environment variables: {error.message}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show no app selected state
|
||||
if (!selectedAppId) {
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<EnvironmentVariablesTitle />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-8">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Select an app to manage environment variables
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<EnvironmentVariablesTitle />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Add new environment variable form */}
|
||||
{isAddingNew ? (
|
||||
<div className="space-y-3 p-3 border rounded-md bg-muted/50">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-key">Key</Label>
|
||||
<Input
|
||||
id="new-key"
|
||||
placeholder="e.g., API_URL"
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-value">Value</Label>
|
||||
<Input
|
||||
id="new-value"
|
||||
placeholder="e.g., https://api.example.com"
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
size="sm"
|
||||
disabled={saveEnvVarsMutation.isPending}
|
||||
>
|
||||
<Save size={14} />
|
||||
{saveEnvVarsMutation.isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button onClick={handleCancelAdd} variant="outline" size="sm">
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => setIsAddingNew(true)}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Environment Variable
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* List of existing environment variables */}
|
||||
<div className="space-y-2">
|
||||
{envVars.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No environment variables configured
|
||||
</p>
|
||||
) : (
|
||||
envVars.map((envVar) => (
|
||||
<div
|
||||
key={envVar.key}
|
||||
className="flex items-center space-x-2 p-2 border rounded-md"
|
||||
>
|
||||
{editingKey === envVar.key ? (
|
||||
<>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Input
|
||||
value={editingKeyValue}
|
||||
onChange={(e) => setEditingKeyValue(e.target.value)}
|
||||
placeholder="Key"
|
||||
className="h-8"
|
||||
/>
|
||||
<Input
|
||||
value={editingValue}
|
||||
onChange={(e) => setEditingValue(e.target.value)}
|
||||
placeholder="Value"
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
data-testid={`save-edit-env-var`}
|
||||
onClick={handleSaveEdit}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={saveEnvVarsMutation.isPending}
|
||||
>
|
||||
<Save size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
data-testid={`cancel-edit-env-var`}
|
||||
onClick={handleCancelEdit}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">
|
||||
{envVar.key}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{envVar.value}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
data-testid={`edit-env-var-${envVar.key}`}
|
||||
onClick={() => handleEdit(envVar)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
data-testid={`delete-env-var-${envVar.key}`}
|
||||
onClick={() => handleDelete(envVar.key)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
||||
disabled={saveEnvVarsMutation.isPending}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* More app configurations button */}
|
||||
<div className="pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full text-sm justify-between"
|
||||
onClick={() => {
|
||||
if (selectedAppId) {
|
||||
navigate({
|
||||
to: "/app-details",
|
||||
search: { appId: selectedAppId },
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>More app settings</span>
|
||||
<ArrowRight size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Cog,
|
||||
Trash2,
|
||||
AlertTriangle,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
@@ -25,7 +26,7 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import { useCheckProblems } from "@/hooks/useCheckProblems";
|
||||
import { isPreviewOpenAtom } from "@/atoms/viewAtoms";
|
||||
|
||||
export type PreviewMode = "preview" | "code" | "problems";
|
||||
export type PreviewMode = "preview" | "code" | "problems" | "configure";
|
||||
|
||||
// Preview Header component with preview mode toggle
|
||||
export const PreviewHeader = () => {
|
||||
@@ -35,6 +36,7 @@ export const PreviewHeader = () => {
|
||||
const previewRef = useRef<HTMLButtonElement>(null);
|
||||
const codeRef = useRef<HTMLButtonElement>(null);
|
||||
const problemsRef = useRef<HTMLButtonElement>(null);
|
||||
const configureRef = useRef<HTMLButtonElement>(null);
|
||||
const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 });
|
||||
const { problemReport } = useCheckProblems(selectedAppId);
|
||||
const { restartApp, refreshAppIframe } = useRunApp();
|
||||
@@ -101,6 +103,9 @@ export const PreviewHeader = () => {
|
||||
case "problems":
|
||||
targetRef = problemsRef;
|
||||
break;
|
||||
case "configure":
|
||||
targetRef = configureRef;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
@@ -146,7 +151,7 @@ export const PreviewHeader = () => {
|
||||
<button
|
||||
data-testid="preview-mode-button"
|
||||
ref={previewRef}
|
||||
className="cursor-pointer relative flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium z-10"
|
||||
className="cursor-pointer relative flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium z-10 hover:bg-[var(--background)]"
|
||||
onClick={() => selectPanel("preview")}
|
||||
>
|
||||
<Eye size={14} />
|
||||
@@ -155,7 +160,7 @@ export const PreviewHeader = () => {
|
||||
<button
|
||||
data-testid="problems-mode-button"
|
||||
ref={problemsRef}
|
||||
className="cursor-pointer relative flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium z-10"
|
||||
className="cursor-pointer relative flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium z-10 hover:bg-[var(--background)]"
|
||||
onClick={() => selectPanel("problems")}
|
||||
>
|
||||
<AlertTriangle size={14} />
|
||||
@@ -166,15 +171,25 @@ export const PreviewHeader = () => {
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
data-testid="code-mode-button"
|
||||
ref={codeRef}
|
||||
className="cursor-pointer relative flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium z-10"
|
||||
className="cursor-pointer relative flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium z-10 hover:bg-[var(--background)]"
|
||||
onClick={() => selectPanel("code")}
|
||||
>
|
||||
<Code size={14} />
|
||||
<span>Code</span>
|
||||
</button>
|
||||
<button
|
||||
data-testid="configure-mode-button"
|
||||
ref={configureRef}
|
||||
className="cursor-pointer relative flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium z-10 hover:bg-[var(--background)]"
|
||||
onClick={() => selectPanel("configure")}
|
||||
>
|
||||
<Wrench size={14} />
|
||||
<span>Configure</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { CodeView } from "./CodeView";
|
||||
import { PreviewIframe } from "./PreviewIframe";
|
||||
import { Problems } from "./Problems";
|
||||
import { ConfigurePanel } from "./ConfigurePanel";
|
||||
import { ChevronDown, ChevronUp, Logs } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { PanelGroup, Panel, PanelResizeHandle } from "react-resizable-panels";
|
||||
@@ -113,6 +114,8 @@ export function PreviewPanel() {
|
||||
<PreviewIframe key={key} loading={loading} />
|
||||
) : previewMode === "code" ? (
|
||||
<CodeView loading={loading} app={app} />
|
||||
) : previewMode === "configure" ? (
|
||||
<ConfigurePanel />
|
||||
) : (
|
||||
<Problems />
|
||||
)}
|
||||
|
||||
81
src/ipc/handlers/app_env_vars_handlers.ts
Normal file
81
src/ipc/handlers/app_env_vars_handlers.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* DO NOT USE LOGGER HERE.
|
||||
* Environment variables are sensitive and should not be logged.
|
||||
*/
|
||||
import { ipcMain } from "electron";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { db } from "../../db";
|
||||
import { apps } from "../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getDyadAppPath } from "../../paths/paths";
|
||||
import { GetAppEnvVarsParams, SetAppEnvVarsParams } from "../ipc_types";
|
||||
import { parseEnvFile, serializeEnvFile } from "../utils/app_env_var_utils";
|
||||
|
||||
export function registerAppEnvVarsHandlers() {
|
||||
// Handler to get app environment variables
|
||||
ipcMain.handle(
|
||||
"get-app-env-vars",
|
||||
async (event, { appId }: GetAppEnvVarsParams) => {
|
||||
try {
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error("App not found");
|
||||
}
|
||||
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
const envFilePath = path.join(appPath, ".env.local");
|
||||
|
||||
// If .env.local doesn't exist, return empty array
|
||||
try {
|
||||
await fs.promises.access(envFilePath);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const content = await fs.promises.readFile(envFilePath, "utf8");
|
||||
const envVars = parseEnvFile(content);
|
||||
|
||||
return envVars;
|
||||
} catch (error) {
|
||||
console.error("Error getting app environment variables:", error);
|
||||
throw new Error(
|
||||
`Failed to get environment variables: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Handler to set app environment variables
|
||||
ipcMain.handle(
|
||||
"set-app-env-vars",
|
||||
async (event, { appId, envVars }: SetAppEnvVarsParams) => {
|
||||
try {
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error("App not found");
|
||||
}
|
||||
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
const envFilePath = path.join(appPath, ".env.local");
|
||||
|
||||
// Serialize environment variables to .env.local format
|
||||
const content = serializeEnvFile(envVars);
|
||||
|
||||
// Write to .env.local file
|
||||
await fs.promises.writeFile(envFilePath, content, "utf8");
|
||||
} catch (error) {
|
||||
console.error("Error setting app environment variables:", error);
|
||||
throw new Error(
|
||||
`Failed to set environment variables: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,8 @@ import type {
|
||||
AppUpgrade,
|
||||
ProblemReport,
|
||||
EditAppFileReturnType,
|
||||
GetAppEnvVarsParams,
|
||||
SetAppEnvVarsParams,
|
||||
} from "./ipc_types";
|
||||
import type { AppChatContext, ProposalResult } from "@/lib/schemas";
|
||||
import { showError } from "@/lib/toast";
|
||||
@@ -183,6 +185,16 @@ export class IpcClient {
|
||||
return this.ipcRenderer.invoke("get-app", appId);
|
||||
}
|
||||
|
||||
public async getAppEnvVars(
|
||||
params: GetAppEnvVarsParams,
|
||||
): Promise<{ key: string; value: string }[]> {
|
||||
return this.ipcRenderer.invoke("get-app-env-vars", params);
|
||||
}
|
||||
|
||||
public async setAppEnvVars(params: SetAppEnvVarsParams): Promise<void> {
|
||||
return this.ipcRenderer.invoke("set-app-env-vars", params);
|
||||
}
|
||||
|
||||
public async getChat(chatId: number): Promise<Chat> {
|
||||
try {
|
||||
const data = await this.ipcRenderer.invoke("get-chat", chatId);
|
||||
|
||||
@@ -23,6 +23,7 @@ import { registerContextPathsHandlers } from "./handlers/context_paths_handlers"
|
||||
import { registerAppUpgradeHandlers } from "./handlers/app_upgrade_handlers";
|
||||
import { registerCapacitorHandlers } from "./handlers/capacitor_handlers";
|
||||
import { registerProblemsHandlers } from "./handlers/problems_handlers";
|
||||
import { registerAppEnvVarsHandlers } from "./handlers/app_env_vars_handlers";
|
||||
|
||||
export function registerIpcHandlers() {
|
||||
// Register all IPC handlers by category
|
||||
@@ -51,4 +52,5 @@ export function registerIpcHandlers() {
|
||||
registerContextPathsHandlers();
|
||||
registerAppUpgradeHandlers();
|
||||
registerCapacitorHandlers();
|
||||
registerAppEnvVarsHandlers();
|
||||
}
|
||||
|
||||
@@ -252,3 +252,17 @@ export interface AppUpgrade {
|
||||
export interface EditAppFileReturnType {
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
export interface EnvVar {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SetAppEnvVarsParams {
|
||||
appId: number;
|
||||
envVars: EnvVar[];
|
||||
}
|
||||
|
||||
export interface GetAppEnvVarsParams {
|
||||
appId: number;
|
||||
}
|
||||
|
||||
71
src/ipc/utils/app_env_var_utils.ts
Normal file
71
src/ipc/utils/app_env_var_utils.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* DO NOT USE LOGGER HERE.
|
||||
* Environment variables are sensitive and should not be logged.
|
||||
*/
|
||||
|
||||
import { EnvVar } from "../ipc_types";
|
||||
|
||||
// Helper function to parse .env.local file content
|
||||
export function parseEnvFile(content: string): EnvVar[] {
|
||||
const envVars: EnvVar[] = [];
|
||||
const lines = content.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// Skip empty lines and comments
|
||||
if (!trimmedLine || trimmedLine.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse key=value pairs
|
||||
const equalIndex = trimmedLine.indexOf("=");
|
||||
if (equalIndex > 0) {
|
||||
const key = trimmedLine.substring(0, equalIndex).trim();
|
||||
const value = trimmedLine.substring(equalIndex + 1).trim();
|
||||
|
||||
// Handle quoted values with potential inline comments
|
||||
let cleanValue = value;
|
||||
if (value.startsWith('"')) {
|
||||
// Find the closing quote, handling escaped quotes
|
||||
let endQuoteIndex = -1;
|
||||
for (let i = 1; i < value.length; i++) {
|
||||
if (value[i] === '"' && value[i - 1] !== "\\") {
|
||||
endQuoteIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (endQuoteIndex !== -1) {
|
||||
cleanValue = value.slice(1, endQuoteIndex);
|
||||
// Unescape escaped quotes
|
||||
cleanValue = cleanValue.replace(/\\"/g, '"');
|
||||
}
|
||||
} else if (value.startsWith("'")) {
|
||||
// Find the closing quote for single quotes
|
||||
const endQuoteIndex = value.indexOf("'", 1);
|
||||
if (endQuoteIndex !== -1) {
|
||||
cleanValue = value.slice(1, endQuoteIndex);
|
||||
}
|
||||
}
|
||||
// For unquoted values, keep everything as-is (including potential # symbols)
|
||||
|
||||
envVars.push({ key, value: cleanValue });
|
||||
}
|
||||
}
|
||||
|
||||
return envVars;
|
||||
}
|
||||
|
||||
// Helper function to serialize environment variables to .env.local format
|
||||
export function serializeEnvFile(envVars: EnvVar[]): string {
|
||||
return envVars
|
||||
.map(({ key, value }) => {
|
||||
// Add quotes if value contains spaces or special characters
|
||||
const needsQuotes = /[\s#"'=&?]/.test(value);
|
||||
const quotedValue = needsQuotes
|
||||
? `"${value.replace(/"/g, '\\"')}"`
|
||||
: value;
|
||||
return `${key}=${quotedValue}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
@@ -26,6 +26,8 @@ const validInvokeChannels = [
|
||||
"get-chat-logs",
|
||||
"list-apps",
|
||||
"get-app",
|
||||
"get-app-env-vars",
|
||||
"set-app-env-vars",
|
||||
"edit-app-file",
|
||||
"read-app-file",
|
||||
"run-app",
|
||||
|
||||
Reference in New Issue
Block a user