Open History to see the last commit
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.
4import * as NodeFS from "node:fs";
5
6import {
7 EnvironmentThemeFile,
8 EnvironmentThemeId,
9 environmentThemeFileHasColors,
10} from "@t3tools/contracts";
11import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";
12import { BUILT_IN_THEME_IDS, UNPUBLISHABLE_THEME_IDS } from "@t3tools/shared/themePalettes";
13import * as Config from "effect/Config";
14import * as Console from "effect/Console";
15import * as DateTime from "effect/DateTime";
16import * as Effect from "effect/Effect";
17import * as FileSystem from "effect/FileSystem";
18import * as Option from "effect/Option";
19import * as Path from "effect/Path";
20import * as Schema from "effect/Schema";
21import { Argument, Command, Flag } from "effect/cli";
22
23import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
24import * as ServerConfig from "../config.ts";
25import {
26 MAX_THEME_FILE_BYTES,
27 readPublishedThemes,
28 readThemeFileGuarded,
29} from "../environmentTheme.ts";
30import { expandHomePath, resolveBaseDir } from "../os-jank.ts";
31import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
32import { baseDirFlag } from "./config.ts";
33
34const SparseSettings = Schema.Record(Schema.String, Schema.Unknown);
35const decodeSettingsJson = Schema.decodeUnknownEffect(fromLenientJson(SparseSettings));
36const encodeSettingsJson = Schema.encodeEffect(fromJsonStringPretty(SparseSettings));
37const decodeThemeFileJsonExit = Schema.decodeUnknownExit(
38 Schema.fromJsonString(EnvironmentThemeFile),
39);
40const isEnvironmentThemeId = Schema.is(EnvironmentThemeId);
41
42export class ThemeSettingsUnreadableError extends Schema.TaggedError<ThemeSettingsUnreadableError>()(
43 "ThemeSettingsUnreadableError",
44 { settingsPath: Schema.String, cause: Schema.Defect() },
45) {
46 override get message(): string {
47 return `Could not read ${this.settingsPath}. Fix its permissions, then run this again.`;
48 }
49}
50
51export class ThemeSettingsMalformedError extends Schema.TaggedError<ThemeSettingsMalformedError>()(
52 "ThemeSettingsMalformedError",
53 { settingsPath: Schema.String, cause: Schema.Defect() },
54) {
55 override get message(): string {
56 return `${this.settingsPath} is not a JSON object. Fix or remove it, then run this again.`;
57 }
58}
59
60export class ThemeSettingsBusyError extends Schema.TaggedError<ThemeSettingsBusyError>()(
61 "ThemeSettingsBusyError",
62 { settingsPath: Schema.String, attempts: Schema.Number },
63) {
64 override get message(): string {
65 return `${this.settingsPath} kept changing while writing (gave up after ${this.attempts} attempts). Try again.`;
66 }
67}
68
69export class ThemeSettingsWriteError extends Schema.TaggedError<ThemeSettingsWriteError>()(
70 "ThemeSettingsWriteError",
71 { settingsPath: Schema.String, cause: Schema.Defect() },
72) {
73 override get message(): string {
74 return `Could not write ${this.settingsPath}.`;
75 }
76}
77
78export class ThemeFileUnreadableError extends Schema.TaggedError<ThemeFileUnreadableError>()(
79 "ThemeFileUnreadableError",
80 { filePath: Schema.String, cause: Schema.optional(Schema.Defect()) },
81) {
82 override get message(): string {
83 return `Could not read ${this.filePath}.`;
84 }
85}
86
87export class ThemeFileInvalidError extends Schema.TaggedError<ThemeFileInvalidError>()(
88 "ThemeFileInvalidError",
89 { filePath: Schema.String, cause: Schema.Defect() },
90) {
91 override get message(): string {
92 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.`;
93 }
94}
95
96export class ThemeFileTooLargeError extends Schema.TaggedError<ThemeFileTooLargeError>()(
97 "ThemeFileTooLargeError",
98 { filePath: Schema.String, limit: Schema.Number },
99) {
100 override get message(): string {
101 return `${this.filePath} is larger than ${this.limit} bytes, which is more than a theme can publish.`;
102 }
103}
104
105export class ThemeFileColorlessError extends Schema.TaggedError<ThemeFileColorlessError>()(
106 "ThemeFileColorlessError",
107 { filePath: Schema.String },
108) {
109 override get message(): string {
110 return `${this.filePath} has no colors to publish.`;
111 }
112}
113
114export class ThemePublishError extends Schema.TaggedError<ThemePublishError>()(
115 "ThemePublishError",
116 { themesDir: Schema.String, cause: Schema.Defect() },
117) {
118 override get message(): string {
119 return `Could not publish the theme into ${this.themesDir}.`;
120 }
121}
122
123const INVALID_THEME_ID_REASON =
124 "is not a valid theme id (lowercase letters, digits, and hyphens; not an appearance keyword)";
125
126export class ThemeIdUnknownError extends Schema.TaggedError<ThemeIdUnknownError>()(
127 "ThemeIdUnknownError",
128 { themeId: Schema.String, known: Schema.Array(Schema.String) },
129) {
130 override get message(): string {
131 return `No theme named "${this.themeId}". Available: ${this.known.join(", ")}. Publish one by passing a theme file instead of an id.`;
132 }
133}
134
135export class ThemeIdInvalidError extends Schema.TaggedError<ThemeIdInvalidError>()(
136 "ThemeIdInvalidError",
137 { themeId: Schema.String },
138) {
139 override get message(): string {
140 return `"${this.themeId}" ${INVALID_THEME_ID_REASON}.`;
141 }
142}
143
144export class ThemeFileIdInvalidError extends Schema.TaggedError<ThemeFileIdInvalidError>()(
145 "ThemeFileIdInvalidError",
146 { themeId: Schema.String, filePath: Schema.String },
147) {
148 override get message(): string {
149 return `"${this.themeId}" ${INVALID_THEME_ID_REASON}. Pass one with --id.`;
150 }
151}
152
153export class ThemeTargetMissingError extends Schema.TaggedError<ThemeTargetMissingError>()(
154 "ThemeTargetMissingError",
155 {},
156) {
157 override get message(): string {
158 return "Provide a theme id or file, or run `t3 theme clear` to remove the theme.";
159 }
160}
161
162const envT3Home = Config.String("T3CODE_HOME").pipe(Config.option);
163const DEV_STATE_PLACEHOLDER_URL = new URL("http://localhost");
164
165const hasLiveServer = (statePath: string) =>
166 readPersistedServerRuntimeState(statePath).pipe(
167 Effect.map((state) => Option.isSome(state) && isProcessAlive(state.value.pid)),
168 Effect.orElseSucceed(() => false),
169 );
170
171const resolveThemePaths = Effect.fn(function* (explicitBaseDir: Option.Option<string>) {
172 const envHome = Option.filter(yield* envT3Home, (value) => value.trim().length > 0);
173 const configuredBaseDir = Option.orElse(explicitBaseDir, () => envHome);
174 const baseDir = yield* resolveBaseDir(Option.getOrUndefined(configuredBaseDir));
175 const userdataPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined, {
176 baseDirIsExplicit: Option.isSome(configuredBaseDir),
177 });
178 // A dev server with an implicit home keeps its state in `dev`; follow it unless a
179 // `userdata` server is running too, the same order `t3 pair` checks.
180 let derivedPaths = userdataPaths;
181 if (
182 Option.isNone(configuredBaseDir) &&
183 !(yield* hasLiveServer(userdataPaths.serverRuntimeStatePath))
184 ) {
185 const devPaths = yield* ServerConfig.deriveServerPaths(baseDir, DEV_STATE_PLACEHOLDER_URL);
186 if (yield* hasLiveServer(devPaths.serverRuntimeStatePath)) derivedPaths = devPaths;
187 }
188 return {
189 settingsPath: derivedPaths.settingsPath,
190 themesDir: derivedPaths.environmentThemesDir,
191 };
192});
193
194/** Propagate read errors to avoid overwriting unread settings with a sparse file. */
195const readSettingsObject = Effect.fn(function* (settingsPath: string) {
196 const fs = yield* FileSystem.FileSystem;
197 const exists = yield* fs
198 .exists(settingsPath)
199 .pipe(Effect.mapError((cause) => new ThemeSettingsUnreadableError({ settingsPath, cause })));
200 if (!exists) return { raw: "", settings: {} };
201
202 const raw = yield* fs
203 .readFileString(settingsPath)
204 .pipe(Effect.mapError((cause) => new ThemeSettingsUnreadableError({ settingsPath, cause })));
205 if (raw.trim().length === 0) return { raw, settings: {} };
206
207 const settings = yield* decodeSettingsJson(raw).pipe(
208 Effect.mapError((cause) => new ThemeSettingsMalformedError({ settingsPath, cause })),
209 );
210 return { raw, settings };
211});
212
213/** The server's in-process semaphore cannot serialize writes from this process. */
214const CONCURRENT_WRITE_ATTEMPTS = 5;
215
216const writeDefaultTheme = Effect.fn(function* (input: {
217 readonly settingsPath: string;
218 readonly themeId: string;
219}) {
220 const fs = yield* FileSystem.FileSystem;
221
222 for (let attempt = 1; ; attempt++) {
223 const { raw, settings } = yield* readSettingsObject(input.settingsPath);
224 const setAt = DateTime.formatIso(yield* DateTime.now);
225 const next =
226 input.themeId.length > 0
227 ? // The timestamp lets clients reapply the same theme.
228 { ...settings, defaultTheme: input.themeId, defaultThemeSetAt: setAt }
229 : Object.fromEntries(
230 Object.entries(settings).filter(
231 ([key]) => key !== "defaultTheme" && key !== "defaultThemeSetAt",
232 ),
233 );
234
235 const contents = yield* encodeSettingsJson(next);
236 const current = yield* fs
237 .readFileString(input.settingsPath)
238 .pipe(Effect.orElseSucceed(() => ""));
239 if (current !== raw) {
240 if (attempt >= CONCURRENT_WRITE_ATTEMPTS) {
241 return yield* new ThemeSettingsBusyError({
242 settingsPath: input.settingsPath,
243 attempts: CONCURRENT_WRITE_ATTEMPTS,
244 });
245 }
246 continue;
247 }
248
249 yield* writeFileStringAtomically({
250 filePath: input.settingsPath,
251 contents: `${contents}\n`,
252 }).pipe(
253 Effect.mapError(
254 (cause) => new ThemeSettingsWriteError({ settingsPath: input.settingsPath, cause }),
255 ),
256 );
257 return;
258 }
259});
260
261const publishThemeFile = Effect.fn(function* (input: {
262 readonly themesDir: string;
263 readonly filePath: string;
264 readonly explicitId: Option.Option<string>;
265}) {
266 const fs = yield* FileSystem.FileSystem;
267 const path = yield* Path.Path;
268 const info = yield* fs
269 .stat(input.filePath)
270 .pipe(
271 Effect.mapError((cause) => new ThemeFileUnreadableError({ filePath: input.filePath, cause })),
272 );
273 if (info.type !== "File") {
274 return yield* new ThemeFileUnreadableError({ filePath: input.filePath });
275 }
276 if (Number(info.size) > MAX_THEME_FILE_BYTES) {
277 return yield* new ThemeFileTooLargeError({
278 filePath: input.filePath,
279 limit: MAX_THEME_FILE_BYTES,
280 });
281 }
282
283 const resolvedSource = yield* fs
284 .realPath(input.filePath)
285 .pipe(
286 Effect.mapError((cause) => new ThemeFileUnreadableError({ filePath: input.filePath, cause })),
287 );
288 const raw = readThemeFileGuarded(resolvedSource, MAX_THEME_FILE_BYTES);
289 if (raw === null) {
290 return yield* new ThemeFileUnreadableError({ filePath: input.filePath });
291 }
292
293 const decoded = decodeThemeFileJsonExit(raw);
294 if (decoded._tag === "Failure") {
295 return yield* new ThemeFileInvalidError({ filePath: input.filePath, cause: decoded.cause });
296 }
297 if (!environmentThemeFileHasColors(decoded.value)) {
298 return yield* new ThemeFileColorlessError({ filePath: input.filePath });
299 }
300
301 const fileBasename = path.basename(input.filePath, ".json");
302 const themeId = Option.getOrElse(input.explicitId, () => fileBasename);
303 // Match watcher validation so published files will not be skipped.
304 if (!isEnvironmentThemeId(themeId) || UNPUBLISHABLE_THEME_IDS.has(themeId)) {
305 return yield* new ThemeFileIdInvalidError({ themeId, filePath: input.filePath });
306 }
307
308 const destinationPath = path.join(input.themesDir, `${themeId}.json`);
309 // Non-JSON suffixes hide these from the watcher; PIDs isolate concurrent publishers.
310 const backupPath = `${destinationPath}.rollback-${process.pid}`;
311 const stagingPath = `${destinationPath}.staging-${process.pid}`;
312 yield* fs
313 .makeDirectory(input.themesDir, { recursive: true })
314 .pipe(Effect.mapError((cause) => new ThemePublishError({ themesDir: input.themesDir, cause })));
315
316 const publishFailure = (cause: unknown) =>
317 new ThemePublishError({ themesDir: input.themesDir, cause });
318
319 // O_EXCL prevents following a symlink at the predictable staging path.
320 // Preserve bytes exactly: even an added newline could exceed the watcher's size limit.
321 const stagedIno = yield* Effect.try({
322 try: () => {
323 try {
324 NodeFS.unlinkSync(stagingPath);
325 } catch {}
326 const fd = NodeFS.openSync(
327 stagingPath,
328 NodeFS.constants.O_WRONLY | NodeFS.constants.O_CREAT | NodeFS.constants.O_EXCL,
329 0o644,
330 );
331 try {
332 NodeFS.writeFileSync(fd, raw);
333 return NodeFS.fstatSync(fd).ino;
334 } finally {
335 NodeFS.closeSync(fd);
336 }
337 },
338 catch: publishFailure,
339 });
340
341 // Atomic rename avoids a check/replace race and preserves the exact entry for rollback.
342 const hadPrevious = yield* Effect.try({
343 try: () => {
344 try {
345 NodeFS.renameSync(destinationPath, backupPath);
346 return true;
347 } catch (error) {
348 if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
349 throw error;
350 }
351 },
352 catch: publishFailure,
353 });
354
355 const revert = Effect.sync(() => {
356 try {
357 NodeFS.unlinkSync(stagingPath);
358 } catch {}
359 try {
360 // Only replace this process's inode or an empty destination; concurrent publishers win.
361 const destinationIno = (() => {
362 try {
363 return NodeFS.lstatSync(destinationPath).ino;
364 } catch {
365 return null;
366 }
367 })();
368 if (hadPrevious) {
369 if (destinationIno === null || destinationIno === stagedIno) {
370 NodeFS.renameSync(backupPath, destinationPath);
371 } else {
372 NodeFS.unlinkSync(backupPath);
373 }
374 } else if (destinationIno === stagedIno) {
375 NodeFS.unlinkSync(destinationPath);
376 }
377 } catch {}
378 });
379 const cleanup = Effect.sync(() => {
380 try {
381 if (hadPrevious) NodeFS.unlinkSync(backupPath);
382 } catch {}
383 });
384
385 yield* Effect.try({
386 try: () => NodeFS.renameSync(stagingPath, destinationPath),
387 catch: publishFailure,
388 }).pipe(Effect.onError(() => revert));
389
390 return { themeId, revert, cleanup };
391});
392
393/** Web and desktop cannot resolve the mobile default, and mobile does not follow this setting. */
394const resolvableThemeIds = Effect.fn(function* (themesDir: string) {
395 const published = yield* readPublishedThemes(themesDir);
396 return [...BUILT_IN_THEME_IDS, ...published.map((theme) => theme.id)].toSorted();
397});
398
399const themeSetCommand = Command.make("set", {
400 baseDir: baseDirFlag,
401 id: Flag.String("id").pipe(
402 Flag.withDescription("Theme id to publish a file under, instead of its filename."),
403 Flag.optional,
404 ),
405 theme: Argument.String("theme").pipe(
406 Argument.withDescription(
407 'A theme id (a built-in, or one this machine publishes, where themes/nightfall.json is "nightfall"), or a path to a theme JSON file to publish and set in one step.',
408 ),
409 ),
410}).pipe(
411 Command.withDescription("Set the environment's theme; connected clients switch to it."),
412 Command.withHandler((flags) =>
413 Effect.gen(function* () {
414 const fs = yield* FileSystem.FileSystem;
415 const target = yield* expandHomePath(flags.theme.trim());
416 if (target.length === 0) {
417 return yield* new ThemeTargetMissingError();
418 }
419 const paths = yield* resolveThemePaths(flags.baseDir);
420
421 // A bare theme id must not publish a same-named file that happens to exist in cwd.
422 const looksLikePath =
423 target.endsWith(".json") ||
424 target.includes("/") ||
425 target.includes("\\") ||
426 target.startsWith("~");
427 const targetIsFile =
428 looksLikePath && (yield* fs.exists(target).pipe(Effect.orElseSucceed(() => false)));
429 let themeId: string;
430 let revertPublish: Effect.Effect<void> = Effect.void;
431 let cleanupPublish: Effect.Effect<void> = Effect.void;
432 if (targetIsFile) {
433 yield* readSettingsObject(paths.settingsPath);
434 const published = yield* publishThemeFile({
435 themesDir: paths.themesDir,
436 filePath: target,
437 explicitId: flags.id,
438 });
439 themeId = published.themeId;
440 revertPublish = published.revert;
441 cleanupPublish = published.cleanup;
442 } else if (looksLikePath) {
443 return yield* new ThemeFileUnreadableError({ filePath: target });
444 } else if (isEnvironmentThemeId(target)) {
445 const known = yield* resolvableThemeIds(paths.themesDir);
446 if (!known.includes(target)) {
447 return yield* new ThemeIdUnknownError({ themeId: target, known });
448 }
449 themeId = target;
450 } else {
451 return yield* new ThemeIdInvalidError({ themeId: target });
452 }
453
454 yield* writeDefaultTheme({ settingsPath: paths.settingsPath, themeId }).pipe(
455 Effect.onError(() => revertPublish),
456 );
457 yield* cleanupPublish;
458 yield* Console.log(
459 targetIsFile
460 ? `Published ${target} as "${themeId}" and set it as the environment theme.\n`
461 : `Environment theme set to "${themeId}" in ${paths.settingsPath}.\n`,
462 );
463 }),
464 ),
465);
466
467const themeClearCommand = Command.make("clear", { baseDir: baseDirFlag }).pipe(
468 Command.withDescription("Remove the environment's theme; clients keep what they have."),
469 Command.withHandler((flags) =>
470 Effect.gen(function* () {
471 const paths = yield* resolveThemePaths(flags.baseDir);
472 yield* writeDefaultTheme({ settingsPath: paths.settingsPath, themeId: "" });
473 yield* Console.log(`Environment theme cleared in ${paths.settingsPath}.\n`);
474 }),
475 ),
476);
477
478const themeShowCommand = Command.make("show", { baseDir: baseDirFlag }).pipe(
479 Command.withDescription("Show the environment's theme and its published themes."),
480 Command.withHandler((flags) =>
481 Effect.gen(function* () {
482 const paths = yield* resolveThemePaths(flags.baseDir);
483 const { settings } = yield* readSettingsObject(paths.settingsPath);
484 const defaultTheme =
485 typeof settings.defaultTheme === "string" && settings.defaultTheme.length > 0
486 ? settings.defaultTheme
487 : null;
488
489 const published = (yield* readPublishedThemes(paths.themesDir))
490 .map((theme) => theme.id)
491 .toSorted();
492
493 yield* Console.log(
494 defaultTheme === null
495 ? "Environment theme: not set.\n"
496 : `Environment theme: "${defaultTheme}".\n`,
497 );
498 yield* Console.log(
499 published.length === 0
500 ? `Published themes: none (publish into ${paths.themesDir}).\n`
501 : `Published themes: ${published.join(", ")}.\n`,
502 );
503 }),
504 ),
505);
506
507export const themeCommand = Command.make("theme").pipe(
508 Command.withDescription("Inspect and set environment-wide theme defaults."),
509 Command.withSubcommands([themeSetCommand, themeClearCommand, themeShowCommand]),
510);