1// @effect-diagnostics nodeBuiltinImport:off - publish commits and rollbacks
2// move exact directory entries with rename, which the FileSystem service does
3// not expose atomically.
4/**
5 * `t3 theme` - inspect and set the environment's theme. Connected web and
6 * desktop clients switch when it is set; mobile keeps its own appearance
7 * settings. Each client applies one set once, so a theme the user picks in
8 * Settings afterwards sticks until the next `t3 theme set`.
9 *
10 * Writes `defaultTheme` (and `defaultThemeSetAt`, so a re-set of the same
11 * value still acts) into the environment's `settings.json`. A running server
12 * watches that file and pushes the change, so this works before the first
13 * launch and on a live server alike.
14 *
15 * The edit is deliberately a minimal one on the parsed JSON object rather than
16 * a schema round-trip. Settings files outlive the build that reads them, and a
17 * provisioning command must not drop keys this version does not recognise.
18 */
19import * as NodeFS from "node:fs";
20
21import {
22 EnvironmentThemeFile,
23 EnvironmentThemeId,
24 environmentThemeFileHasColors,
25} from "@t3tools/contracts";
26import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";
27import { BUILT_IN_THEME_IDS, UNPUBLISHABLE_THEME_IDS } from "@t3tools/shared/themePalettes";
28import * as Config from "effect/Config";
29import * as Console from "effect/Console";
30import * as DateTime from "effect/DateTime";
31import * as Effect from "effect/Effect";
32import * as FileSystem from "effect/FileSystem";
33import * as Option from "effect/Option";
34import * as Path from "effect/Path";
35import * as Schema from "effect/Schema";
36import { Argument, Command, Flag } from "effect/cli";
37
38import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
39import * as ServerConfig from "../config.ts";
40import {
41 MAX_THEME_FILE_BYTES,
42 readPublishedThemes,
43 readThemeFileGuarded,
44} from "../environmentTheme.ts";
45import { expandHomePath, resolveBaseDir } from "../os-jank.ts";
46import { baseDirFlag } from "./config.ts";
47
48/** Settings files outlive the build that reads them, so the object is carried
49 * as-is and only the theme keys are touched. */
50const SparseSettings = Schema.Record(Schema.String, Schema.Unknown);
51const decodeSettingsJson = Schema.decodeUnknownEffect(fromLenientJson(SparseSettings));
52const encodeSettingsJson = Schema.encodeEffect(fromJsonStringPretty(SparseSettings));
53const decodeThemeFileJsonExit = Schema.decodeUnknownExit(
54 Schema.fromJsonString(EnvironmentThemeFile),
55);
56const isEnvironmentThemeId = Schema.is(EnvironmentThemeId);
57
58export class ThemeSettingsUnreadableError extends Schema.TaggedError<ThemeSettingsUnreadableError>()(
59 "ThemeSettingsUnreadableError",
60 { settingsPath: Schema.String, cause: Schema.Defect() },
61) {
62 override get message(): string {
63 return `Could not read ${this.settingsPath}. Fix its permissions, then run this again.`;
64 }
65}
66
67export class ThemeSettingsMalformedError extends Schema.TaggedError<ThemeSettingsMalformedError>()(
68 "ThemeSettingsMalformedError",
69 { settingsPath: Schema.String, cause: Schema.Defect() },
70) {
71 override get message(): string {
72 return `${this.settingsPath} is not a JSON object. Fix or remove it, then run this again.`;
73 }
74}
75
76export class ThemeSettingsBusyError extends Schema.TaggedError<ThemeSettingsBusyError>()(
77 "ThemeSettingsBusyError",
78 { settingsPath: Schema.String, attempts: Schema.Number },
79) {
80 override get message(): string {
81 return `${this.settingsPath} kept changing while writing (gave up after ${this.attempts} attempts). Try again.`;
82 }
83}
84
85export class ThemeSettingsWriteError extends Schema.TaggedError<ThemeSettingsWriteError>()(
86 "ThemeSettingsWriteError",
87 { settingsPath: Schema.String, cause: Schema.Defect() },
88) {
89 override get message(): string {
90 return `Could not write ${this.settingsPath}.`;
91 }
92}
93
94export class ThemeFileUnreadableError extends Schema.TaggedError<ThemeFileUnreadableError>()(
95 "ThemeFileUnreadableError",
96 // Optional: a path that never existed has no underlying failure to carry,
97 // and a manufactured string there would only look like a real one.
98 { filePath: Schema.String, cause: Schema.optional(Schema.Defect()) },
99) {
100 override get message(): string {
101 return `Could not read ${this.filePath}.`;
102 }
103}
104
105export class ThemeFileInvalidError extends Schema.TaggedError<ThemeFileInvalidError>()(
106 "ThemeFileInvalidError",
107 { filePath: Schema.String, cause: Schema.Defect() },
108) {
109 override get message(): string {
110 return `${this.filePath} is not a valid theme file. Use a theme exported from T3 Code, or a seeded file with name, appearance, canvas, and accent.`;
111 }
112}
113
114export class ThemeFileTooLargeError extends Schema.TaggedError<ThemeFileTooLargeError>()(
115 "ThemeFileTooLargeError",
116 { filePath: Schema.String, limit: Schema.Number },
117) {
118 override get message(): string {
119 return `${this.filePath} is larger than ${this.limit} bytes, which is more than a theme can publish.`;
120 }
121}
122
123export class ThemeFileColorlessError extends Schema.TaggedError<ThemeFileColorlessError>()(
124 "ThemeFileColorlessError",
125 { filePath: Schema.String },
126) {
127 override get message(): string {
128 return `${this.filePath} has no colors to publish.`;
129 }
130}
131
132export class ThemePublishError extends Schema.TaggedError<ThemePublishError>()(
133 "ThemePublishError",
134 { themesDir: Schema.String, cause: Schema.Defect() },
135) {
136 override get message(): string {
137 return `Could not publish the theme into ${this.themesDir}.`;
138 }
139}
140
141const INVALID_THEME_ID_REASON =
142 "is not a valid theme id (lowercase letters, digits, and hyphens; not an appearance keyword)";
143
144export class ThemeIdUnknownError extends Schema.TaggedError<ThemeIdUnknownError>()(
145 "ThemeIdUnknownError",
146 { themeId: Schema.String, known: Schema.Array(Schema.String) },
147) {
148 override get message(): string {
149 return `No theme named "${this.themeId}". Available: ${this.known.join(", ")}. Publish one by passing a theme file instead of an id.`;
150 }
151}
152
153export class ThemeIdInvalidError extends Schema.TaggedError<ThemeIdInvalidError>()(
154 "ThemeIdInvalidError",
155 { themeId: Schema.String },
156) {
157 override get message(): string {
158 return `"${this.themeId}" ${INVALID_THEME_ID_REASON}.`;
159 }
160}
161
162/** A filename that cannot be a theme id, where --id is the way out. */
163export class ThemeFileIdInvalidError extends Schema.TaggedError<ThemeFileIdInvalidError>()(
164 "ThemeFileIdInvalidError",
165 { themeId: Schema.String, filePath: Schema.String },
166) {
167 override get message(): string {
168 return `"${this.themeId}" ${INVALID_THEME_ID_REASON}. Pass one with --id.`;
169 }
170}
171
172export class ThemeTargetMissingError extends Schema.TaggedError<ThemeTargetMissingError>()(
173 "ThemeTargetMissingError",
174 {},
175) {
176 override get message(): string {
177 return "Provide a theme id or file, or run `t3 theme clear` to remove the theme.";
178 }
179}
180
181const envT3Home = Config.String("T3CODE_HOME").pipe(Config.option);
182
183const resolveThemePaths = Effect.fn(function* (explicitBaseDir: Option.Option<string>) {
184 // Same precedence as the rest of the CLI: --base-dir, then T3CODE_HOME,
185 // then the default home. A provisioning script exporting T3CODE_HOME must
186 // not have this one command silently target the default install.
187 const envHome = Option.filter(yield* envT3Home, (value) => value.trim().length > 0);
188 const configuredBaseDir = Option.orElse(explicitBaseDir, () => envHome);
189 const baseDir = yield* resolveBaseDir(Option.getOrUndefined(configuredBaseDir));
190 const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined, {
191 baseDirIsExplicit: Option.isSome(configuredBaseDir),
192 });
193 return {
194 settingsPath: derivedPaths.settingsPath,
195 themesDir: derivedPaths.environmentThemesDir,
196 };
197});
198
199/**
200 * Reads the sparse settings object, treating only a genuinely absent file as
201 * empty. A permission or I/O error must propagate: reading it as "no settings"
202 * would have the caller write a fresh sparse file over settings it never saw.
203 */
204const readSettingsObject = Effect.fn(function* (settingsPath: string) {
205 const fs = yield* FileSystem.FileSystem;
206 const exists = yield* fs
207 .exists(settingsPath)
208 .pipe(Effect.mapError((cause) => new ThemeSettingsUnreadableError({ settingsPath, cause })));
209 if (!exists) return { raw: "", settings: {} };
210
211 const raw = yield* fs
212 .readFileString(settingsPath)
213 .pipe(Effect.mapError((cause) => new ThemeSettingsUnreadableError({ settingsPath, cause })));
214 if (raw.trim().length === 0) return { raw, settings: {} };
215
216 const settings = yield* decodeSettingsJson(raw).pipe(
217 Effect.mapError((cause) => new ThemeSettingsMalformedError({ settingsPath, cause })),
218 );
219 return { raw, settings };
220});
221
222/**
223 * A running server owns this file too, and its write path is an in-process
224 * semaphore that cannot serialize against another process. So the document is
225 * re-read immediately before the rename and the whole edit is retried when it
226 * moved underneath us, which is what turns "last writer wins" into "last
227 * writer merges", and an edit that keeps losing the race fails loudly rather
228 * than overwriting. A write landing inside the remaining rename window is
229 * still possible; the server's own watcher reconciles the file either way.
230 */
231const CONCURRENT_WRITE_ATTEMPTS = 5;
232
233const writeDefaultTheme = Effect.fn(function* (input: {
234 readonly settingsPath: string;
235 readonly themeId: string;
236}) {
237 const fs = yield* FileSystem.FileSystem;
238
239 for (let attempt = 1; ; attempt++) {
240 const { raw, settings } = yield* readSettingsObject(input.settingsPath);
241 const setAt = DateTime.formatIso(yield* DateTime.now);
242 const next =
243 input.themeId.length > 0
244 ? // The timestamp is the set-generation: it lets clients apply a re-set
245 // of the same value they already applied once.
246 { ...settings, defaultTheme: input.themeId, defaultThemeSetAt: setAt }
247 : // Clearing removes the keys rather than storing empty strings, so the
248 // file reads the same as one that never set a theme.
249 Object.fromEntries(
250 Object.entries(settings).filter(
251 ([key]) => key !== "defaultTheme" && key !== "defaultThemeSetAt",
252 ),
253 );
254
255 const contents = yield* encodeSettingsJson(next);
256 const current = yield* fs
257 .readFileString(input.settingsPath)
258 .pipe(Effect.orElseSucceed(() => ""));
259 if (current !== raw) {
260 // Falling through here would overwrite whatever landed in between, which
261 // is exactly the loss this loop exists to prevent.
262 if (attempt >= CONCURRENT_WRITE_ATTEMPTS) {
263 return yield* new ThemeSettingsBusyError({
264 settingsPath: input.settingsPath,
265 attempts: CONCURRENT_WRITE_ATTEMPTS,
266 });
267 }
268 continue;
269 }
270
271 yield* writeFileStringAtomically({
272 filePath: input.settingsPath,
273 contents: `${contents}\n`,
274 }).pipe(
275 Effect.mapError(
276 (cause) => new ThemeSettingsWriteError({ settingsPath: input.settingsPath, cause }),
277 ),
278 );
279 return;
280 }
281});
282
283/** Publishes a theme file into the environment's themes directory and returns
284 * the id it published under. */
285const publishThemeFile = Effect.fn(function* (input: {
286 readonly themesDir: string;
287 readonly filePath: string;
288 readonly explicitId: Option.Option<string>;
289}) {
290 const fs = yield* FileSystem.FileSystem;
291 const path = yield* Path.Path;
292 // A preflight for error quality only: it tells a FIFO from an oversized
293 // file. Enforcement happens at the guarded read below.
294 const info = yield* fs
295 .stat(input.filePath)
296 .pipe(
297 Effect.mapError((cause) => new ThemeFileUnreadableError({ filePath: input.filePath, cause })),
298 );
299 if (info.type !== "File") {
300 return yield* new ThemeFileUnreadableError({ filePath: input.filePath });
301 }
302 if (Number(info.size) > MAX_THEME_FILE_BYTES) {
303 return yield* new ThemeFileTooLargeError({
304 filePath: input.filePath,
305 limit: MAX_THEME_FILE_BYTES,
306 });
307 }
308
309 // An explicit source path is the user's own input, and a symlink there is a
310 // normal way to point at a theme (desktop hooks symlink the current
311 // palette), so it is resolved before the guarded read. The read still goes
312 // through one opened handle whose type and size checks bind to the file
313 // actually read, so a FIFO cannot hang the command and an oversized target
314 // is refused.
315 const resolvedSource = yield* fs
316 .realPath(input.filePath)
317 .pipe(
318 Effect.mapError((cause) => new ThemeFileUnreadableError({ filePath: input.filePath, cause })),
319 );
320 const raw = readThemeFileGuarded(resolvedSource, MAX_THEME_FILE_BYTES);
321 if (raw === null) {
322 return yield* new ThemeFileUnreadableError({ filePath: input.filePath });
323 }
324
325 const decoded = decodeThemeFileJsonExit(raw);
326 if (decoded._tag === "Failure") {
327 return yield* new ThemeFileInvalidError({ filePath: input.filePath, cause: decoded.cause });
328 }
329 if (!environmentThemeFileHasColors(decoded.value)) {
330 return yield* new ThemeFileColorlessError({ filePath: input.filePath });
331 }
332
333 const fileBasename = path.basename(input.filePath, ".json");
334 const themeId = Option.getOrElse(input.explicitId, () => fileBasename);
335 // The same rules the watcher applies when it reads the directory back, so a
336 // publish cannot report success for a file that will then be skipped.
337 if (!isEnvironmentThemeId(themeId) || UNPUBLISHABLE_THEME_IDS.has(themeId)) {
338 return yield* new ThemeFileIdInvalidError({ themeId, filePath: input.filePath });
339 }
340
341 const destinationPath = path.join(input.themesDir, `${themeId}.json`);
342 // Neither ends in `.json`, so the watcher never mistakes them for themes.
343 // Both names carry the pid, so concurrent publishers of one id cannot
344 // unlink or restore over each other's staging and rollback copies.
345 const backupPath = `${destinationPath}.rollback-${process.pid}`;
346 const stagingPath = `${destinationPath}.staging-${process.pid}`;
347 yield* fs
348 .makeDirectory(input.themesDir, { recursive: true })
349 .pipe(Effect.mapError((cause) => new ThemePublishError({ themesDir: input.themesDir, cause })));
350
351 const publishFailure = (cause: unknown) =>
352 new ThemePublishError({ themesDir: input.themesDir, cause });
353
354 // Staged in full before anything moves, so the commit below is two adjacent
355 // renames with no I/O between them. The staging entry is created O_EXCL
356 // after clearing any stale leftover, so a symlink or file already at that
357 // predictable name is never followed or written through. Written verbatim:
358 // appending so much as a newline could push a file at the size limit past
359 // it and have the watcher skip what was just accepted.
360 const stagedIno = yield* Effect.try({
361 try: () => {
362 try {
363 NodeFS.unlinkSync(stagingPath);
364 } catch {
365 // Nothing stale to clear.
366 }
367 const fd = NodeFS.openSync(
368 stagingPath,
369 NodeFS.constants.O_WRONLY | NodeFS.constants.O_CREAT | NodeFS.constants.O_EXCL,
370 0o644,
371 );
372 try {
373 NodeFS.writeFileSync(fd, raw);
374 // Rename preserves the inode, so this identifies our published file
375 // at the destination for as long as it is actually ours.
376 return NodeFS.fstatSync(fd).ino;
377 } finally {
378 NodeFS.closeSync(fd);
379 }
380 },
381 catch: publishFailure,
382 });
383
384 // Whatever occupies the destination -- a theme, a symlink, anything -- is
385 // moved aside in one atomic step rather than inspected and then replaced:
386 // there is no window between a check and the commit, and rollback restores
387 // that exact directory entry instead of a re-read of it. Only "nothing
388 // there" continues; any other rename failure aborts before the destination
389 // is touched.
390 const hadPrevious = yield* Effect.try({
391 try: () => {
392 try {
393 NodeFS.renameSync(destinationPath, backupPath);
394 return true;
395 } catch (error) {
396 if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
397 throw error;
398 }
399 },
400 catch: publishFailure,
401 });
402
403 const revert = Effect.sync(() => {
404 try {
405 NodeFS.unlinkSync(stagingPath);
406 } catch {
407 // Usually already renamed away; a stray staging file is watcher-inert.
408 }
409 try {
410 // The destination is touched only while it is empty or still holds
411 // the exact file this process put there; a concurrent publisher's
412 // newer file wins, and this process's obsolete copy is discarded.
413 const destinationIno = (() => {
414 try {
415 return NodeFS.lstatSync(destinationPath).ino;
416 } catch {
417 return null;
418 }
419 })();
420 if (hadPrevious) {
421 if (destinationIno === null || destinationIno === stagedIno) {
422 NodeFS.renameSync(backupPath, destinationPath);
423 } else {
424 NodeFS.unlinkSync(backupPath);
425 }
426 } else if (destinationIno === stagedIno) {
427 NodeFS.unlinkSync(destinationPath);
428 }
429 } catch {
430 // Best effort; the failure that triggered the revert still surfaces.
431 }
432 });
433 const cleanup = Effect.sync(() => {
434 try {
435 if (hadPrevious) NodeFS.unlinkSync(backupPath);
436 } catch {
437 // A stray backup is inert: it is not `.json`, so nothing serves it.
438 }
439 });
440
441 yield* Effect.try({
442 try: () => NodeFS.renameSync(stagingPath, destinationPath),
443 catch: publishFailure,
444 }).pipe(Effect.onError(() => revert));
445
446 return { themeId, revert, cleanup };
447});
448
449/**
450 * Ids a client can actually resolve: this build's built-ins plus what the
451 * machine publishes, read through the same function the watcher uses so a file
452 * it would skip can never be accepted here. The mobile default is absent on
453 * purpose -- web and desktop cannot resolve it and mobile does not follow this
454 * setting, so naming it would be the silent no-op this check exists to stop.
455 */
456const resolvableThemeIds = Effect.fn(function* (themesDir: string) {
457 const published = yield* readPublishedThemes(themesDir);
458 return [...BUILT_IN_THEME_IDS, ...published.map((theme) => theme.id)].toSorted();
459});
460
461const themeSetCommand = Command.make("set", {
462 baseDir: baseDirFlag,
463 id: Flag.String("id").pipe(
464 Flag.withDescription("Theme id to publish a file under, instead of its filename."),
465 Flag.optional,
466 ),
467 theme: Argument.String("theme").pipe(
468 Argument.withDescription(
469 'A theme id (a built-in, or one this machine publishes — themes/nightfall.json is "nightfall"), or a path to a theme JSON file to publish and set in one step.',
470 ),
471 ),
472}).pipe(
473 Command.withDescription("Set the environment's theme; connected clients switch to it."),
474 Command.withHandler((flags) =>
475 Effect.gen(function* () {
476 const fs = yield* FileSystem.FileSystem;
477 const target = yield* expandHomePath(flags.theme.trim());
478 if (target.length === 0) {
479 return yield* new ThemeTargetMissingError();
480 }
481 const paths = yield* resolveThemePaths(flags.baseDir);
482
483 // An existing file publishes; anything path-shaped that does not exist
484 // is a mistake to surface, not an id to store; everything else must be
485 // a well-formed id, so a typo cannot be written as a theme no client
486 // will ever resolve.
487 // Path-shaped first, existence second. Deciding on existence alone would
488 // make `t3 theme set ocean` publish ./ocean whenever the cwd happens to
489 // hold a file by that name, instead of selecting the built-in.
490 const looksLikePath =
491 target.endsWith(".json") ||
492 target.includes("/") ||
493 target.includes("\\") ||
494 target.startsWith("~");
495 const targetIsFile =
496 looksLikePath && (yield* fs.exists(target).pipe(Effect.orElseSucceed(() => false)));
497 let themeId: string;
498 let revertPublish: Effect.Effect<void> = Effect.void;
499 let cleanupPublish: Effect.Effect<void> = Effect.void;
500 if (targetIsFile) {
501 // Settings are preflighted before publishing, so a settings file the
502 // set step cannot read or parse fails the command before it mutates
503 // the themes directory.
504 yield* readSettingsObject(paths.settingsPath);
505 const published = yield* publishThemeFile({
506 themesDir: paths.themesDir,
507 filePath: target,
508 explicitId: flags.id,
509 });
510 themeId = published.themeId;
511 revertPublish = published.revert;
512 cleanupPublish = published.cleanup;
513 } else if (looksLikePath) {
514 return yield* new ThemeFileUnreadableError({ filePath: target });
515 } else if (isEnvironmentThemeId(target)) {
516 const known = yield* resolvableThemeIds(paths.themesDir);
517 if (!known.includes(target)) {
518 return yield* new ThemeIdUnknownError({ themeId: target, known });
519 }
520 themeId = target;
521 } else {
522 return yield* new ThemeIdInvalidError({ themeId: target });
523 }
524
525 // set means set: if the default cannot be written, the publish that
526 // rode along with it is undone rather than left as a side effect of a
527 // command that reported failure.
528 yield* writeDefaultTheme({ settingsPath: paths.settingsPath, themeId }).pipe(
529 Effect.onError(() => revertPublish),
530 );
531 yield* cleanupPublish;
532 yield* Console.log(
533 targetIsFile
534 ? `Published ${target} as "${themeId}" and set it as the environment theme.\n`
535 : `Environment theme set to "${themeId}" in ${paths.settingsPath}.\n`,
536 );
537 }),
538 ),
539);
540
541const themeClearCommand = Command.make("clear", { baseDir: baseDirFlag }).pipe(
542 Command.withDescription("Remove the environment's theme; clients keep what they have."),
543 Command.withHandler((flags) =>
544 Effect.gen(function* () {
545 const paths = yield* resolveThemePaths(flags.baseDir);
546 yield* writeDefaultTheme({ settingsPath: paths.settingsPath, themeId: "" });
547 yield* Console.log(`Environment theme cleared in ${paths.settingsPath}.\n`);
548 }),
549 ),
550);
551
552const themeShowCommand = Command.make("show", { baseDir: baseDirFlag }).pipe(
553 Command.withDescription("Show the environment's theme and its published themes."),
554 Command.withHandler((flags) =>
555 Effect.gen(function* () {
556 const paths = yield* resolveThemePaths(flags.baseDir);
557 const { settings } = yield* readSettingsObject(paths.settingsPath);
558 const defaultTheme =
559 typeof settings.defaultTheme === "string" && settings.defaultTheme.length > 0
560 ? settings.defaultTheme
561 : null;
562
563 const published = (yield* readPublishedThemes(paths.themesDir))
564 .map((theme) => theme.id)
565 .toSorted();
566
567 yield* Console.log(
568 defaultTheme === null
569 ? "Environment theme: not set.\n"
570 : `Environment theme: "${defaultTheme}".\n`,
571 );
572 yield* Console.log(
573 published.length === 0
574 ? `Published themes: none (publish into ${paths.themesDir}).\n`
575 : `Published themes: ${published.join(", ")}.\n`,
576 );
577 }),
578 ),
579);
580
581export const themeCommand = Command.make("theme").pipe(
582 Command.withDescription("Inspect and set environment-wide theme defaults."),
583 Command.withSubcommands([themeSetCommand, themeClearCommand, themeShowCommand]),
584);