// @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises the filesystem boundary. import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as ConfigProvider from "effect/ConfigProvider"; import * as NetService from "@t3tools/shared/Net"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as TestConsole from "effect/testing/TestConsole"; import { Command } from "effect/unstable/cli"; import { cli } from "../bin.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; // These force a failure with chmod, which Windows ignores for directories and // cannot use to make a file unreadable, so the failure never happens there. const windowsHost = HostProcessPlatform.defaultValue() === "win32"; const runCli = (args: ReadonlyArray) => Command.runWith(cli, { version: "0.0.0" })(args).pipe( Effect.provide(Layer.mergeAll(NodeServices.layer, NetService.layer, TestConsole.layer)), ); const makeBaseDir = () => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-theme-cli-")); const settingsPathFor = (baseDir: string) => NodePath.join(baseDir, "userdata", "settings.json"); const NIGHTFALL_THEME_JSON = `${JSON.stringify({ name: "Nightfall", appearance: "dark", canvas: "#1a1b26", accent: "#7aa2f7", })}\n`; const JUNK_THEME_JSON = `${JSON.stringify({ name: "Junk" })}\n`; const readSettings = (baseDir: string): Record => { const raw = NodeFS.readFileSync(settingsPathFor(baseDir), "utf8"); return JSON.parse(raw) as Record; }; const writeSettings = (baseDir: string, settings: Record) => { NodeFS.mkdirSync(NodePath.dirname(settingsPathFor(baseDir)), { recursive: true }); NodeFS.writeFileSync(settingsPathFor(baseDir), `${JSON.stringify(settings, null, 2)}\n`); }; describe("t3 theme", () => { it.effect("writes a default theme when no settings file exists yet", () => Effect.gen(function* () { const baseDir = makeBaseDir(); yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]); assert.equal(readSettings(baseDir).defaultTheme, "ocean"); }), ); // A provisioning command runs against settings written by whatever version // happens to be installed, so it must not drop what it cannot interpret. it.effect("preserves settings it does not recognise", () => Effect.gen(function* () { const baseDir = makeBaseDir(); writeSettings(baseDir, { enableProviderUpdateChecks: false, somethingFromANewerBuild: { nested: true }, }); yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]); const settings = readSettings(baseDir); assert.equal(settings.defaultTheme, "ocean"); assert.equal(settings.enableProviderUpdateChecks, false); assert.deepEqual(settings.somethingFromANewerBuild, { nested: true }); }), ); it.effect("clears the default back to leaving fresh clients alone", () => Effect.gen(function* () { const baseDir = makeBaseDir(); writeSettings(baseDir, { enableProviderUpdateChecks: false }); yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]); yield* runCli(["theme", "clear", "--base-dir", baseDir]); const settings = readSettings(baseDir); assert.equal(Object.hasOwn(settings, "defaultTheme"), false); assert.equal(settings.enableProviderUpdateChecks, false); }), ); // Publishing a file and pointing at it are one step, so an integration // (a desktop's theme hook) needs no knowledge of the themes directory. it.effect("publishes a theme file under its filename and sets it", () => Effect.gen(function* () { const baseDir = makeBaseDir(); const themeFile = NodePath.join(baseDir, "nightfall.json"); NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]); const published = NodePath.join(baseDir, "userdata", "themes", "nightfall.json"); assert.equal(NodeFS.existsSync(published), true); assert.equal(readSettings(baseDir).defaultTheme, "nightfall"); // No rollback or staging residue after a successful set. const residue = NodeFS.readdirSync(NodePath.dirname(published)).filter( (entry) => !entry.endsWith(".json"), ); assert.deepEqual(residue, []); }), ); it.effect("publishes a theme file under an explicit id", () => Effect.gen(function* () { const baseDir = makeBaseDir(); const themeFile = NodePath.join(baseDir, "t3code.json"); NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); yield* runCli(["theme", "set", "--id", "nightfall", themeFile, "--base-dir", baseDir]); assert.equal( NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "nightfall.json")), true, ); assert.equal(readSettings(baseDir).defaultTheme, "nightfall"); }), ); it.effect("rejects a file that is not a theme and sets nothing", () => Effect.gen(function* () { const baseDir = makeBaseDir(); const themeFile = NodePath.join(baseDir, "junk.json"); NodeFS.writeFileSync(themeFile, JUNK_THEME_JSON); const failure = yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe( Effect.flip, ); assert.include(String(failure), "not a valid theme file"); assert.equal(NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes")), false); assert.equal(NodeFS.existsSync(settingsPathFor(baseDir)), false); }), ); // Publish and set are one command, so a settings file the set step cannot // use must fail it before the themes directory is mutated -- not after, // with a half-applied publish left behind. it.effect("publishes nothing when the settings file cannot be used", () => Effect.gen(function* () { const baseDir = makeBaseDir(); NodeFS.mkdirSync(NodePath.dirname(settingsPathFor(baseDir)), { recursive: true }); NodeFS.writeFileSync(settingsPathFor(baseDir), "{ not json"); const themeFile = NodePath.join(baseDir, "nightfall.json"); NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); const failure = yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe( Effect.flip, ); assert.include(String(failure), "not a JSON object"); assert.equal(NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes")), false); }), ); // set means set: a publish that rode along with a failed default write is // rolled back rather than left mutating the environment's theme set. The // userdata directory is made read-only while themes stays writable, so the // failure lands after the publish -- the case the rollback exists for. it.effect.skipIf(windowsHost)("rolls back a publish when the default cannot be written", () => Effect.gen(function* () { const baseDir = makeBaseDir(); writeSettings(baseDir, {}); const userdataDir = NodePath.dirname(settingsPathFor(baseDir)); const themesDir = NodePath.join(userdataDir, "themes"); NodeFS.mkdirSync(themesDir, { recursive: true }); const themeFile = NodePath.join(baseDir, "nightfall.json"); NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); NodeFS.chmodSync(userdataDir, 0o555); try { const failure = yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe( Effect.flip, ); assert.include(String(failure), "Could not write"); assert.equal(NodeFS.existsSync(NodePath.join(themesDir, "nightfall.json")), false); } finally { NodeFS.chmodSync(userdataDir, 0o755); } }), ); // A symlink is a normal way to hand this command a theme -- desktop hooks // symlink the current palette -- so the source is resolved, not refused. it.effect.skipIf(!symlinksSupported)( "publishes a theme file through a symlinked source path", () => Effect.gen(function* () { const baseDir = makeBaseDir(); const realFile = NodePath.join(baseDir, "real-nightfall.json"); NodeFS.writeFileSync(realFile, NIGHTFALL_THEME_JSON); const linkPath = NodePath.join(baseDir, "nightfall.json"); NodeFS.symlinkSync(realFile, linkPath); yield* runCli(["theme", "set", linkPath, "--base-dir", baseDir]); assert.equal( NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "nightfall.json")), true, ); assert.equal(readSettings(baseDir).defaultTheme, "nightfall"); }), ); // The staging entry is created fresh with O_EXCL, so a symlink planted at // its predictable name is cleared, never followed and written through. it.effect.skipIf(!symlinksSupported)("never writes through a symlink at the staging path", () => Effect.gen(function* () { const baseDir = makeBaseDir(); const themesDir = NodePath.join(baseDir, "userdata", "themes"); NodeFS.mkdirSync(themesDir, { recursive: true }); const victim = NodePath.join(baseDir, "victim.txt"); NodeFS.writeFileSync(victim, "precious"); NodeFS.symlinkSync(victim, NodePath.join(themesDir, `nightfall.json.staging-${process.pid}`)); const themeFile = NodePath.join(baseDir, "nightfall.json"); NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]); assert.equal(NodeFS.readFileSync(victim, "utf8"), "precious"); assert.equal(readSettings(baseDir).defaultTheme, "nightfall"); }), ); // Rollback moves the previous directory entry aside and back, so even an // entry the watcher would never publish -- here a symlink -- comes back // exactly as it was when the set fails. it.effect.skipIf(!symlinksSupported || windowsHost)( "restores a non-theme destination entry when the set fails", () => Effect.gen(function* () { const baseDir = makeBaseDir(); writeSettings(baseDir, {}); const userdataDir = NodePath.dirname(settingsPathFor(baseDir)); const themesDir = NodePath.join(userdataDir, "themes"); NodeFS.mkdirSync(themesDir, { recursive: true }); const outside = NodePath.join(baseDir, "outside.json"); NodeFS.writeFileSync(outside, NIGHTFALL_THEME_JSON); const destination = NodePath.join(themesDir, "nightfall.json"); NodeFS.symlinkSync(outside, destination); const themeFile = NodePath.join(baseDir, "nightfall.json"); NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); NodeFS.chmodSync(userdataDir, 0o555); try { yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe(Effect.flip); assert.equal(NodeFS.lstatSync(destination).isSymbolicLink(), true); } finally { NodeFS.chmodSync(userdataDir, 0o755); } }), ); it.effect.skipIf(windowsHost)("restores the previous theme when a re-publish fails to set", () => Effect.gen(function* () { const baseDir = makeBaseDir(); writeSettings(baseDir, {}); const userdataDir = NodePath.dirname(settingsPathFor(baseDir)); const themesDir = NodePath.join(userdataDir, "themes"); NodeFS.mkdirSync(themesDir, { recursive: true }); const publishedPath = NodePath.join(themesDir, "nightfall.json"); const previous = '{ "name": "Old Nightfall", "appearance": "dark", "canvas": "#000000", "accent": "#ffffff" }\n'; NodeFS.writeFileSync(publishedPath, previous); const themeFile = NodePath.join(baseDir, "nightfall.json"); NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); NodeFS.chmodSync(userdataDir, 0o555); try { yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe(Effect.flip); assert.equal(NodeFS.readFileSync(publishedPath, "utf8"), previous); } finally { NodeFS.chmodSync(userdataDir, 0o755); } }), ); // A typo'd id written as the theme would silently never resolve anywhere; // the id branch is as strict as the filename rule. it.effect("rejects an id no client could resolve", () => Effect.gen(function* () { const baseDir = makeBaseDir(); const failure = yield* runCli(["theme", "set", "Nightfall", "--base-dir", baseDir]).pipe( Effect.flip, ); assert.include(String(failure), "not a valid theme id"); assert.equal(NodeFS.existsSync(settingsPathFor(baseDir)), false); }), ); it.effect("rejects a path that does not exist instead of storing it as an id", () => Effect.gen(function* () { const baseDir = makeBaseDir(); const failure = yield* runCli([ "theme", "set", `${baseDir}/missing.json`, "--base-dir", baseDir, ]).pipe(Effect.flip); assert.include(String(failure), "Could not read"); }), ); // File-ness is decided by existence, not extension, so a generated file // named for its target app still publishes. it.effect("publishes an extensionless file", () => Effect.gen(function* () { const baseDir = makeBaseDir(); const themeFile = NodePath.join(baseDir, "brand"); NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]); assert.equal( NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "brand.json")), true, ); assert.equal(readSettings(baseDir).defaultTheme, "brand"); }), ); it.effect("records a set generation and clears it with the theme", () => Effect.gen(function* () { const baseDir = makeBaseDir(); yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]); const setAt = readSettings(baseDir).defaultThemeSetAt; assert.equal(typeof setAt, "string"); yield* runCli(["theme", "clear", "--base-dir", baseDir]); const cleared = readSettings(baseDir); assert.equal(Object.hasOwn(cleared, "defaultTheme"), false); assert.equal(Object.hasOwn(cleared, "defaultThemeSetAt"), false); }), ); it.effect("honors T3CODE_HOME like the rest of the CLI", () => Effect.gen(function* () { const baseDir = makeBaseDir(); yield* runCli(["theme", "set", "ocean"]).pipe( Effect.provide( ConfigProvider.layer(ConfigProvider.fromEnv({ env: { T3CODE_HOME: baseDir } })), ), ); assert.equal(readSettings(baseDir).defaultTheme, "ocean"); }), ); // An unreadable settings file must never read as "no settings": writing a // fresh sparse file over it would discard every key the user had. it.effect.skipIf(windowsHost)("refuses to write when the settings file cannot be read", () => Effect.gen(function* () { const baseDir = makeBaseDir(); writeSettings(baseDir, { enableProviderUpdateChecks: false }); NodeFS.chmodSync(settingsPathFor(baseDir), 0o000); const failure = yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]).pipe( Effect.flip, ); NodeFS.chmodSync(settingsPathFor(baseDir), 0o644); assert.include(String(failure), "Could not read"); assert.equal(readSettings(baseDir).enableProviderUpdateChecks, false); assert.equal(Object.hasOwn(readSettings(baseDir), "defaultTheme"), false); }), ); // A typo is syntactically a valid id, so shape validation alone would write // a theme no client can resolve and report success. it.effect("rejects an id that names no theme", () => Effect.gen(function* () { const baseDir = makeBaseDir(); const failure = yield* runCli(["theme", "set", "ocian", "--base-dir", baseDir]).pipe( Effect.flip, ); assert.include(String(failure), "No theme named"); assert.equal(NodeFS.existsSync(settingsPathFor(baseDir)), false); }), ); it.effect("accepts an id a published file provides", () => Effect.gen(function* () { const baseDir = makeBaseDir(); const themeFile = NodePath.join(baseDir, "nightfall.json"); NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]); // Now resolvable by bare id, because the file published it. yield* runCli(["theme", "clear", "--base-dir", baseDir]); yield* runCli(["theme", "set", "nightfall", "--base-dir", baseDir]); assert.equal(readSettings(baseDir).defaultTheme, "nightfall"); }), ); // The watcher skips files it cannot use, so accepting their filename would // set a theme no client ever receives. it.effect("rejects an id whose published file the watcher would skip", () => Effect.gen(function* () { const baseDir = makeBaseDir(); const themesDir = NodePath.join(baseDir, "userdata", "themes"); NodeFS.mkdirSync(themesDir, { recursive: true }); NodeFS.writeFileSync(NodePath.join(themesDir, "broken.json"), "{ not json\n"); const failure = yield* runCli(["theme", "set", "broken", "--base-dir", baseDir]).pipe( Effect.flip, ); assert.include(String(failure), "No theme named"); }), ); // Web and desktop cannot resolve the mobile default, and mobile does not // follow this setting, so naming it would be a silent no-op. it.effect("rejects the mobile default theme id", () => Effect.gen(function* () { const baseDir = makeBaseDir(); const failure = yield* runCli(["theme", "set", "t3-code", "--base-dir", baseDir]).pipe( Effect.flip, ); assert.include(String(failure), "No theme named"); }), ); // Deciding on existence alone would publish ./ocean instead of selecting the // built-in, purely because of what happens to be in the working directory. it.effect("treats a bare id as an id even when a file shares its name", () => Effect.gen(function* () { const baseDir = makeBaseDir(); const cwdFile = NodePath.join(baseDir, "ocean"); NodeFS.writeFileSync(cwdFile, NIGHTFALL_THEME_JSON); const previous = process.cwd(); process.chdir(baseDir); try { yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]); } finally { process.chdir(previous); } assert.equal(readSettings(baseDir).defaultTheme, "ocean"); assert.equal( NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "ocean.json")), false, ); }), ); // The watcher would skip an oversized file, so publishing one must not // report success for a theme no client receives. it.effect("rejects a theme file larger than the watcher will read", () => Effect.gen(function* () { const baseDir = makeBaseDir(); const themeFile = NodePath.join(baseDir, "huge.json"); const padding = "x".repeat(40 * 1024); NodeFS.writeFileSync( themeFile, `{ "name": "Huge", "appearance": "dark", "canvas": "#1a1b26", "accent": "#7aa2f7", "note": "${padding}" }\n`, ); const failure = yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe( Effect.flip, ); assert.include(String(failure), "larger than"); }), ); it.effect("refuses a settings file that is not a JSON object", () => Effect.gen(function* () { const baseDir = makeBaseDir(); NodeFS.mkdirSync(NodePath.dirname(settingsPathFor(baseDir)), { recursive: true }); NodeFS.writeFileSync(settingsPathFor(baseDir), "[1, 2, 3]\n"); const failure = yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]).pipe( Effect.flip, ); assert.include(String(failure), "not a JSON object"); }), ); });