Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@
"reflect-metadata": "^0.2.2",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"smol-toml": "^1.6.0",
"sonner": "^2.0.7",
"striptags": "^3.2.0",
"tippy.js": "^6.3.7",
Expand Down
2 changes: 2 additions & 0 deletions apps/code/src/main/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { CloudTaskService } from "../services/cloud-task/service";
import { ConnectivityService } from "../services/connectivity/service";
import { ContextMenuService } from "../services/context-menu/service";
import { DeepLinkService } from "../services/deep-link/service";
import { EnvironmentService } from "../services/environment/service";
import { ExternalAppsService } from "../services/external-apps/service";
import { FileWatcherService } from "../services/file-watcher/service";
import { FocusService } from "../services/focus/service";
Expand Down Expand Up @@ -59,6 +60,7 @@ container.bind(MAIN_TOKENS.CloudTaskService).to(CloudTaskService);
container.bind(MAIN_TOKENS.ConnectivityService).to(ConnectivityService);
container.bind(MAIN_TOKENS.ContextMenuService).to(ContextMenuService);
container.bind(MAIN_TOKENS.DeepLinkService).to(DeepLinkService);
container.bind(MAIN_TOKENS.EnvironmentService).to(EnvironmentService);

container.bind(MAIN_TOKENS.ExternalAppsService).to(ExternalAppsService);
container.bind(MAIN_TOKENS.LlmGatewayService).to(LlmGatewayService);
Expand Down
1 change: 1 addition & 0 deletions apps/code/src/main/di/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,6 @@ export const MAIN_TOKENS = Object.freeze({
UpdatesService: Symbol.for("Main.UpdatesService"),
TaskLinkService: Symbol.for("Main.TaskLinkService"),
WatcherRegistryService: Symbol.for("Main.WatcherRegistryService"),
EnvironmentService: Symbol.for("Main.EnvironmentService"),
WorkspaceService: Symbol.for("Main.WorkspaceService"),
});
55 changes: 55 additions & 0 deletions apps/code/src/main/services/environment/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { z } from "zod";

const CURRENT_SCHEMA_VERSION = 1;

const setupSchema = z.object({
script: z.string().optional(),
});

export const environmentActionSchema = z.object({
id: z.string(),
name: z.string().min(1),
icon: z.string().optional(),
command: z.string().min(1),
});

export const environmentSchema = z.object({
id: z.string(),
version: z.literal(CURRENT_SCHEMA_VERSION),
name: z.string().min(1),
setup: setupSchema.optional(),
actions: z.array(environmentActionSchema).optional(),
});

const repoPathInput = z.object({
repoPath: z.string().min(1),
});

const repoPathWithIdInput = repoPathInput.extend({
id: z.string(),
});

export const listEnvironmentsInput = repoPathInput;

export const getEnvironmentInput = repoPathWithIdInput;

export const deleteEnvironmentInput = repoPathWithIdInput;

export const createEnvironmentInput = repoPathInput.extend({
name: z.string().min(1),
setup: setupSchema.optional(),
actions: z.array(environmentActionSchema.omit({ id: true })).optional(),
});

export const updateEnvironmentInput = repoPathWithIdInput.extend({
name: z.string().min(1).optional(),
setup: setupSchema.optional(),
actions: z
.array(environmentActionSchema.extend({ id: z.string().optional() }))
.optional(),
});

export type Environment = z.infer<typeof environmentSchema>;
export type EnvironmentAction = z.infer<typeof environmentActionSchema>;
export type CreateEnvironmentInput = z.infer<typeof createEnvironmentInput>;
export type UpdateEnvironmentInput = z.infer<typeof updateEnvironmentInput>;
230 changes: 230 additions & 0 deletions apps/code/src/main/services/environment/service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it } from "vitest";
import type { Environment } from "./schemas";
import { EnvironmentService } from "./service";

describe("EnvironmentService", () => {
let service: EnvironmentService;
let repoPath: string;

const envsDir = () => path.join(repoPath, ".posthog-code", "environments");

const readEnvFiles = () => fs.readdir(envsDir()).then((f) => f.sort());

const writeRawToml = async (filename: string, content: string) => {
const dir = envsDir();
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, filename), content, "utf-8");
};

const create = (
input: Parameters<EnvironmentService["createEnvironment"]>[0],
) => service.createEnvironment(input, repoPath);

const update = (
input: Parameters<EnvironmentService["updateEnvironment"]>[0],
) => service.updateEnvironment(input, repoPath);

beforeEach(async () => {
service = new EnvironmentService();
repoPath = await fs.mkdtemp(path.join(os.tmpdir(), "env-test-"));
});

