| 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 | */ |
| 19 | import * as NodeFS from "node:fs"; |
| 20 | |
| 21 | import { |
| 22 | EnvironmentThemeFile, |
| 23 | EnvironmentThemeId, |
| 24 | environmentThemeFileHasColors, |
| 25 | } from "@t3tools/contracts"; |
| 26 | import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 27 | import { BUILT_IN_THEME_IDS, UNPUBLISHABLE_THEME_IDS } from "@t3tools/shared/themePalettes"; |
| 28 | import * as Config from "effect/Config"; |
| 29 | import * as Console from "effect/Console"; |
| 30 | import * as DateTime from "effect/DateTime"; |
| 31 | import * as Effect from "effect/Effect"; |
| 32 | import * as FileSystem from "effect/FileSystem"; |
| 33 | import * as Option from "effect/Option"; |
| 34 | import * as Path from "effect/Path"; |
| 35 | import * as Schema from "effect/Schema"; |
| 36 | import { Argument, Command, Flag } from "effect/cli"; |
| 37 | |
| 38 | import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; |
| 39 | import * as ServerConfig from "../config.ts"; |
| 40 | import { |
| 41 | MAX_THEME_FILE_BYTES, |
| 42 | readPublishedThemes, |
| 43 | readThemeFileGuarded, |
| 44 | } from "../environmentTheme.ts"; |
| 45 | import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; |
| 46 | import { 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. */ |
| 50 | const SparseSettings = Schema.Record(Schema.String, Schema.Unknown); |
| 51 | const decodeSettingsJson = Schema.decodeUnknownEffect(fromLenientJson(SparseSettings)); |
| 52 | const encodeSettingsJson = Schema.encodeEffect(fromJsonStringPretty(SparseSettings)); |
| 53 | const decodeThemeFileJsonExit = Schema.decodeUnknownExit( |
| 54 | Schema.fromJsonString(EnvironmentThemeFile), |
| 55 | ); |
| 56 | const isEnvironmentThemeId = Schema.is(EnvironmentThemeId); |
| 57 | |
| 58 | export 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 | |
| 67 | export 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 | |
| 76 | export 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 | |
| 85 | export 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 | |
| 94 | export 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 | |
| 105 | export 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 | |
| 114 | export 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 | |
| 123 | export 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 | |
| 132 | export 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 | |
| 141 | const INVALID_THEME_ID_REASON = |
| 142 | "is not a valid theme id (lowercase letters, digits, and hyphens; not an appearance keyword)"; |
| 143 | |
| 144 | export 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 | |
| 153 | export 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. */ |
| 163 | export 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 | |
| 172 | export 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 | |
| 181 | const envT3Home = Config.String("T3CODE_HOME").pipe(Config.option); |
| 182 | |
| 183 | const 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 | */ |
| 204 | const 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 | */ |
| 231 | const CONCURRENT_WRITE_ATTEMPTS = 5; |
| 232 | |
| 233 | const 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. */ |
| 285 | const 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 | */ |
| 456 | const 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 | |
| 461 | const 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 | |
| 541 | const 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 | |
| 552 | const 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 | |
| 581 | export const themeCommand = Command.make("theme").pipe( |
| 582 | Command.withDescription("Inspect and set environment-wide theme defaults."), |
| 583 | Command.withSubcommands([themeSetCommand, themeClearCommand, themeShowCommand]), |
| 584 | ); |