1// @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises the filesystem boundary.
2import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import * as ConfigProvider from "effect/ConfigProvider";
8import * as NetService from "@t3tools/shared/Net";
9import { assert, describe, it } from "@effect/vitest";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as TestConsole from "effect/testing/TestConsole";
13import { Command } from "effect/cli";
14
15import { cli } from "../binCli.ts";
16import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
17import * as HostProcess from "@t3tools/shared/HostProcess";
18
19// These force a failure with chmod, which Windows ignores for directories and
20// cannot use to make a file unreadable, so the failure never happens there.
21const windowsHost = HostProcess.Platform.defaultValue() === "win32";
22
23const runCli = (args: ReadonlyArray<string>) =>
24 Command.runWith(cli, { version: "0.0.0" })(args).pipe(
25 Effect.provide(Layer.mergeAll(NodeServices.layer, NetService.layer, TestConsole.layer)),
26 );
27
28const makeBaseDir = () => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-theme-cli-"));
29
30const settingsPathFor = (baseDir: string) => NodePath.join(baseDir, "userdata", "settings.json");
31
32const NIGHTFALL_THEME_JSON = `${JSON.stringify({
33 name: "Nightfall",
34 appearance: "dark",
35 canvas: "#1a1b26",
36 accent: "#7aa2f7",
37})}\n`;
38const JUNK_THEME_JSON = `${JSON.stringify({ name: "Junk" })}\n`;
39
40const readSettings = (baseDir: string): Record<string, unknown> => {
41 const raw = NodeFS.readFileSync(settingsPathFor(baseDir), "utf8");
42 return JSON.parse(raw) as Record<string, unknown>;
43};
44
45const writeSettings = (baseDir: string, settings: Record<string, unknown>) => {
46 NodeFS.mkdirSync(NodePath.dirname(settingsPathFor(baseDir)), { recursive: true });
47 NodeFS.writeFileSync(settingsPathFor(baseDir), `${JSON.stringify(settings, null, 2)}\n`);
48};
49
50describe("t3 theme", () => {
51 it.effect("writes a default theme when no settings file exists yet", () =>
52 Effect.gen(function* () {
53 const baseDir = makeBaseDir();
54 yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]);
55 assert.equal(readSettings(baseDir).defaultTheme, "ocean");
56 }),
57 );
58
59 // A provisioning command runs against settings written by whatever version
60 // happens to be installed, so it must not drop what it cannot interpret.
61 it.effect("preserves settings it does not recognise", () =>
62 Effect.gen(function* () {
63 const baseDir = makeBaseDir();
64 writeSettings(baseDir, {
65 enableProviderUpdateChecks: false,
66 somethingFromANewerBuild: { nested: true },
67 });
68
69 yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]);
70
71 const settings = readSettings(baseDir);
72 assert.equal(settings.defaultTheme, "ocean");
73 assert.equal(settings.enableProviderUpdateChecks, false);
74 assert.deepEqual(settings.somethingFromANewerBuild, { nested: true });
75 }),
76 );
77
78 it.effect("clears the default back to leaving fresh clients alone", () =>
79 Effect.gen(function* () {
80 const baseDir = makeBaseDir();
81 writeSettings(baseDir, { enableProviderUpdateChecks: false });
82
83 yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]);
84 yield* runCli(["theme", "clear", "--base-dir", baseDir]);
85
86 const settings = readSettings(baseDir);
87 assert.equal(Object.hasOwn(settings, "defaultTheme"), false);
88 assert.equal(settings.enableProviderUpdateChecks, false);
89 }),
90 );
91
92 // Publishing a file and pointing at it are one step, so an integration
93 // (a desktop's theme hook) needs no knowledge of the themes directory.
94 it.effect("publishes a theme file under its filename and sets it", () =>
95 Effect.gen(function* () {
96 const baseDir = makeBaseDir();
97 const themeFile = NodePath.join(baseDir, "nightfall.json");
98 NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON);
99
100 yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]);
101
102 const published = NodePath.join(baseDir, "userdata", "themes", "nightfall.json");
103 assert.equal(NodeFS.existsSync(published), true);
104 assert.equal(readSettings(baseDir).defaultTheme, "nightfall");
105 // No rollback or staging residue after a successful set.
106 const residue = NodeFS.readdirSync(NodePath.dirname(published)).filter(
107 (entry) => !entry.endsWith(".json"),
108 );
109 assert.deepEqual(residue, []);
110 }),
111 );
112
113 it.effect("publishes a theme file under an explicit id", () =>
114 Effect.gen(function* () {
115 const baseDir = makeBaseDir();
116 const themeFile = NodePath.join(baseDir, "t3code.json");
117 NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON);
118
119 yield* runCli(["theme", "set", "--id", "nightfall", themeFile, "--base-dir", baseDir]);
120
121 assert.equal(
122 NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "nightfall.json")),
123 true,
124 );
125 assert.equal(readSettings(baseDir).defaultTheme, "nightfall");
126 }),
127 );
128
129 it.effect("rejects a file that is not a theme and sets nothing", () =>
130 Effect.gen(function* () {
131 const baseDir = makeBaseDir();
132 const themeFile = NodePath.join(baseDir, "junk.json");
133 NodeFS.writeFileSync(themeFile, JUNK_THEME_JSON);
134
135 const failure = yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe(
136 Effect.flip,
137 );
138
139 assert.include(String(failure), "not a valid theme file");
140 assert.equal(NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes")), false);
141 assert.equal(NodeFS.existsSync(settingsPathFor(baseDir)), false);
142 }),
143 );
144
145 // Publish and set are one command, so a settings file the set step cannot
146 // use must fail it before the themes directory is mutated -- not after,
147 // with a half-applied publish left behind.
148 it.effect("publishes nothing when the settings file cannot be used", () =>
149 Effect.gen(function* () {
150 const baseDir = makeBaseDir();
151 NodeFS.mkdirSync(NodePath.dirname(settingsPathFor(baseDir)), { recursive: true });
152 NodeFS.writeFileSync(settingsPathFor(baseDir), "{ not json");
153 const themeFile = NodePath.join(baseDir, "nightfall.json");
154 NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON);
155
156 const failure = yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe(
157 Effect.flip,
158 );
159
160 assert.include(String(failure), "not a JSON object");
161 assert.equal(NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes")), false);
162 }),
163 );
164
165 // set means set: a publish that rode along with a failed default write is
166 // rolled back rather than left mutating the environment's theme set. The
167 // userdata directory is made read-only while themes stays writable, so the
168 // failure lands after the publish -- the case the rollback exists for.
169 it.effect.skipIf(windowsHost)("rolls back a publish when the default cannot be written", () =>
170 Effect.gen(function* () {
171 const baseDir = makeBaseDir();
172 writeSettings(baseDir, {});
173 const userdataDir = NodePath.dirname(settingsPathFor(baseDir));
174 const themesDir = NodePath.join(userdataDir, "themes");
175 NodeFS.mkdirSync(themesDir, { recursive: true });
176 const themeFile = NodePath.join(baseDir, "nightfall.json");
177 NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON);
178
179 NodeFS.chmodSync(userdataDir, 0o555);
180 try {
181 const failure = yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe(
182 Effect.flip,
183 );
184 assert.include(String(failure), "Could not write");
185 assert.equal(NodeFS.existsSync(NodePath.join(themesDir, "nightfall.json")), false);
186 } finally {
187 NodeFS.chmodSync(userdataDir, 0o755);
188 }
189 }),
190 );
191
192 // A symlink is a normal way to hand this command a theme -- desktop hooks
193 // symlink the current palette -- so the source is resolved, not refused.
194 it.effect.skipIf(!symlinksSupported)(
195 "publishes a theme file through a symlinked source path",
196 () =>
197 Effect.gen(function* () {
198 const baseDir = makeBaseDir();
199 const realFile = NodePath.join(baseDir, "real-nightfall.json");
200 NodeFS.writeFileSync(realFile, NIGHTFALL_THEME_JSON);
201 const linkPath = NodePath.join(baseDir, "nightfall.json");
202 NodeFS.symlinkSync(realFile, linkPath);
203
204 yield* runCli(["theme", "set", linkPath, "--base-dir", baseDir]);
205
206 assert.equal(
207 NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "nightfall.json")),
208 true,
209 );
210 assert.equal(readSettings(baseDir).defaultTheme, "nightfall");
211 }),
212 );
213
214 // The staging entry is created fresh with O_EXCL, so a symlink planted at
215 // its predictable name is cleared, never followed and written through.
216 it.effect.skipIf(!symlinksSupported)("never writes through a symlink at the staging path", () =>
217 Effect.gen(function* () {
218 const baseDir = makeBaseDir();
219 const themesDir = NodePath.join(baseDir, "userdata", "themes");
220 NodeFS.mkdirSync(themesDir, { recursive: true });
221 const victim = NodePath.join(baseDir, "victim.txt");
222 NodeFS.writeFileSync(victim, "precious");
223 NodeFS.symlinkSync(victim, NodePath.join(themesDir, `nightfall.json.staging-${process.pid}`));
224 const themeFile = NodePath.join(baseDir, "nightfall.json");
225 NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON);
226
227 yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]);
228
229 assert.equal(NodeFS.readFileSync(victim, "utf8"), "precious");
230 assert.equal(readSettings(baseDir).defaultTheme, "nightfall");
231 }),
232 );
233
234 // Rollback moves the previous directory entry aside and back, so even an
235 // entry the watcher would never publish -- here a symlink -- comes back
236 // exactly as it was when the set fails.
237 it.effect.skipIf(!symlinksSupported || windowsHost)(
238 "restores a non-theme destination entry when the set fails",
239 () =>
240 Effect.gen(function* () {
241 const baseDir = makeBaseDir();
242 writeSettings(baseDir, {});
243 const userdataDir = NodePath.dirname(settingsPathFor(baseDir));
244 const themesDir = NodePath.join(userdataDir, "themes");
245 NodeFS.mkdirSync(themesDir, { recursive: true });
246 const outside = NodePath.join(baseDir, "outside.json");
247 NodeFS.writeFileSync(outside, NIGHTFALL_THEME_JSON);
248 const destination = NodePath.join(themesDir, "nightfall.json");
249 NodeFS.symlinkSync(outside, destination);
250 const themeFile = NodePath.join(baseDir, "nightfall.json");
251 NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON);
252
253 NodeFS.chmodSync(userdataDir, 0o555);
254 try {
255 yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe(Effect.flip);
256 assert.equal(NodeFS.lstatSync(destination).isSymbolicLink(), true);
257 } finally {
258 NodeFS.chmodSync(userdataDir, 0o755);
259 }
260 }),
261 );
262
263 it.effect.skipIf(windowsHost)("restores the previous theme when a re-publish fails to set", () =>
264 Effect.gen(function* () {
265 const baseDir = makeBaseDir();
266 writeSettings(baseDir, {});
267 const userdataDir = NodePath.dirname(settingsPathFor(baseDir));
268 const themesDir = NodePath.join(userdataDir, "themes");
269 NodeFS.mkdirSync(themesDir, { recursive: true });
270 const publishedPath = NodePath.join(themesDir, "nightfall.json");
271 const previous =
272 '{ "name": "Old Nightfall", "appearance": "dark", "canvas": "#000000", "accent": "#ffffff" }\n';
273 NodeFS.writeFileSync(publishedPath, previous);
274 const themeFile = NodePath.join(baseDir, "nightfall.json");
275 NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON);
276
277 NodeFS.chmodSync(userdataDir, 0o555);
278 try {
279 yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe(Effect.flip);
280 assert.equal(NodeFS.readFileSync(publishedPath, "utf8"), previous);
281 } finally {
282 NodeFS.chmodSync(userdataDir, 0o755);
283 }
284 }),
285 );
286
287 // A typo'd id written as the theme would silently never resolve anywhere;
288 // the id branch is as strict as the filename rule.
289 it.effect("rejects an id no client could resolve", () =>
290 Effect.gen(function* () {
291 const baseDir = makeBaseDir();
292 const failure = yield* runCli(["theme", "set", "Nightfall", "--base-dir", baseDir]).pipe(
293 Effect.flip,
294 );
295 assert.include(String(failure), "not a valid theme id");
296 assert.equal(NodeFS.existsSync(settingsPathFor(baseDir)), false);
297 }),
298 );
299
300 it.effect("rejects a path that does not exist instead of storing it as an id", () =>
301 Effect.gen(function* () {
302 const baseDir = makeBaseDir();
303 const failure = yield* runCli([
304 "theme",
305 "set",
306 `${baseDir}/missing.json`,
307 "--base-dir",
308 baseDir,
309 ]).pipe(Effect.flip);
310 assert.include(String(failure), "Could not read");
311 }),
312 );
313
314 // File-ness is decided by existence, not extension, so a generated file
315 // named for its target app still publishes.
316 it.effect("publishes an extensionless file", () =>
317 Effect.gen(function* () {
318 const baseDir = makeBaseDir();
319 const themeFile = NodePath.join(baseDir, "brand");
320 NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON);
321
322 yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]);
323
324 assert.equal(
325 NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "brand.json")),
326 true,
327 );
328 assert.equal(readSettings(baseDir).defaultTheme, "brand");
329 }),
330 );
331
332 it.effect("records a set generation and clears it with the theme", () =>
333 Effect.gen(function* () {
334 const baseDir = makeBaseDir();
335 yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]);
336 const setAt = readSettings(baseDir).defaultThemeSetAt;
337 assert.equal(typeof setAt, "string");
338
339 yield* runCli(["theme", "clear", "--base-dir", baseDir]);
340 const cleared = readSettings(baseDir);
341 assert.equal(Object.hasOwn(cleared, "defaultTheme"), false);
342 assert.equal(Object.hasOwn(cleared, "defaultThemeSetAt"), false);
343 }),
344 );
345
346 it.effect("honors T3CODE_HOME like the rest of the CLI", () =>
347 Effect.gen(function* () {
348 const baseDir = makeBaseDir();
349 yield* runCli(["theme", "set", "ocean"]).pipe(
350 Effect.provide(
351 ConfigProvider.layer(ConfigProvider.fromEnv({ env: { T3CODE_HOME: baseDir } })),
352 ),
353 );
354 assert.equal(readSettings(baseDir).defaultTheme, "ocean");
355 }),
356 );
357
358 // An unreadable settings file must never read as "no settings": writing a
359 // fresh sparse file over it would discard every key the user had.
360 it.effect.skipIf(windowsHost)("refuses to write when the settings file cannot be read", () =>
361 Effect.gen(function* () {
362 const baseDir = makeBaseDir();
363 writeSettings(baseDir, { enableProviderUpdateChecks: false });
364 NodeFS.chmodSync(settingsPathFor(baseDir), 0o000);
365
366 const failure = yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]).pipe(
367 Effect.flip,
368 );
369
370 NodeFS.chmodSync(settingsPathFor(baseDir), 0o644);
371 assert.include(String(failure), "Could not read");
372 assert.equal(readSettings(baseDir).enableProviderUpdateChecks, false);
373 assert.equal(Object.hasOwn(readSettings(baseDir), "defaultTheme"), false);
374 }),
375 );
376
377 // A typo is syntactically a valid id, so shape validation alone would write
378 // a theme no client can resolve and report success.
379 it.effect("rejects an id that names no theme", () =>
380 Effect.gen(function* () {
381 const baseDir = makeBaseDir();
382 const failure = yield* runCli(["theme", "set", "ocian", "--base-dir", baseDir]).pipe(
383 Effect.flip,
384 );
385 assert.include(String(failure), "No theme named");
386 assert.equal(NodeFS.existsSync(settingsPathFor(baseDir)), false);
387 }),
388 );
389
390 it.effect("accepts an id a published file provides", () =>
391 Effect.gen(function* () {
392 const baseDir = makeBaseDir();
393 const themeFile = NodePath.join(baseDir, "nightfall.json");
394 NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON);
395 yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]);
396
397 // Now resolvable by bare id, because the file published it.
398 yield* runCli(["theme", "clear", "--base-dir", baseDir]);
399 yield* runCli(["theme", "set", "nightfall", "--base-dir", baseDir]);
400 assert.equal(readSettings(baseDir).defaultTheme, "nightfall");
401 }),
402 );
403
404 // The watcher skips files it cannot use, so accepting their filename would
405 // set a theme no client ever receives.
406 it.effect("rejects an id whose published file the watcher would skip", () =>
407 Effect.gen(function* () {
408 const baseDir = makeBaseDir();
409 const themesDir = NodePath.join(baseDir, "userdata", "themes");
410 NodeFS.mkdirSync(themesDir, { recursive: true });
411 NodeFS.writeFileSync(NodePath.join(themesDir, "broken.json"), "{ not json\n");
412
413 const failure = yield* runCli(["theme", "set", "broken", "--base-dir", baseDir]).pipe(
414 Effect.flip,
415 );
416 assert.include(String(failure), "No theme named");
417 }),
418 );
419
420 // Web and desktop cannot resolve the mobile default, and mobile does not
421 // follow this setting, so naming it would be a silent no-op.
422 it.effect("rejects the mobile default theme id", () =>
423 Effect.gen(function* () {
424 const baseDir = makeBaseDir();
425 const failure = yield* runCli(["theme", "set", "t3-code", "--base-dir", baseDir]).pipe(
426 Effect.flip,
427 );
428 assert.include(String(failure), "No theme named");
429 }),
430 );
431
432 // Deciding on existence alone would publish ./ocean instead of selecting the
433 // built-in, purely because of what happens to be in the working directory.
434 it.effect("treats a bare id as an id even when a file shares its name", () =>
435 Effect.gen(function* () {
436 const baseDir = makeBaseDir();
437 const cwdFile = NodePath.join(baseDir, "ocean");
438 NodeFS.writeFileSync(cwdFile, NIGHTFALL_THEME_JSON);
439
440 const previous = process.cwd();
441 process.chdir(baseDir);
442 try {
443 yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]);
444 } finally {
445 process.chdir(previous);
446 }
447
448 assert.equal(readSettings(baseDir).defaultTheme, "ocean");
449 assert.equal(
450 NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "ocean.json")),
451 false,
452 );
453 }),
454 );
455
456 // The watcher would skip an oversized file, so publishing one must not
457 // report success for a theme no client receives.
458 it.effect("rejects a theme file larger than the watcher will read", () =>
459 Effect.gen(function* () {
460 const baseDir = makeBaseDir();
461 const themeFile = NodePath.join(baseDir, "huge.json");
462 const padding = "x".repeat(40 * 1024);
463 NodeFS.writeFileSync(
464 themeFile,
465 `{ "name": "Huge", "appearance": "dark", "canvas": "#1a1b26", "accent": "#7aa2f7", "note": "${padding}" }\n`,
466 );
467
468 const failure = yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe(
469 Effect.flip,
470 );
471 assert.include(String(failure), "larger than");
472 }),
473 );
474
475 it.effect("refuses a settings file that is not a JSON object", () =>
476 Effect.gen(function* () {
477 const baseDir = makeBaseDir();
478 NodeFS.mkdirSync(NodePath.dirname(settingsPathFor(baseDir)), { recursive: true });
479 NodeFS.writeFileSync(settingsPathFor(baseDir), "[1, 2, 3]\n");
480
481 const failure = yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]).pipe(
482 Effect.flip,
483 );
484
485 assert.include(String(failure), "not a JSON object");
486 }),
487 );
488});