Skip to content

Commit 0cb5fe1

Browse files
authored
feat: environments service (#1291)
responsible for managing the toml files
1 parent 05d3caa commit 0cb5fe1

9 files changed

Lines changed: 511 additions & 1 deletion

File tree

apps/code/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@
175175
"reflect-metadata": "^0.2.2",
176176
"remark-breaks": "^4.0.0",
177177
"remark-gfm": "^4.0.1",
178+
"smol-toml": "^1.6.0",
178179
"sonner": "^2.0.7",
179180
"striptags": "^3.2.0",
180181
"tippy.js": "^6.3.7",

apps/code/src/main/di/container.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { CloudTaskService } from "../services/cloud-task/service";
1414
import { ConnectivityService } from "../services/connectivity/service";
1515
import { ContextMenuService } from "../services/context-menu/service";
1616
import { DeepLinkService } from "../services/deep-link/service";
17+
import { EnvironmentService } from "../services/environment/service";
1718
import { ExternalAppsService } from "../services/external-apps/service";
1819
import { FileWatcherService } from "../services/file-watcher/service";
1920
import { FocusService } from "../services/focus/service";
@@ -59,6 +60,7 @@ container.bind(MAIN_TOKENS.CloudTaskService).to(CloudTaskService);
5960
container.bind(MAIN_TOKENS.ConnectivityService).to(ConnectivityService);
6061
container.bind(MAIN_TOKENS.ContextMenuService).to(ContextMenuService);
6162
container.bind(MAIN_TOKENS.DeepLinkService).to(DeepLinkService);
63+
container.bind(MAIN_TOKENS.EnvironmentService).to(EnvironmentService);
6264

6365
container.bind(MAIN_TOKENS.ExternalAppsService).to(ExternalAppsService);
6466
container.bind(MAIN_TOKENS.LlmGatewayService).to(LlmGatewayService);

apps/code/src/main/di/tokens.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,5 +48,6 @@ export const MAIN_TOKENS = Object.freeze({
4848
UpdatesService: Symbol.for("Main.UpdatesService"),
4949
TaskLinkService: Symbol.for("Main.TaskLinkService"),
5050
WatcherRegistryService: Symbol.for("Main.WatcherRegistryService"),
51+
EnvironmentService: Symbol.for("Main.EnvironmentService"),
5152
WorkspaceService: Symbol.for("Main.WorkspaceService"),
5253
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { z } from "zod";
2+
3+
const CURRENT_SCHEMA_VERSION = 1;
4+
5+
const setupSchema = z.object({
6+
script: z.string().optional(),
7+
});
8+
9+
export const environmentActionSchema = z.object({
10+
id: z.string(),
11+
name: z.string().min(1),
12+
icon: z.string().optional(),
13+
command: z.string().min(1),
14+
});
15+
16+
export const environmentSchema = z.object({
17+
id: z.string(),
18+
version: z.literal(CURRENT_SCHEMA_VERSION),
19+
name: z.string().min(1),
20+
setup: setupSchema.optional(),
21+
actions: z.array(environmentActionSchema).optional(),
22+
});
23+
24+
const repoPathInput = z.object({
25+
repoPath: z.string().min(1),
26+
});
27+
28+
const repoPathWithIdInput = repoPathInput.extend({
29+
id: z.string(),
30+
});
31+
32+
export const listEnvironmentsInput = repoPathInput;
33+
34+
export const getEnvironmentInput = repoPathWithIdInput;
35+
36+
export const deleteEnvironmentInput = repoPathWithIdInput;
37+
38+
export const createEnvironmentInput = repoPathInput.extend({
39+
name: z.string().min(1),
40+
setup: setupSchema.optional(),
41+
actions: z.array(environmentActionSchema.omit({ id: true })).optional(),
42+
});
43+
44+
export const updateEnvironmentInput = repoPathWithIdInput.extend({
45+
name: z.string().min(1).optional(),
46+
setup: setupSchema.optional(),
47+
actions: z
48+
.array(environmentActionSchema.extend({ id: z.string().optional() }))
49+
.optional(),
50+
});
51+
52+
export type Environment = z.infer<typeof environmentSchema>;
53+
export type EnvironmentAction = z.infer<typeof environmentActionSchema>;
54+
export type CreateEnvironmentInput = z.infer<typeof createEnvironmentInput>;
55+
export type UpdateEnvironmentInput = z.infer<typeof updateEnvironmentInput>;
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { beforeEach, describe, expect, it } from "vitest";
5+
import type { Environment } from "./schemas";
6+
import { EnvironmentService } from "./service";
7+
8+
describe("EnvironmentService", () => {
9+
let service: EnvironmentService;
10+
let repoPath: string;
11+
12+
const envsDir = () => path.join(repoPath, ".posthog-code", "environments");
13+
14+
const readEnvFiles = () => fs.readdir(envsDir()).then((f) => f.sort());
15+
16+
const writeRawToml = async (filename: string, content: string) => {
17+
const dir = envsDir();
18+
await fs.mkdir(dir, { recursive: true });
19+
await fs.writeFile(path.join(dir, filename), content, "utf-8");
20+
};
21+
22+
const create = (
23+
input: Parameters<EnvironmentService["createEnvironment"]>[0],
24+
) => service.createEnvironment(input, repoPath);
25+
26+
const update = (
27+
input: Parameters<EnvironmentService["updateEnvironment"]>[0],
28+
) => service.updateEnvironment(input, repoPath);
29+
30+
beforeEach(async () => {
31+
service = new EnvironmentService();
32+
repoPath = await fs.mkdtemp(path.join(os.tmpdir(), "env-test-"));
33+
});
34+
35+
describe("listEnvironments", () => {
36+
it("returns empty array when directory does not exist", async () => {
37+
expect(await service.listEnvironments(repoPath)).toEqual([]);
38+
});
39+
40+
it("returns parsed environments", async () => {
41+
const env = await create({ name: "Dev" });
42+
43+
const result = await service.listEnvironments(repoPath);
44+
expect(result).toHaveLength(1);
45+
expect(result[0]).toEqual(env);
46+
});
47+
48+
it("skips invalid toml files", async () => {
49+
await writeRawToml("bad.toml", "not valid {{{");
50+
await create({ name: "Good" });
51+
52+
const result = await service.listEnvironments(repoPath);
53+
expect(result).toHaveLength(1);
54+
expect(result[0].name).toBe("Good");
55+
});
56+
57+
it("skips valid toml that does not match schema", async () => {
58+
await writeRawToml("wrong-schema.toml", 'title = "not an environment"');
59+
await create({ name: "Valid" });
60+
61+
expect(await service.listEnvironments(repoPath)).toHaveLength(1);
62+
});
63+
64+
it("ignores non-toml files", async () => {
65+
await writeRawToml("readme.md", "# notes");
66+
await create({ name: "Only" });
67+
68+
expect(await service.listEnvironments(repoPath)).toHaveLength(1);
69+
});
70+
});
71+
72+
describe("createEnvironment", () => {
73+
it("creates a toml file with slugified name", async () => {
74+
const env = await create({ name: "My Dev Environment" });
75+
76+
expect(env).toMatchObject({ version: 1, name: "My Dev Environment" });
77+
expect(env.id).toBeTruthy();
78+
expect(await readEnvFiles()).toContain("my-dev-environment.toml");
79+
});
80+
81+
it("handles filename collisions", async () => {
82+
await create({ name: "test" });
83+
await create({ name: "test" });
84+
85+
expect(await readEnvFiles()).toEqual(["test-2.toml", "test.toml"]);
86+
});
87+
88+
it("falls back to 'environment' slug for names with no alphanumeric chars", async () => {
89+
await create({ name: "---" });
90+
expect(await readEnvFiles()).toEqual(["environment.toml"]);
91+
});
92+
93+
it("generates unique ids for actions", async () => {
94+
const env = await create({
95+
name: "Actions",
96+
actions: [
97+
{ name: "Build", command: "npm run build" },
98+
{ name: "Test", command: "npm test" },
99+
],
100+
});
101+
102+
expect(env.actions).toHaveLength(2);
103+
const [a, b] = env.actions!;
104+
expect(a.id).toBeTruthy();
105+
expect(b.id).toBeTruthy();
106+
expect(a.id).not.toBe(b.id);
107+
});
108+
109+
it("round-trips setup script through toml", async () => {
110+
const env = await create({
111+
name: "Setup",
112+
setup: { script: "npm install\nnpm run build" },
113+
});
114+
115+
const found = await service.getEnvironment(repoPath, env.id);
116+
expect(found?.setup?.script).toBe("npm install\nnpm run build");
117+
});
118+
119+
it("round-trips action icon through toml", async () => {
120+
const env = await create({
121+
name: "Icons",
122+
actions: [{ name: "Run", command: "go run .", icon: "play" }],
123+
});
124+
125+
const found = await service.getEnvironment(repoPath, env.id);
126+
expect(found?.actions?.[0].icon).toBe("play");
127+
});
128+
});
129+
130+
describe("getEnvironment", () => {
131+
it("returns null for nonexistent id", async () => {
132+
expect(await service.getEnvironment(repoPath, "nonexistent")).toBeNull();
133+
});
134+
135+
it("finds environment by id", async () => {
136+
const created = await create({ name: "Find Me" });
137+
expect(await service.getEnvironment(repoPath, created.id)).toEqual(
138+
created,
139+
);
140+
});
141+
});
142+
143+
describe("updateEnvironment", () => {
144+
let env: Environment;
145+
146+
beforeEach(async () => {
147+
env = await create({
148+
name: "Original",
149+
actions: [{ name: "Build", command: "make" }],
150+
});
151+
});
152+
153+
it("updates name while preserving id and version", async () => {
154+
const updated = await update({ id: env.id, name: "Renamed" });
155+
156+
expect(updated.id).toBe(env.id);
157+
expect(updated.version).toBe(1);
158+
expect(updated.name).toBe("Renamed");
159+
});
160+
161+
it("keeps filename stable on rename", async () => {
162+
await update({ id: env.id, name: "Renamed" });
163+
expect(await readEnvFiles()).toEqual(["original.toml"]);
164+
});
165+
166+
it("preserves fields not included in the update", async () => {
167+
const updated = await update({ id: env.id, name: "New Name" });
168+
expect(updated.actions).toEqual(env.actions);
169+
});
170+
171+
it("generates ids for new actions without an id", async () => {
172+
const updated = await update({
173+
id: env.id,
174+
actions: [{ name: "Run", command: "npm start" }],
175+
});
176+
177+
expect(updated.actions?.[0].id).toBeTruthy();
178+
});
179+
180+
it("preserves existing action ids", async () => {
181+
const actionId = env.actions?.[0].id;
182+
const updated = await update({
183+
id: env.id,
184+
actions: [{ id: actionId, name: "Build v2", command: "make all" }],
185+
});
186+
187+
expect(updated.actions?.[0].id).toBe(actionId);
188+
expect(updated.actions?.[0].command).toBe("make all");
189+
});
190+
191+
it("persists update to disk", async () => {
192+
await update({ id: env.id, name: "Persisted" });
193+
194+
const found = await service.getEnvironment(repoPath, env.id);
195+
expect(found?.name).toBe("Persisted");
196+
});
197+
198+
it("throws for nonexistent id", async () => {
199+
await expect(update({ id: "nope", name: "X" })).rejects.toThrow(
200+
"Environment not found: nope",
201+
);
202+
});
203+
});
204+
205+
describe("deleteEnvironment", () => {
206+
it("removes the toml file", async () => {
207+
const created = await create({ name: "Doomed" });
208+
await service.deleteEnvironment(repoPath, created.id);
209+
210+
expect(await readEnvFiles()).toEqual([]);
211+
});
212+
213+
it("does not affect other environments", async () => {
214+
const keep = await create({ name: "keep" });
215+
const remove = await create({ name: "remove" });
216+
217+
await service.deleteEnvironment(repoPath, remove.id);
218+
219+
const remaining = await service.listEnvironments(repoPath);
220+
expect(remaining).toHaveLength(1);
221+
expect(remaining[0].id).toBe(keep.id);
222+
});
223+
224+
it("throws for nonexistent id", async () => {
225+
await expect(service.deleteEnvironment(repoPath, "nope")).rejects.toThrow(
226+
"Environment not found: nope",
227+
);
228+
});
229+
});
230+
});

0 commit comments

Comments
 (0)