describe("listEnvironments", () => {
it("returns empty array when directory does not exist", async () => {
expect(await service.listEnvironments(repoPath)).toEqual([]);
});

it("returns parsed environments", async () => {
const env = await create({ name: "Dev" });

const result = await service.listEnvironments(repoPath);
expect(result).toHaveLength(1);
expect(result[0]).toEqual(env);
});

it("skips invalid toml files", async () => {
await writeRawToml("bad.toml", "not valid {{{");
await create({ name: "Good" });

const result = await service.listEnvironments(repoPath);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("Good");
});

it("skips valid toml that does not match schema", async () => {
await writeRawToml("wrong-schema.toml", 'title = "not an environment"');
await create({ name: "Valid" });

expect(await service.listEnvironments(repoPath)).toHaveLength(1);
});

it("ignores non-toml files", async () => {
await writeRawToml("readme.md", "# notes");
await create({ name: "Only" });

expect(await service.listEnvironments(repoPath)).toHaveLength(1);
});
});

describe("createEnvironment", () => {
it("creates a toml file with slugified name", async () => {
const env = await create({ name: "My Dev Environment" });

expect(env).toMatchObject({ version: 1, name: "My Dev Environment" });
expect(env.id).toBeTruthy();
expect(await readEnvFiles()).toContain("my-dev-environment.toml");
});

it("handles filename collisions", async () => {
await create({ name: "test" });
await create({ name: "test" });

expect(await readEnvFiles()).toEqual(["test-2.toml", "test.toml"]);
});

it("falls back to 'environment' slug for names with no alphanumeric chars", async () => {
await create({ name: "---" });
expect(await readEnvFiles()).toEqual(["environment.toml"]);
});

it("generates unique ids for actions", async () => {
const env = await create({
name: "Actions",
actions: [
{ name: "Build", command: "npm run build" },
{ name: "Test", command: "npm test" },
],
});

expect(env.actions).toHaveLength(2);
const [a, b] = env.actions!;
expect(a.id).toBeTruthy();
expect(b.id).toBeTruthy();
expect(a.id).not.toBe(b.id);
});

it("round-trips setup script through toml", async () => {
const env = await create({
name: "Setup",
setup: { script: "npm install\nnpm run build" },
});

const found = await service.getEnvironment(repoPath, env.id);
expect(found?.setup?.script).toBe("npm install\nnpm run build");
});

it("round-trips action icon through toml", async () => {
const env = await create({
name: "Icons",
actions: [{ name: "Run", command: "go run .", icon: "play" }],
});

const found = await service.getEnvironment(repoPath, env.id);
expect(found?.actions?.[0].icon).toBe("play");
});
});

describe("getEnvironment", () => {
it("returns null for nonexistent id", async () => {
expect(await service.getEnvironment(repoPath, "nonexistent")).toBeNull();
});

it("finds environment by id", async () => {
const created = await create({ name: "Find Me" });
expect(await service.getEnvironment(repoPath, created.id)).toEqual(
created,
);
});
});

describe("updateEnvironment", () => {
let env: Environment;

beforeEach(async () => {
env = await create({
name: "Original",
actions: [{ name: "Build", command: "make" }],
});
});

it("updates name while preserving id and version", async () => {
const updated = await update({ id: env.id, name: "Renamed" });

expect(updated.id).toBe(env.id);
expect(updated.version).toBe(1);
expect(updated.name).toBe("Renamed");
});

it("keeps filename stable on rename", async () => {
await update({ id: env.id, name: "Renamed" });
expect(await readEnvFiles()).toEqual(["original.toml"]);
});

it("preserves fields not included in the update", async () => {
const updated = await update({ id: env.id, name: "New Name" });
expect(updated.actions).toEqual(env.actions);
});

it("generates ids for new actions without an id", async () => {
const updated = await update({
id: env.id,
actions: [{ name: "Run", command: "npm start" }],
});

expect(updated.actions?.[0].id).toBeTruthy();
});

it("preserves existing action ids", async () => {
const actionId = env.actions?.[0].id;
const updated = await update({
id: env.id,
actions: [{ id: actionId, name: "Build v2", command: "make all" }],
});

expect(updated.actions?.[0].id).toBe(actionId);
expect(updated.actions?.[0].command).toBe("make all");
});

it("persists update to disk", async () => {
await update({ id: env.id, name: "Persisted" });

const found = await service.getEnvironment(repoPath, env.id);
expect(found?.name).toBe("Persisted");
});

it("throws for nonexistent id", async () => {
await expect(update({ id: "nope", name: "X" })).rejects.toThrow(
"Environment not found: nope",
);
});
});

describe("deleteEnvironment", () => {
it("removes the toml file", async () => {
const created = await create({ name: "Doomed" });
await service.deleteEnvironment(repoPath, created.id);

expect(await readEnvFiles()).toEqual([]);
});

it("does not affect other environments", async () => {
const keep = await create({ name: "keep" });
const remove = await create({ name: "remove" });

await service.deleteEnvironment(repoPath, remove.id);

const remaining = await service.listEnvironments(repoPath);
expect(remaining).toHaveLength(1);
expect(remaining[0].id).toBe(keep.id);
});

it("throws for nonexistent id", async () => {
await expect(service.deleteEnvironment(repoPath, "nope")).rejects.toThrow(
"Environment not found: nope",
);
});
});
});
Loading
Loading