From 2a81519641305d876b6bab927769acd360fde2a5 Mon Sep 17 00:00:00 2001 From: Michael Marszalek Date: Thu, 5 Feb 2026 12:44:20 +0100 Subject: [PATCH 01/14] fix: dir in config are relative to config location --- packages/cli/bin/designsystemet.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/cli/bin/designsystemet.ts b/packages/cli/bin/designsystemet.ts index d5e236799c..ed201da394 100644 --- a/packages/cli/bin/designsystemet.ts +++ b/packages/cli/bin/designsystemet.ts @@ -51,9 +51,7 @@ function makeTokenCommands() { .option('--experimental-tailwind', 'Generate Tailwind CSS classes for tokens', false) .action(async (opts) => { console.log(figletAscii); - const { verbose, clean, dry, experimentalTailwind } = opts; - const tokensDir = typeof opts.tokens === 'string' ? opts.tokens : DEFAULT_TOKENS_CREATE_DIR; - const outDir = typeof opts.outDir === 'string' ? opts.outDir : './dist/tokens'; + const { verbose, clean, dry, experimentalTailwind, outDir, tokens } = opts; const { configFile, configPath } = await getConfigFile(opts.config); const config = await parseBuildConfig(configFile, { configPath }); @@ -66,7 +64,7 @@ function makeTokenCommands() { await cleanDir(outDir, dry); } - await buildTokens({ tokensDir, outDir, verbose, dry, tailwind: experimentalTailwind, ...config }); + await buildTokens({ tokensDir: tokens, outDir, verbose, dry, tailwind: experimentalTailwind, ...config }); return Promise.resolve(); }); From 69667503644f75668121c53dca11d06778c79ee5 Mon Sep 17 00:00:00 2001 From: Michael Marszalek Date: Fri, 6 Feb 2026 16:15:03 +0100 Subject: [PATCH 02/14] making resolving working dir in a single place --- packages/cli/bin/config.ts | 7 ++-- packages/cli/bin/designsystemet.ts | 34 ++++++++++++++++---- packages/cli/configs/test-tokens.config.json | 2 +- packages/cli/src/tokens/create/write.ts | 14 ++++---- 4 files changed, 38 insertions(+), 19 deletions(-) diff --git a/packages/cli/bin/config.ts b/packages/cli/bin/config.ts index 1f43c54caa..8246229587 100644 --- a/packages/cli/bin/config.ts +++ b/packages/cli/bin/config.ts @@ -14,21 +14,20 @@ import { readFile } from '../src/utils.js'; import { getCliOption, getDefaultCliOption, getSuppliedCliOption, type OptionGetter } from './options.js'; export async function readConfigFile(configPath: string, allowFileNotFound = true): Promise { - const resolvedPath = path.resolve(process.cwd(), configPath); let configFile: string; try { - configFile = await readFile(resolvedPath, false, allowFileNotFound); + configFile = await readFile(configPath, false, allowFileNotFound); } catch (err) { if (allowFileNotFound) { return ''; } - console.error(pc.redBright(`Could not read config file at ${pc.blue(resolvedPath)}`)); + console.error(pc.redBright(`Could not read config file at ${pc.blue(configPath)}`)); throw err; } if (configFile) { - console.log(`Found config file: ${pc.green(resolvedPath)}`); + console.log(`Found config file: ${pc.green(configPath)}`); } return configFile; diff --git a/packages/cli/bin/designsystemet.ts b/packages/cli/bin/designsystemet.ts index ed201da394..fc599af7b3 100644 --- a/packages/cli/bin/designsystemet.ts +++ b/packages/cli/bin/designsystemet.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import path from 'node:path'; import { Argument, createCommand, program } from '@commander-js/extra-typings'; import pc from 'picocolors'; import * as R from 'ramda'; @@ -53,18 +54,30 @@ function makeTokenCommands() { console.log(figletAscii); const { verbose, clean, dry, experimentalTailwind, outDir, tokens } = opts; - const { configFile, configPath } = await getConfigFile(opts.config); + const configFilePath = opts.config; + const workingDir = configFilePath ? path.dirname(configFilePath) : process.cwd(); + + const { configFile, configPath } = await getConfigFile(configFilePath); const config = await parseBuildConfig(configFile, { configPath }); + const writeDir = path.resolve(workingDir, outDir); + if (dry) { console.log(`Performing dry run, no files will be written`); } if (clean) { - await cleanDir(outDir, dry); + await cleanDir(writeDir, dry); } - await buildTokens({ tokensDir: tokens, outDir, verbose, dry, tailwind: experimentalTailwind, ...config }); + await buildTokens({ + tokensDir: tokens, + outDir: writeDir, + verbose, + dry, + tailwind: experimentalTailwind, + ...config, + }); return Promise.resolve(); }); @@ -96,17 +109,23 @@ function makeTokenCommands() { if (opts.dry) { console.log(`Performing dry run, no files will be written`); } + const themeName = opts.theme; + const configFilePath = opts.config; + const workingDir = configFilePath ? path.dirname(configFilePath) : process.cwd(); - const { configFile, configPath } = await getConfigFile(opts.config); + const { configFile, configPath } = await getConfigFile(configFilePath); const config = await parseCreateConfig(configFile, { - theme: opts.theme, + theme: themeName, cmd, configPath, }); + const writeDir = path.resolve(workingDir, config.outDir); + if (config.clean) { - await cleanDir(config.outDir, opts.dry); + await cleanDir(writeDir, opts.dry); } + /* * Create and write tokens for each theme */ @@ -116,7 +135,7 @@ function makeTokenCommands() { const theme = { name, ...themeWithoutName } as Theme; const { tokenSets } = await createTokens(theme); - await writeTokens({ outDir: config.outDir, theme, dry: opts.dry, tokenSets }); + await writeTokens({ outDir: writeDir, theme, dry: opts.dry, tokenSets }); } } }); @@ -203,5 +222,6 @@ async function getConfigFile(config: string | undefined) { const allowFileNotFound = R.isNil(config) || config === DEFAULT_CONFIG_FILE; const configPath = config ?? DEFAULT_CONFIG_FILE; const configFile = await readConfigFile(configPath, allowFileNotFound); + return { configFile, configPath }; } diff --git a/packages/cli/configs/test-tokens.config.json b/packages/cli/configs/test-tokens.config.json index 5d1b5f9201..b04c9ebbec 100644 --- a/packages/cli/configs/test-tokens.config.json +++ b/packages/cli/configs/test-tokens.config.json @@ -1,6 +1,6 @@ { "$schema": "../dist/config.schema.json", - "outDir": "./temp/config/design-tokens", + "outDir": "../temp/config/design-tokens", "clean": true, "themes": { "some-org": { diff --git a/packages/cli/src/tokens/create/write.ts b/packages/cli/src/tokens/create/write.ts index 0eacb60015..5d2a186cba 100644 --- a/packages/cli/src/tokens/create/write.ts +++ b/packages/cli/src/tokens/create/write.ts @@ -25,14 +25,14 @@ export const writeTokens = async (options: WriteTokensOptions) => { theme: { name: themeName, colors }, dry, } = options; - const targetDir = path.resolve(process.cwd(), String(outDir)); - const $themesPath = path.join(targetDir, '$themes.json'); - const $metadataPath = path.join(targetDir, '$metadata.json'); - const $designsystemetPath = path.join(targetDir, '$designsystemet.jsonc'); + + const $themesPath = path.join(outDir, '$themes.json'); + const $metadataPath = path.join(outDir, '$metadata.json'); + const $designsystemetPath = path.join(outDir, '$designsystemet.jsonc'); let themeObjects: ThemeObject[] = []; const sizeModes: SizeModes[] = ['small', 'medium', 'large']; - await mkdir(targetDir, dry); + await mkdir(outDir, dry); try { // Fetch existing themes @@ -65,10 +65,10 @@ export const writeTokens = async (options: WriteTokensOptions) => { for (const [set, tokens] of tokenSets) { // Remove last part of the path to get the directory - const fileDir = path.join(targetDir, path.dirname(set)); + const fileDir = path.join(outDir, path.dirname(set)); await mkdir(fileDir, dry); - const filePath = path.join(targetDir, `${set}.json`); + const filePath = path.join(outDir, `${set}.json`); await writeFile(filePath, stringify(tokens), dry); } From 6dfcbbd7e5802c46c45d86ed2491508a85d18760 Mon Sep 17 00:00:00 2001 From: Michael Marszalek Date: Fri, 6 Feb 2026 16:34:01 +0100 Subject: [PATCH 03/14] added filesystem class --- packages/cli/src/utils.ts | 99 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index 0e93f35ae8..0c99590551 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -2,6 +2,105 @@ import type { CopyOptions } from 'node:fs'; import fs from 'node:fs/promises'; import pc from 'picocolors'; +/** + * An abstraction of Node's file system API which allows dry-running destructive operations + */ +export class FileSystem { + private dry: boolean; + workingDir: string = process.cwd(); + + constructor( + /** Dry-run destructive operations instead of actually performing them */ + dry = false, + ) { + this.dry = dry; + } + + /** + * Creates a directory if it does not already exist. + * + * @param dir - The path of the directory to create. + * + * @returns A promise that resolves when the operation is complete. + * If the directory already exists or `dry` is `true`, the promise resolves immediately. + */ + mkdir = async (dir: string) => { + if (this.dry) { + console.log(`${pc.blue('mkdir')} ${dir}`); + return Promise.resolve(); + } + + const exists = await fs + .access(dir, fs.constants.F_OK) + .then(() => true) + .catch(() => false); + + if (exists) { + return Promise.resolve(); + } + + return fs.mkdir(dir, { recursive: true }); + }; + + writeFile = async (path: string, data: string) => { + if (this.dry) { + console.log(`${pc.blue('writeFile')} ${path}`); + return Promise.resolve(); + } + + return fs.writeFile(path, data, { encoding: 'utf-8' }).catch((error) => { + console.error(pc.red(`Error writing file: ${path}`)); + console.error(pc.red(error)); + throw error; + }); + }; + + cp = async (src: string, dest: string, filter?: CopyOptions['filter']) => { + if (this.dry) { + console.log(`${pc.blue('cp')} ${src} ${dest}`); + return Promise.resolve(); + } + + return fs.cp(src, dest, { recursive: true, filter }); + }; + + copyFile = async (src: string, dest: string) => { + if (this.dry) { + console.log(`${pc.blue('copyFile')} ${src} to ${dest}`); + return Promise.resolve(); + } + + return fs.copyFile(src, dest); + }; + + cleanDir = async (dir: string) => { + if (this.dry) { + console.log(`${pc.blue('cleanDir')} ${dir}`); + return Promise.resolve(); + } + + console.log(`\n🔥 Cleaning dir ${pc.red(`${dir.trim()}`)} `); + + return fs.rm(dir, { recursive: true, force: true }); + }; + + readFile = async (path: string, allowFileNotFound?: boolean) => { + if (this.dry) { + console.log(`${pc.blue('readFile')} ${path}`); + return Promise.resolve(''); + } + + try { + return await fs.readFile(path, 'utf-8'); + } catch (error) { + if (allowFileNotFound && (error as NodeJS.ErrnoException).code === 'ENOENT') { + return ''; + } + throw error; + } + }; +} + /** * Creates a directory if it does not already exist. * From 63962212d3dabbb164c454aa9728c0502f307be3 Mon Sep 17 00:00:00 2001 From: Michael Marszalek Date: Mon, 9 Feb 2026 10:04:38 +0100 Subject: [PATCH 04/14] introduce filesystem singleton for better file system handling in CLI --- packages/cli/bin/config.ts | 5 +- packages/cli/bin/designsystemet.ts | 19 +- .../cli/src/migrations/codemods/css/run.ts | 10 +- .../cli/src/scripts/update-preview-tokens.ts | 11 +- packages/cli/src/tokens/build.ts | 22 +- packages/cli/src/tokens/create/write.ts | 19 +- packages/cli/src/tokens/generate-config.ts | 11 +- packages/cli/src/tokens/process/platform.ts | 2 - packages/cli/src/utils.ts | 188 ------------------ packages/cli/src/utils/filesystem.ts | 132 ++++++++++++ 10 files changed, 171 insertions(+), 248 deletions(-) delete mode 100644 packages/cli/src/utils.ts create mode 100644 packages/cli/src/utils/filesystem.ts diff --git a/packages/cli/bin/config.ts b/packages/cli/bin/config.ts index 8246229587..1771befb5c 100644 --- a/packages/cli/bin/config.ts +++ b/packages/cli/bin/config.ts @@ -1,4 +1,3 @@ -import path from 'node:path'; import type { Command, OptionValues } from '@commander-js/extra-typings'; import pc from 'picocolors'; import * as R from 'ramda'; @@ -10,14 +9,14 @@ import { parseConfig, validateConfig, } from '../src/config.js'; -import { readFile } from '../src/utils.js'; +import fs from '../src/utils/filesystem.js'; import { getCliOption, getDefaultCliOption, getSuppliedCliOption, type OptionGetter } from './options.js'; export async function readConfigFile(configPath: string, allowFileNotFound = true): Promise { let configFile: string; try { - configFile = await readFile(configPath, false, allowFileNotFound); + configFile = await fs.readFile(configPath, allowFileNotFound); } catch (err) { if (allowFileNotFound) { return ''; diff --git a/packages/cli/bin/designsystemet.ts b/packages/cli/bin/designsystemet.ts index fc599af7b3..3b75ee8db6 100644 --- a/packages/cli/bin/designsystemet.ts +++ b/packages/cli/bin/designsystemet.ts @@ -11,7 +11,7 @@ import { writeTokens } from '../src/tokens/create/write.js'; import { cliOptions, createTokens } from '../src/tokens/create.js'; import { generateConfigFromTokens } from '../src/tokens/generate-config.js'; import type { Theme } from '../src/tokens/types.js'; -import { cleanDir } from '../src/utils.js'; +import fs from '../src/utils/filesystem.js'; import { parseBuildConfig, parseCreateConfig, readConfigFile } from './config.js'; export const figletAscii = ` @@ -60,21 +60,17 @@ function makeTokenCommands() { const { configFile, configPath } = await getConfigFile(configFilePath); const config = await parseBuildConfig(configFile, { configPath }); - const writeDir = path.resolve(workingDir, outDir); - - if (dry) { - console.log(`Performing dry run, no files will be written`); - } + const writeDir = path.resolve(workingDir, outDir); // TODO read output directory from config or CLI options + fs.init({ dry, writeDir }); if (clean) { - await cleanDir(writeDir, dry); + await fs.cleanDir(writeDir); } await buildTokens({ tokensDir: tokens, outDir: writeDir, verbose, - dry, tailwind: experimentalTailwind, ...config, }); @@ -119,11 +115,12 @@ function makeTokenCommands() { cmd, configPath, }); - const writeDir = path.resolve(workingDir, config.outDir); + fs.init({ dry: opts.dry, writeDir }); + if (config.clean) { - await cleanDir(writeDir, opts.dry); + await fs.cleanDir(writeDir); } /* @@ -135,7 +132,7 @@ function makeTokenCommands() { const theme = { name, ...themeWithoutName } as Theme; const { tokenSets } = await createTokens(theme); - await writeTokens({ outDir: writeDir, theme, dry: opts.dry, tokenSets }); + await writeTokens({ outDir: writeDir, theme, tokenSets }); } } }); diff --git a/packages/cli/src/migrations/codemods/css/run.ts b/packages/cli/src/migrations/codemods/css/run.ts index 482893cf1f..73b36210c0 100644 --- a/packages/cli/src/migrations/codemods/css/run.ts +++ b/packages/cli/src/migrations/codemods/css/run.ts @@ -1,9 +1,7 @@ -import fs from 'node:fs'; - import glob from 'fast-glob'; import type { AcceptedPlugin } from 'postcss'; import postcss from 'postcss'; -import { readFile } from '../../../utils.js'; +import fs from '../../../../src/utils/filesystem.js'; type CssCodemodProps = { plugins: AcceptedPlugin[]; @@ -25,10 +23,10 @@ export const runCssCodemod = async ({ plugins = [], globPattern = './**/*.css' } // console.log(`Skipping ${file}`); return; } - const contents = readFile(file).toString(); - const result = await processor.process(contents, { from: file }); + const contents = await fs.readFile(file); + const result = await processor.process(contents.toString(), { from: file }); - fs.writeFileSync(file, result.css); + await fs.writeFile(file, result.css); }); await Promise.all(filePromises); diff --git a/packages/cli/src/scripts/update-preview-tokens.ts b/packages/cli/src/scripts/update-preview-tokens.ts index 24615c5e6a..897d518656 100644 --- a/packages/cli/src/scripts/update-preview-tokens.ts +++ b/packages/cli/src/scripts/update-preview-tokens.ts @@ -7,11 +7,11 @@ import { createTokens } from '../tokens/create.js'; import { buildOptions, processPlatform } from '../tokens/process/platform.js'; import { processThemeObject } from '../tokens/process/utils/getMultidimensionalThemes.js'; import type { OutputFile, SizeModes, Theme } from '../tokens/types.js'; -import { cleanDir, mkdir, writeFile } from '../utils.js'; +import fs from '../utils/filesystem.js'; const OUTDIR = '../../internal/components/src/tokens/design-tokens'; -async function write(files: OutputFile[], outDir: string, dry?: boolean) { +async function write(files: OutputFile[], outDir: string) { for (const { destination, output } of files) { if (destination) { const filePath = path.join(outDir, destination); @@ -19,8 +19,8 @@ async function write(files: OutputFile[], outDir: string, dry?: boolean) { console.log(`Writing file: ${pc.green(filePath)}`); - await mkdir(fileDir, dry); - await writeFile(filePath, output, dry); + await fs.mkdir(fileDir); + await fs.writeFile(filePath, output); } } } @@ -55,7 +55,7 @@ export const formatTheme = async (themeConfig: Theme) => { buildTokenFormats: {}, }); - await cleanDir(OUTDIR, false); + await fs.cleanDir(OUTDIR); console.log( buildOptions?.buildTokenFormats @@ -103,7 +103,6 @@ export const formatTheme = async (themeConfig: Theme) => { }, ], OUTDIR, - false, ); } console.log(`\n✅ Finished building preview tokens for ${pc.blue('Designsystemet')}`); diff --git a/packages/cli/src/tokens/build.ts b/packages/cli/src/tokens/build.ts index 29c258e5e7..2618154cba 100644 --- a/packages/cli/src/tokens/build.ts +++ b/packages/cli/src/tokens/build.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import type { ThemeObject } from '@tokens-studio/types'; import pc from 'picocolors'; import * as R from 'ramda'; -import { mkdir, readFile, writeFile } from '../utils.js'; +import fs from '../utils/filesystem.js'; import { createTypeDeclarationFiles } from './process/output/declarations.js'; import { createTailwindCSSFiles } from './process/output/tailwind.js'; import { createThemeCSSFiles, defaultFileHeader } from './process/output/theme.js'; @@ -10,7 +10,7 @@ import { type BuildOptions, processPlatform } from './process/platform.js'; import { processThemeObject } from './process/utils/getMultidimensionalThemes.js'; import type { DesignsystemetObject, OutputFile } from './types.js'; -async function write(files: OutputFile[], outDir: string, dry?: boolean) { +async function write(files: OutputFile[], outDir: string) { for (const { destination, output } of files) { if (destination) { const filePath = path.join(outDir, destination); @@ -18,8 +18,8 @@ async function write(files: OutputFile[], outDir: string, dry?: boolean) { console.log(destination); - await mkdir(fileDir, dry); - await writeFile(filePath, output, dry); + await fs.mkdir(fileDir); + await fs.writeFile(filePath, output); } } } @@ -27,12 +27,12 @@ async function write(files: OutputFile[], outDir: string, dry?: boolean) { export const buildTokens = async (options: Omit) => { const outDir = path.resolve(options.outDir); const tokensDir = path.resolve(options.tokensDir); - const $themes = JSON.parse(await readFile(`${tokensDir}/$themes.json`)) as ThemeObject[]; + const $themes = JSON.parse(await fs.readFile(`${tokensDir}/$themes.json`)) as ThemeObject[]; const processed$themes = $themes.map(processThemeObject); let $designsystemet: DesignsystemetObject | undefined; try { - const $designsystemetContent = await readFile(`${tokensDir}/$designsystemet.jsonc`); + const $designsystemetContent = await fs.readFile(`${tokensDir}/$designsystemet.jsonc`); $designsystemet = JSON.parse($designsystemetContent) as DesignsystemetObject; } catch (_error) {} @@ -47,14 +47,6 @@ export const buildTokens = async (options: Omit JSON.stringify(data, null, 2); type WriteTokensOptions = { outDir: string; theme: Theme; - /** Dry run, no files will be written */ - dry?: boolean; tokenSets: TokenSets; }; @@ -23,7 +21,6 @@ export const writeTokens = async (options: WriteTokensOptions) => { outDir, tokenSets, theme: { name: themeName, colors }, - dry, } = options; const $themesPath = path.join(outDir, '$themes.json'); @@ -32,11 +29,11 @@ export const writeTokens = async (options: WriteTokensOptions) => { let themeObjects: ThemeObject[] = []; const sizeModes: SizeModes[] = ['small', 'medium', 'large']; - await mkdir(outDir, dry); + await fs.mkdir(outDir); try { // Fetch existing themes - const $themes = await readFile($themesPath); + const $themes = await fs.readFile($themesPath); if ($themes) { themeObjects = JSON.parse($themes) as ThemeObject[]; } @@ -59,17 +56,17 @@ export const writeTokens = async (options: WriteTokensOptions) => { const $metadata = generate$Metadata(['dark', 'light'], themes, colors, sizeModes); const $designsystemet = generate$Designsystemet(); - await writeFile($themesPath, stringify($themes), dry); - await writeFile($metadataPath, stringify($metadata), dry); - await writeFile($designsystemetPath, stringify($designsystemet), dry); + await fs.writeFile($themesPath, stringify($themes)); + await fs.writeFile($metadataPath, stringify($metadata)); + await fs.writeFile($designsystemetPath, stringify($designsystemet)); for (const [set, tokens] of tokenSets) { // Remove last part of the path to get the directory const fileDir = path.join(outDir, path.dirname(set)); - await mkdir(fileDir, dry); + await fs.mkdir(fileDir); const filePath = path.join(outDir, `${set}.json`); - await writeFile(filePath, stringify(tokens), dry); + await fs.writeFile(filePath, stringify(tokens)); } console.log(`Finished creating Designsystem design tokens in ${pc.green(outDir)} for theme ${pc.blue(themeName)}`); diff --git a/packages/cli/src/tokens/generate-config.ts b/packages/cli/src/tokens/generate-config.ts index f8d244d46a..25e29e5882 100644 --- a/packages/cli/src/tokens/generate-config.ts +++ b/packages/cli/src/tokens/generate-config.ts @@ -1,8 +1,8 @@ -import fs from 'node:fs/promises'; import path from 'node:path'; import pc from 'picocolors'; import type { CssColor } from '../colors/types.js'; import type { CreateConfigSchema } from '../config.js'; +import fs from '../utils/filesystem.js'; type TokenValue = { $type: string; @@ -18,7 +18,7 @@ type TokenObject = { */ async function readJsonFile(filePath: string): Promise { try { - const content = await fs.readFile(filePath, 'utf-8'); + const content = await fs.readFile(filePath); return JSON.parse(content) as TokenObject; } catch (err) { throw new Error(`Failed to read token file at ${filePath}: ${err instanceof Error ? err.message : String(err)}`); @@ -209,14 +209,13 @@ function categorizeColors( export type GenerateConfigOptions = { tokensDir: string; outFile?: string; - dry?: boolean; }; /** * Generates a config file from existing design tokens */ export async function generateConfigFromTokens(options: GenerateConfigOptions): Promise { - const { tokensDir, dry = false } = options; + const { tokensDir } = options; console.log(`\nReading tokens from ${pc.blue(tokensDir)}`); @@ -291,9 +290,9 @@ export async function generateConfigFromTokens(options: GenerateConfigOptions): } } - if (!dry && options.outFile) { + if (options.outFile) { const configJson = JSON.stringify(config, null, 2); - await fs.writeFile(options.outFile, configJson, 'utf-8'); + await fs.writeFile(options.outFile, configJson); console.log(); console.log(`\n✅ Config file written to ${pc.blue(options.outFile)}`); } diff --git a/packages/cli/src/tokens/process/platform.ts b/packages/cli/src/tokens/process/platform.ts index 22ea78ace4..b38924ca4a 100644 --- a/packages/cli/src/tokens/process/platform.ts +++ b/packages/cli/src/tokens/process/platform.ts @@ -16,8 +16,6 @@ type SharedOptions = { defaultSize?: string; /** Set the available size modes */ sizeModes?: string[]; - /** Dry run, no files will be written */ - dry?: boolean; /** Token Studio `$themes.json` content */ processed$themes: ProcessedThemeObject[]; /** Color groups */ diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts deleted file mode 100644 index 0c99590551..0000000000 --- a/packages/cli/src/utils.ts +++ /dev/null @@ -1,188 +0,0 @@ -import type { CopyOptions } from 'node:fs'; -import fs from 'node:fs/promises'; -import pc from 'picocolors'; - -/** - * An abstraction of Node's file system API which allows dry-running destructive operations - */ -export class FileSystem { - private dry: boolean; - workingDir: string = process.cwd(); - - constructor( - /** Dry-run destructive operations instead of actually performing them */ - dry = false, - ) { - this.dry = dry; - } - - /** - * Creates a directory if it does not already exist. - * - * @param dir - The path of the directory to create. - * - * @returns A promise that resolves when the operation is complete. - * If the directory already exists or `dry` is `true`, the promise resolves immediately. - */ - mkdir = async (dir: string) => { - if (this.dry) { - console.log(`${pc.blue('mkdir')} ${dir}`); - return Promise.resolve(); - } - - const exists = await fs - .access(dir, fs.constants.F_OK) - .then(() => true) - .catch(() => false); - - if (exists) { - return Promise.resolve(); - } - - return fs.mkdir(dir, { recursive: true }); - }; - - writeFile = async (path: string, data: string) => { - if (this.dry) { - console.log(`${pc.blue('writeFile')} ${path}`); - return Promise.resolve(); - } - - return fs.writeFile(path, data, { encoding: 'utf-8' }).catch((error) => { - console.error(pc.red(`Error writing file: ${path}`)); - console.error(pc.red(error)); - throw error; - }); - }; - - cp = async (src: string, dest: string, filter?: CopyOptions['filter']) => { - if (this.dry) { - console.log(`${pc.blue('cp')} ${src} ${dest}`); - return Promise.resolve(); - } - - return fs.cp(src, dest, { recursive: true, filter }); - }; - - copyFile = async (src: string, dest: string) => { - if (this.dry) { - console.log(`${pc.blue('copyFile')} ${src} to ${dest}`); - return Promise.resolve(); - } - - return fs.copyFile(src, dest); - }; - - cleanDir = async (dir: string) => { - if (this.dry) { - console.log(`${pc.blue('cleanDir')} ${dir}`); - return Promise.resolve(); - } - - console.log(`\n🔥 Cleaning dir ${pc.red(`${dir.trim()}`)} `); - - return fs.rm(dir, { recursive: true, force: true }); - }; - - readFile = async (path: string, allowFileNotFound?: boolean) => { - if (this.dry) { - console.log(`${pc.blue('readFile')} ${path}`); - return Promise.resolve(''); - } - - try { - return await fs.readFile(path, 'utf-8'); - } catch (error) { - if (allowFileNotFound && (error as NodeJS.ErrnoException).code === 'ENOENT') { - return ''; - } - throw error; - } - }; -} - -/** - * Creates a directory if it does not already exist. - * - * @param dir - The path of the directory to create. - * @param dry - Optional. If `true`, the function will log the operation - * without actually creating the directory. - * - * @returns A promise that resolves when the operation is complete. - * If the directory already exists or `dry` is `true`, the promise resolves immediately. - */ -export const mkdir = async (dir: string, dry?: boolean) => { - if (dry) { - console.log(`${pc.blue('mkdir')} ${dir}`); - return Promise.resolve(); - } - - const exists = await fs - .access(dir, fs.constants.F_OK) - .then(() => true) - .catch(() => false); - - if (exists) { - return Promise.resolve(); - } - - return fs.mkdir(dir, { recursive: true }); -}; - -export const writeFile = async (path: string, data: string, dry?: boolean) => { - if (dry) { - console.log(`${pc.blue('writeFile')} ${path}`); - return Promise.resolve(); - } - - return fs.writeFile(path, data, { encoding: 'utf-8' }).catch((error) => { - console.error(pc.red(`Error writing file: ${path}`)); - console.error(pc.red(error)); - throw error; - }); -}; - -export const cp = async (src: string, dest: string, dry?: boolean, filter?: CopyOptions['filter']) => { - if (dry) { - console.log(`${pc.blue('cp')} ${src} ${dest}`); - return Promise.resolve(); - } - - return fs.cp(src, dest, { recursive: true, filter }); -}; - -export const copyFile = async (src: string, dest: string, dry?: boolean) => { - if (dry) { - console.log(`${pc.blue('copyFile')} ${src} to ${dest}`); - return Promise.resolve(); - } - - return fs.copyFile(src, dest); -}; - -export const cleanDir = async (dir: string, dry?: boolean) => { - if (dry) { - console.log(`${pc.blue('cleanDir')} ${dir}`); - return Promise.resolve(); - } - - console.log(`\n🔥 Cleaning dir ${pc.red(`${dir.trim()}`)} `); - - return fs.rm(dir, { recursive: true, force: true }); -}; - -export const readFile = async (path: string, dry?: boolean, allowFileNotFound?: boolean) => { - if (dry) { - console.log(`${pc.blue('readFile')} ${path}`); - return Promise.resolve(''); - } - - try { - return await fs.readFile(path, 'utf-8'); - } catch (error) { - if (allowFileNotFound && (error as NodeJS.ErrnoException).code === 'ENOENT') { - return ''; - } - throw error; - } -}; diff --git a/packages/cli/src/utils/filesystem.ts b/packages/cli/src/utils/filesystem.ts new file mode 100644 index 0000000000..0af638dbd2 --- /dev/null +++ b/packages/cli/src/utils/filesystem.ts @@ -0,0 +1,132 @@ +import type { CopyOptions } from 'node:fs'; +import fs from 'node:fs/promises'; +import pc from 'picocolors'; + +class FileSystem { + private isInitialized = false; + private dry = false; + /** Resolved write directory */ + writeDir = process.cwd(); + + /** Initialize the file system */ + init({ dry, writeDir }: { dry?: boolean; writeDir?: string }) { + if (this.isInitialized) { + console.warn(pc.yellow('FileSystem is already initialized. Ignoring subsequent init call.')); + return; + } + + if (dry) { + console.log(pc.blue('Initializing FileSystem in dry-run mode. No files will be written.')); + } + + this.dry = dry ?? false; + this.writeDir = writeDir ?? process.cwd(); + this.isInitialized = true; + } + + /** + * Creates a directory if it does not already exist. + * + * @param dir - The path of the directory to create. + * + * @returns A promise that resolves when the operation is complete. + * If the directory already exists or `dry` is `true`, the promise resolves immediately. + */ + mkdir = async (dir: string) => { + if (this.dry) { + console.log(`${pc.blue('mkdir')} ${dir}`); + return Promise.resolve(); + } + + const exists = await fs + .access(dir, fs.constants.F_OK) + .then(() => true) + .catch(() => false); + + if (exists) { + return Promise.resolve(); + } + + return fs.mkdir(dir, { recursive: true }); + }; + + writeFile = async (path: string, data: string) => { + if (this.dry) { + console.log(`${pc.blue('writeFile')} ${path}`); + return Promise.resolve(); + } + + return fs.writeFile(path, data, { encoding: 'utf-8' }).catch((error) => { + console.error(pc.red(`Error writing file: ${path}`)); + console.error(pc.red(error)); + throw error; + }); + }; + + cp = async (src: string, dest: string, filter?: CopyOptions['filter']) => { + if (this.dry) { + console.log(`${pc.blue('cp')} ${src} ${dest}`); + return Promise.resolve(); + } + + return fs.cp(src, dest, { recursive: true, filter }); + }; + + copyFile = async (src: string, dest: string) => { + if (this.dry) { + console.log(`${pc.blue('copyFile')} ${src} to ${dest}`); + return Promise.resolve(); + } + + return fs.copyFile(src, dest); + }; + + cleanDir = async (dir: string) => { + if (this.dry) { + console.log(`${pc.blue('cleanDir')} ${dir}`); + return Promise.resolve(); + } + + console.log(`\n🔥 Cleaning dir ${pc.red(`${dir.trim()}`)} `); + + return fs.rm(dir, { recursive: true, force: true }); + }; + + readFile = async (path: string, allowFileNotFound?: boolean) => { + if (this.dry) { + console.log(`${pc.blue('readFile')} ${path}`); + return Promise.resolve(''); + } + + try { + return await fs.readFile(path, 'utf-8'); + } catch (error) { + if (allowFileNotFound && (error as NodeJS.ErrnoException).code === 'ENOENT') { + return ''; + } + throw error; + } + }; + readdir = async (path: string) => { + if (this.dry) { + console.log(`${pc.blue('readdir')} ${path}`); + return Promise.resolve([]); + } + + try { + return await fs.readdir(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + throw error; + } + }; +} + +/** + * An abstraction of Node's file system API for how CLI should interact with Files system. + * + * Allows dry-running destructive operations, logging and store relevant file system state. + */ +export default new FileSystem(); From b0e0d92d4ed83112cc0a8d7e0a9857540cd50593 Mon Sep 17 00:00:00 2001 From: Michael Marszalek Date: Mon, 9 Feb 2026 16:34:48 +0100 Subject: [PATCH 05/14] minimizing file system interaction --- packages/cli/bin/designsystemet.ts | 37 +++++++++++++-------- packages/cli/src/tokens/build.ts | 35 ++++++++----------- packages/cli/src/tokens/create/write.ts | 2 -- packages/cli/src/tokens/process/platform.ts | 2 -- packages/cli/src/tokens/types.ts | 1 + 5 files changed, 39 insertions(+), 38 deletions(-) diff --git a/packages/cli/bin/designsystemet.ts b/packages/cli/bin/designsystemet.ts index 3b75ee8db6..8fc32fd96f 100644 --- a/packages/cli/bin/designsystemet.ts +++ b/packages/cli/bin/designsystemet.ts @@ -6,7 +6,7 @@ import * as R from 'ramda'; import { convertToHex } from '../src/colors/index.js'; import type { CssColor } from '../src/colors/types.js'; import migrations from '../src/migrations/index.js'; -import { buildTokens } from '../src/tokens/build.js'; +import { buildTokens, writeFiles } from '../src/tokens/build.js'; import { writeTokens } from '../src/tokens/create/write.js'; import { cliOptions, createTokens } from '../src/tokens/create.js'; import { generateConfigFromTokens } from '../src/tokens/generate-config.js'; @@ -52,7 +52,7 @@ function makeTokenCommands() { .option('--experimental-tailwind', 'Generate Tailwind CSS classes for tokens', false) .action(async (opts) => { console.log(figletAscii); - const { verbose, clean, dry, experimentalTailwind, outDir, tokens } = opts; + const { verbose, clean, dry, experimentalTailwind, tokens } = opts; const configFilePath = opts.config; const workingDir = configFilePath ? path.dirname(configFilePath) : process.cwd(); @@ -60,21 +60,27 @@ function makeTokenCommands() { const { configFile, configPath } = await getConfigFile(configFilePath); const config = await parseBuildConfig(configFile, { configPath }); - const writeDir = path.resolve(workingDir, outDir); // TODO read output directory from config or CLI options - fs.init({ dry, writeDir }); + const outDir = path.resolve(workingDir, opts.outDir); // TODO read output directory from config or CLI options + + fs.init({ dry }); if (clean) { - await fs.cleanDir(writeDir); + await fs.cleanDir(outDir); } - await buildTokens({ + const files = await buildTokens({ tokensDir: tokens, - outDir: writeDir, verbose, tailwind: experimentalTailwind, ...config, }); + console.log(`\n💾 Writing build to ${pc.green(outDir)}`); + + await writeFiles(files, outDir); + + console.log(`\n✅ Finished building tokens in ${pc.green(outDir)}`); + return Promise.resolve(); }); @@ -115,12 +121,12 @@ function makeTokenCommands() { cmd, configPath, }); - const writeDir = path.resolve(workingDir, config.outDir); - fs.init({ dry: opts.dry, writeDir }); + const outDir = path.resolve(workingDir, config.outDir); + fs.init({ dry: opts.dry }); if (config.clean) { - await fs.cleanDir(writeDir); + await fs.cleanDir(outDir); } /* @@ -132,9 +138,13 @@ function makeTokenCommands() { const theme = { name, ...themeWithoutName } as Theme; const { tokenSets } = await createTokens(theme); - await writeTokens({ outDir: writeDir, theme, tokenSets }); + await writeTokens({ outDir, theme, tokenSets }); } } + + console.log(`\n✅ Finished creating tokens in ${pc.green(outDir)} for theme: ${pc.blue(themeName)}`); + + return Promise.resolve(); }); return tokenCmd; @@ -154,11 +164,12 @@ program const tokensDir = typeof opts.dir === 'string' ? opts.dir : DEFAULT_TOKENS_CREATE_DIR; const outFile = typeof opts.out === 'string' ? opts.out : DEFAULT_CONFIG_FILE; + fs.init({ dry }); + try { const config = await generateConfigFromTokens({ tokensDir, - outFile: dry ? undefined : outFile, - dry, + outFile, }); if (dry) { diff --git a/packages/cli/src/tokens/build.ts b/packages/cli/src/tokens/build.ts index 2618154cba..621bdb7618 100644 --- a/packages/cli/src/tokens/build.ts +++ b/packages/cli/src/tokens/build.ts @@ -10,22 +10,7 @@ import { type BuildOptions, processPlatform } from './process/platform.js'; import { processThemeObject } from './process/utils/getMultidimensionalThemes.js'; import type { DesignsystemetObject, OutputFile } from './types.js'; -async function write(files: OutputFile[], outDir: string) { - for (const { destination, output } of files) { - if (destination) { - const filePath = path.join(outDir, destination); - const fileDir = path.dirname(filePath); - - console.log(destination); - - await fs.mkdir(fileDir); - await fs.writeFile(filePath, output); - } - } -} - export const buildTokens = async (options: Omit) => { - const outDir = path.resolve(options.outDir); const tokensDir = path.resolve(options.tokensDir); const $themes = JSON.parse(await fs.readFile(`${tokensDir}/$themes.json`)) as ThemeObject[]; const processed$themes = $themes.map(processThemeObject); @@ -40,7 +25,6 @@ export const buildTokens = async (options: Omit { const filePath = path.join(outDir, `${set}.json`); await fs.writeFile(filePath, stringify(tokens)); } - - console.log(`Finished creating Designsystem design tokens in ${pc.green(outDir)} for theme ${pc.blue(themeName)}`); }; diff --git a/packages/cli/src/tokens/process/platform.ts b/packages/cli/src/tokens/process/platform.ts index b38924ca4a..0872ef1a49 100644 --- a/packages/cli/src/tokens/process/platform.ts +++ b/packages/cli/src/tokens/process/platform.ts @@ -28,8 +28,6 @@ export type BuildOptions = { type: 'build'; /** Design tokens path */ tokensDir: string; - /** Output directory for built tokens */ - outDir: string; /** Tailwind CSS configuration */ tailwind?: boolean; } & SharedOptions; diff --git a/packages/cli/src/tokens/types.ts b/packages/cli/src/tokens/types.ts index 67a5db9919..fde2249900 100644 --- a/packages/cli/src/tokens/types.ts +++ b/packages/cli/src/tokens/types.ts @@ -70,6 +70,7 @@ export type BuildConfig = { export type SDConfigForThemePermutation = { permutation: ThemePermutation; config: SDConfig }; +/** This type is taken from Style Dictionary `formatPlatform` */ export type OutputFile = { output: string; destination: string; From 6d26ffe69271364f52b325e148680df809e0a6c2 Mon Sep 17 00:00:00 2001 From: Michael Marszalek Date: Tue, 10 Feb 2026 09:26:44 +0100 Subject: [PATCH 06/14] conformed io using new fs --- packages/cli/bin/designsystemet.ts | 25 ++++++++++------ packages/cli/src/tokens/create/write.ts | 28 +++++++++--------- packages/cli/src/tokens/generate-config.ts | 7 ----- packages/cli/src/tokens/types.ts | 3 +- packages/cli/src/utils/filesystem.ts | 33 ++++++++++++++++------ 5 files changed, 56 insertions(+), 40 deletions(-) diff --git a/packages/cli/bin/designsystemet.ts b/packages/cli/bin/designsystemet.ts index 8fc32fd96f..ab6ea58c36 100644 --- a/packages/cli/bin/designsystemet.ts +++ b/packages/cli/bin/designsystemet.ts @@ -6,11 +6,11 @@ import * as R from 'ramda'; import { convertToHex } from '../src/colors/index.js'; import type { CssColor } from '../src/colors/types.js'; import migrations from '../src/migrations/index.js'; -import { buildTokens, writeFiles } from '../src/tokens/build.js'; +import { buildTokens } from '../src/tokens/build.js'; import { writeTokens } from '../src/tokens/create/write.js'; import { cliOptions, createTokens } from '../src/tokens/create.js'; import { generateConfigFromTokens } from '../src/tokens/generate-config.js'; -import type { Theme } from '../src/tokens/types.js'; +import type { OutputFile, Theme } from '../src/tokens/types.js'; import fs from '../src/utils/filesystem.js'; import { parseBuildConfig, parseCreateConfig, readConfigFile } from './config.js'; @@ -77,7 +77,7 @@ function makeTokenCommands() { console.log(`\n💾 Writing build to ${pc.green(outDir)}`); - await writeFiles(files, outDir); + await fs.writeFiles(files, outDir, true); console.log(`\n✅ Finished building tokens in ${pc.green(outDir)}`); @@ -129,19 +129,19 @@ function makeTokenCommands() { await fs.cleanDir(outDir); } - /* - * Create and write tokens for each theme - */ + let files: OutputFile[] = []; if (config.themes) { for (const [name, themeWithoutName] of Object.entries(config.themes)) { // Casting as missing properties should be validated by `getDefaultOrExplicitOption` to default values const theme = { name, ...themeWithoutName } as Theme; const { tokenSets } = await createTokens(theme); - await writeTokens({ outDir, theme, tokenSets }); + files = files.concat(await writeTokens({ outDir, theme, tokenSets })); } } + await fs.writeFiles(files, outDir); + console.log(`\n✅ Finished creating tokens in ${pc.green(outDir)} for theme: ${pc.blue(themeName)}`); return Promise.resolve(); @@ -161,8 +161,8 @@ program .action(async (opts) => { console.log(figletAscii); const { dry } = opts; - const tokensDir = typeof opts.dir === 'string' ? opts.dir : DEFAULT_TOKENS_CREATE_DIR; - const outFile = typeof opts.out === 'string' ? opts.out : DEFAULT_CONFIG_FILE; + const tokensDir = path.resolve(opts.dir); + const outFile = path.resolve(opts.out); fs.init({ dry }); @@ -177,6 +177,13 @@ program console.log('Generated config (dry run):'); console.log(JSON.stringify(config, null, 2)); } + + if (outFile) { + const configJson = JSON.stringify(config, null, 2); + await fs.writeFile(outFile, configJson); + console.log(); + console.log(`\n✅ Config file written to ${pc.blue(outFile)}`); + } } catch (error) { console.error(pc.redBright('Error generating config:')); console.error(error instanceof Error ? error.message : String(error)); diff --git a/packages/cli/src/tokens/create/write.ts b/packages/cli/src/tokens/create/write.ts index c2ce97af3d..178539d8f5 100644 --- a/packages/cli/src/tokens/create/write.ts +++ b/packages/cli/src/tokens/create/write.ts @@ -3,7 +3,7 @@ import type { ThemeObject } from '@tokens-studio/types'; import pc from 'picocolors'; import * as R from 'ramda'; import fs from '../../utils/filesystem.js'; -import type { SizeModes, Theme, TokenSets } from '../types.js'; +import type { OutputFile, SizeModes, Theme, TokenSets } from '../types.js'; import { generate$Designsystemet } from './generators/$designsystemet.js'; import { generate$Metadata } from './generators/$metadata.js'; import { generate$Themes } from './generators/$themes.js'; @@ -23,9 +23,9 @@ export const writeTokens = async (options: WriteTokensOptions) => { theme: { name: themeName, colors }, } = options; - const $themesPath = path.join(outDir, '$themes.json'); - const $metadataPath = path.join(outDir, '$metadata.json'); - const $designsystemetPath = path.join(outDir, '$designsystemet.jsonc'); + const $themesPath = '$themes.json'; + const $metadataPath = '$metadata.json'; + const $designsystemetPath = '$designsystemet.jsonc'; let themeObjects: ThemeObject[] = []; const sizeModes: SizeModes[] = ['small', 'medium', 'large']; @@ -33,7 +33,7 @@ export const writeTokens = async (options: WriteTokensOptions) => { try { // Fetch existing themes - const $themes = await fs.readFile($themesPath); + const $themes = await fs.readFile(path.join(outDir, $themesPath)); if ($themes) { themeObjects = JSON.parse($themes) as ThemeObject[]; } @@ -56,16 +56,16 @@ export const writeTokens = async (options: WriteTokensOptions) => { const $metadata = generate$Metadata(['dark', 'light'], themes, colors, sizeModes); const $designsystemet = generate$Designsystemet(); - await fs.writeFile($themesPath, stringify($themes)); - await fs.writeFile($metadataPath, stringify($metadata)); - await fs.writeFile($designsystemetPath, stringify($designsystemet)); + const files: OutputFile[] = []; - for (const [set, tokens] of tokenSets) { - // Remove last part of the path to get the directory - const fileDir = path.join(outDir, path.dirname(set)); - await fs.mkdir(fileDir); + files.push({ destination: $themesPath, output: stringify($themes) }); + files.push({ destination: $metadataPath, output: stringify($metadata) }); + files.push({ destination: $designsystemetPath, output: stringify($designsystemet) }); - const filePath = path.join(outDir, `${set}.json`); - await fs.writeFile(filePath, stringify(tokens)); + for (const [set, tokens] of tokenSets) { + const filePath = `${set}.json`; + files.push({ destination: filePath, output: stringify(tokens) }); } + + return files; }; diff --git a/packages/cli/src/tokens/generate-config.ts b/packages/cli/src/tokens/generate-config.ts index 25e29e5882..d7c3c84f81 100644 --- a/packages/cli/src/tokens/generate-config.ts +++ b/packages/cli/src/tokens/generate-config.ts @@ -290,12 +290,5 @@ export async function generateConfigFromTokens(options: GenerateConfigOptions): } } - if (options.outFile) { - const configJson = JSON.stringify(config, null, 2); - await fs.writeFile(options.outFile, configJson); - console.log(); - console.log(`\n✅ Config file written to ${pc.blue(options.outFile)}`); - } - return config; } diff --git a/packages/cli/src/tokens/types.ts b/packages/cli/src/tokens/types.ts index fde2249900..731f818cc7 100644 --- a/packages/cli/src/tokens/types.ts +++ b/packages/cli/src/tokens/types.ts @@ -70,7 +70,8 @@ export type BuildConfig = { export type SDConfigForThemePermutation = { permutation: ThemePermutation; config: SDConfig }; -/** This type is taken from Style Dictionary `formatPlatform` */ +/** This type is taken from Style Dictionary `formatPlatform`. + * Metadata for a file output */ export type OutputFile = { output: string; destination: string; diff --git a/packages/cli/src/utils/filesystem.ts b/packages/cli/src/utils/filesystem.ts index 0af638dbd2..48cbbb189a 100644 --- a/packages/cli/src/utils/filesystem.ts +++ b/packages/cli/src/utils/filesystem.ts @@ -1,6 +1,8 @@ -import type { CopyOptions } from 'node:fs'; +import type { CopyOptions, PathLike } from 'node:fs'; import fs from 'node:fs/promises'; +import path from 'node:path'; import pc from 'picocolors'; +import type { OutputFile } from '../tokens/types.js'; class FileSystem { private isInitialized = false; @@ -32,7 +34,7 @@ class FileSystem { * @returns A promise that resolves when the operation is complete. * If the directory already exists or `dry` is `true`, the promise resolves immediately. */ - mkdir = async (dir: string) => { + mkdir = async (dir: PathLike) => { if (this.dry) { console.log(`${pc.blue('mkdir')} ${dir}`); return Promise.resolve(); @@ -50,7 +52,7 @@ class FileSystem { return fs.mkdir(dir, { recursive: true }); }; - writeFile = async (path: string, data: string) => { + writeFile = async (path: PathLike, data: string) => { if (this.dry) { console.log(`${pc.blue('writeFile')} ${path}`); return Promise.resolve(); @@ -72,7 +74,7 @@ class FileSystem { return fs.cp(src, dest, { recursive: true, filter }); }; - copyFile = async (src: string, dest: string) => { + copyFile = async (src: PathLike, dest: PathLike) => { if (this.dry) { console.log(`${pc.blue('copyFile')} ${src} to ${dest}`); return Promise.resolve(); @@ -92,10 +94,9 @@ class FileSystem { return fs.rm(dir, { recursive: true, force: true }); }; - readFile = async (path: string, allowFileNotFound?: boolean) => { + readFile = async (path: PathLike, allowFileNotFound?: boolean) => { if (this.dry) { console.log(`${pc.blue('readFile')} ${path}`); - return Promise.resolve(''); } try { @@ -107,10 +108,9 @@ class FileSystem { throw error; } }; - readdir = async (path: string) => { + readdir = async (path: PathLike) => { if (this.dry) { console.log(`${pc.blue('readdir')} ${path}`); - return Promise.resolve([]); } try { @@ -122,10 +122,25 @@ class FileSystem { throw error; } }; + writeFiles = async (files: OutputFile[], outDir: string, log?: boolean) => { + for (const { destination: filename, output } of files) { + if (filename) { + const filePath = path.join(outDir, filename); + const fileDir = path.dirname(filePath); + + if (log) { + console.log(filename); + } + + await this.mkdir(fileDir); + await this.writeFile(filePath, output); + } + } + }; } /** - * An abstraction of Node's file system API for how CLI should interact with Files system. + * An abstraction of Node's file system API and helper functions for CLI interaction with the file system. * * Allows dry-running destructive operations, logging and store relevant file system state. */ From 0b6b253f608ca256bf6e0aeadcb93d4896820bc1 Mon Sep 17 00:00:00 2001 From: Michael Marszalek Date: Tue, 10 Feb 2026 09:42:39 +0100 Subject: [PATCH 07/14] cleaning up --- packages/cli/bin/designsystemet.ts | 23 +++++++++++------------ packages/cli/src/tokens/build.ts | 17 +---------------- packages/cli/src/utils/filesystem.ts | 14 +++++++++++--- 3 files changed, 23 insertions(+), 31 deletions(-) diff --git a/packages/cli/bin/designsystemet.ts b/packages/cli/bin/designsystemet.ts index ab6ea58c36..a07ccd4f8a 100644 --- a/packages/cli/bin/designsystemet.ts +++ b/packages/cli/bin/designsystemet.ts @@ -55,14 +55,13 @@ function makeTokenCommands() { const { verbose, clean, dry, experimentalTailwind, tokens } = opts; const configFilePath = opts.config; - const workingDir = configFilePath ? path.dirname(configFilePath) : process.cwd(); const { configFile, configPath } = await getConfigFile(configFilePath); const config = await parseBuildConfig(configFile, { configPath }); - const outDir = path.resolve(workingDir, opts.outDir); // TODO read output directory from config or CLI options + fs.init({ dry, configFilePath }); - fs.init({ dry }); + const outDir = fs.getOutdir(opts.outDir); // TODO read output directory from config or CLI options if (clean) { await fs.cleanDir(outDir); @@ -113,7 +112,6 @@ function makeTokenCommands() { } const themeName = opts.theme; const configFilePath = opts.config; - const workingDir = configFilePath ? path.dirname(configFilePath) : process.cwd(); const { configFile, configPath } = await getConfigFile(configFilePath); const config = await parseCreateConfig(configFile, { @@ -122,8 +120,9 @@ function makeTokenCommands() { configPath, }); - const outDir = path.resolve(workingDir, config.outDir); - fs.init({ dry: opts.dry }); + fs.init({ dry: opts.dry, configFilePath }); + + const outDir = fs.getOutdir(config.outDir); if (config.clean) { await fs.cleanDir(outDir); @@ -162,14 +161,14 @@ program console.log(figletAscii); const { dry } = opts; const tokensDir = path.resolve(opts.dir); - const outFile = path.resolve(opts.out); + const configFilePath = path.resolve(opts.out); - fs.init({ dry }); + fs.init({ dry, configFilePath }); try { const config = await generateConfigFromTokens({ tokensDir, - outFile, + outFile: configFilePath, }); if (dry) { @@ -178,11 +177,11 @@ program console.log(JSON.stringify(config, null, 2)); } - if (outFile) { + if (configFilePath) { const configJson = JSON.stringify(config, null, 2); - await fs.writeFile(outFile, configJson); + await fs.writeFile(configFilePath, configJson); console.log(); - console.log(`\n✅ Config file written to ${pc.blue(outFile)}`); + console.log(`\n✅ Config file written to ${pc.blue(configFilePath)}`); } } catch (error) { console.error(pc.redBright('Error generating config:')); diff --git a/packages/cli/src/tokens/build.ts b/packages/cli/src/tokens/build.ts index 621bdb7618..48019cd7f9 100644 --- a/packages/cli/src/tokens/build.ts +++ b/packages/cli/src/tokens/build.ts @@ -1,4 +1,3 @@ -import path from 'node:path'; import type { ThemeObject } from '@tokens-studio/types'; import pc from 'picocolors'; import * as R from 'ramda'; @@ -11,7 +10,7 @@ import { processThemeObject } from './process/utils/getMultidimensionalThemes.js import type { DesignsystemetObject, OutputFile } from './types.js'; export const buildTokens = async (options: Omit) => { - const tokensDir = path.resolve(options.tokensDir); + const tokensDir = options.tokensDir; const $themes = JSON.parse(await fs.readFile(`${tokensDir}/$themes.json`)) as ThemeObject[]; const processed$themes = $themes.map(processThemeObject); let $designsystemet: DesignsystemetObject | undefined; @@ -50,17 +49,3 @@ export const buildTokens = async (options: Omit Date: Tue, 10 Feb 2026 09:51:01 +0100 Subject: [PATCH 08/14] changeset --- .changeset/two-experts-punch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/two-experts-punch.md diff --git a/.changeset/two-experts-punch.md b/.changeset/two-experts-punch.md new file mode 100644 index 0000000000..baf56cb5fb --- /dev/null +++ b/.changeset/two-experts-punch.md @@ -0,0 +1,5 @@ +--- +"@digdir/designsystemet": patch +--- + +fix resolving of `outDir` in config not being relative to config file location From 1dc62cf8e5dfcf8e1140980178f46765e0084bd6 Mon Sep 17 00:00:00 2001 From: Michael Marszalek Date: Tue, 10 Feb 2026 09:55:11 +0100 Subject: [PATCH 09/14] rename --- packages/cli/bin/designsystemet.ts | 4 ++-- packages/cli/src/tokens/create/write.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/bin/designsystemet.ts b/packages/cli/bin/designsystemet.ts index a07ccd4f8a..3e8786cc59 100644 --- a/packages/cli/bin/designsystemet.ts +++ b/packages/cli/bin/designsystemet.ts @@ -7,7 +7,7 @@ import { convertToHex } from '../src/colors/index.js'; import type { CssColor } from '../src/colors/types.js'; import migrations from '../src/migrations/index.js'; import { buildTokens } from '../src/tokens/build.js'; -import { writeTokens } from '../src/tokens/create/write.js'; +import { createTokenFiles } from '../src/tokens/create/write.js'; import { cliOptions, createTokens } from '../src/tokens/create.js'; import { generateConfigFromTokens } from '../src/tokens/generate-config.js'; import type { OutputFile, Theme } from '../src/tokens/types.js'; @@ -135,7 +135,7 @@ function makeTokenCommands() { const theme = { name, ...themeWithoutName } as Theme; const { tokenSets } = await createTokens(theme); - files = files.concat(await writeTokens({ outDir, theme, tokenSets })); + files = files.concat(await createTokenFiles({ outDir, theme, tokenSets })); } } diff --git a/packages/cli/src/tokens/create/write.ts b/packages/cli/src/tokens/create/write.ts index 178539d8f5..1a17efc8cb 100644 --- a/packages/cli/src/tokens/create/write.ts +++ b/packages/cli/src/tokens/create/write.ts @@ -10,13 +10,13 @@ import { generate$Themes } from './generators/$themes.js'; export const stringify = (data: unknown) => JSON.stringify(data, null, 2); -type WriteTokensOptions = { +type CreateTokenFilesOptions = { outDir: string; theme: Theme; tokenSets: TokenSets; }; -export const writeTokens = async (options: WriteTokensOptions) => { +export const createTokenFiles = async (options: CreateTokenFilesOptions) => { const { outDir, tokenSets, From eba0d4202b06014c90a088b2b72a3ba9d7a37f02 Mon Sep 17 00:00:00 2001 From: Michael Marszalek Date: Tue, 10 Feb 2026 09:57:05 +0100 Subject: [PATCH 10/14] rename file --- packages/cli/bin/designsystemet.ts | 2 +- packages/cli/src/tokens/create/{write.ts => files.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename packages/cli/src/tokens/create/{write.ts => files.ts} (100%) diff --git a/packages/cli/bin/designsystemet.ts b/packages/cli/bin/designsystemet.ts index 3e8786cc59..8d40b3c4e5 100644 --- a/packages/cli/bin/designsystemet.ts +++ b/packages/cli/bin/designsystemet.ts @@ -7,7 +7,7 @@ import { convertToHex } from '../src/colors/index.js'; import type { CssColor } from '../src/colors/types.js'; import migrations from '../src/migrations/index.js'; import { buildTokens } from '../src/tokens/build.js'; -import { createTokenFiles } from '../src/tokens/create/write.js'; +import { createTokenFiles } from '../src/tokens/create/files.js'; import { cliOptions, createTokens } from '../src/tokens/create.js'; import { generateConfigFromTokens } from '../src/tokens/generate-config.js'; import type { OutputFile, Theme } from '../src/tokens/types.js'; diff --git a/packages/cli/src/tokens/create/write.ts b/packages/cli/src/tokens/create/files.ts similarity index 100% rename from packages/cli/src/tokens/create/write.ts rename to packages/cli/src/tokens/create/files.ts From 0bb63f08647376d2d03ef77580a88697ca9f9945 Mon Sep 17 00:00:00 2001 From: Michael Marszalek Date: Tue, 10 Feb 2026 13:43:17 +0100 Subject: [PATCH 11/14] update preview tokens write --- .../cli/src/scripts/update-preview-tokens.ts | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/scripts/update-preview-tokens.ts b/packages/cli/src/scripts/update-preview-tokens.ts index 897d518656..69a7b65f58 100644 --- a/packages/cli/src/scripts/update-preview-tokens.ts +++ b/packages/cli/src/scripts/update-preview-tokens.ts @@ -1,4 +1,3 @@ -import path from 'node:path'; import pc from 'picocolors'; import type { TransformedToken } from 'style-dictionary/types'; import config from './../../../../designsystemet.config.json' with { type: 'json' }; @@ -6,25 +5,11 @@ import { generate$Themes } from '../tokens/create/generators/$themes.js'; import { createTokens } from '../tokens/create.js'; import { buildOptions, processPlatform } from '../tokens/process/platform.js'; import { processThemeObject } from '../tokens/process/utils/getMultidimensionalThemes.js'; -import type { OutputFile, SizeModes, Theme } from '../tokens/types.js'; +import type { SizeModes, Theme } from '../tokens/types.js'; import fs from '../utils/filesystem.js'; const OUTDIR = '../../internal/components/src/tokens/design-tokens'; -async function write(files: OutputFile[], outDir: string) { - for (const { destination, output } of files) { - if (destination) { - const filePath = path.join(outDir, destination); - const fileDir = path.dirname(filePath); - - console.log(`Writing file: ${pc.green(filePath)}`); - - await fs.mkdir(fileDir); - await fs.writeFile(filePath, output); - } - } -} - const toPreviewToken = (tokens: { token: TransformedToken; formatted: string }[]): PreviewToken[] => tokens.map(({ token, formatted }) => { const [variable, value] = formatted.split(':'); @@ -95,7 +80,7 @@ export const formatTheme = async (themeConfig: Theme) => { console.log(`\n💾 Writing preview tokens`); for (const [type, tokens] of Object.entries(tokensGroupedByType)) { - write( + fs.writeFiles( [ { destination: `${type}.json`, From d0fdff530a47fe32c5ef36d27736cdbf9099e1cb Mon Sep 17 00:00:00 2001 From: Michael Marszalek Date: Wed, 25 Feb 2026 16:03:35 +0100 Subject: [PATCH 12/14] renaming some const for better legibility --- packages/cli/bin/config.ts | 22 +++++++++++----------- packages/cli/src/config.ts | 8 ++++---- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/cli/bin/config.ts b/packages/cli/bin/config.ts index 1771befb5c..59da80f3ec 100644 --- a/packages/cli/bin/config.ts +++ b/packages/cli/bin/config.ts @@ -12,21 +12,21 @@ import { import fs from '../src/utils/filesystem.js'; import { getCliOption, getDefaultCliOption, getSuppliedCliOption, type OptionGetter } from './options.js'; -export async function readConfigFile(configPath: string, allowFileNotFound = true): Promise { +export async function readConfigFile(configFilePath: string, allowFileNotFound = true): Promise { let configFile: string; try { - configFile = await fs.readFile(configPath, allowFileNotFound); + configFile = await fs.readFile(configFilePath, allowFileNotFound); } catch (err) { if (allowFileNotFound) { return ''; } - console.error(pc.redBright(`Could not read config file at ${pc.blue(configPath)}`)); + console.error(pc.redBright(`Could not read config file at ${pc.blue(configFilePath)}`)); throw err; } if (configFile) { - console.log(`Found config file: ${pc.green(configPath)}`); + console.log(`Found config file: ${pc.green(configFilePath)}`); } return configFile; @@ -34,11 +34,11 @@ export async function readConfigFile(configPath: string, allowFileNotFound = tru export async function parseCreateConfig( configFile: string, - options: { theme: string; cmd: Command; configPath: string }, + options: { theme: string; cmd: Command; configFilePath: string }, ): Promise { - const { cmd, theme = 'theme', configPath } = options; + const { cmd, theme = 'theme', configFilePath } = options; - const configParsed: CreateConfigSchema = parseConfig(configFile, configPath); + const configParsed: CreateConfigSchema = parseConfig(configFile, configFilePath); /* * Check that we're not creating multiple themes with different color names. @@ -99,14 +99,14 @@ export async function parseCreateConfig( }, }); - return validateConfig(configFileCreateSchema, unvalidatedConfig, configPath); + return validateConfig(configFileCreateSchema, unvalidatedConfig, configFilePath); } export async function parseBuildConfig( configFile: string, - { configPath }: { configPath: string }, + { configFilePath }: { configFilePath: string }, ): Promise { - const configParsed: BuildConfigSchema = parseConfig(configFile, configPath); + const configParsed: BuildConfigSchema = parseConfig(configFile, configFilePath); - return validateConfig(commonConfig, configParsed, configPath); + return validateConfig(commonConfig, configParsed, configFilePath); } diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 632dec546b..05ceaf28eb 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -51,12 +51,12 @@ function makeFriendlyError(err: unknown) { export function validateConfig( schema: z.ZodType, unvalidatedConfig: Record, - configPath: string, + configFilePath: string, ): T { try { return schema.parse(unvalidatedConfig) as T; } catch (err) { - console.error(pc.redBright(`Invalid config file at ${pc.red(configPath)}`)); + console.error(pc.redBright(`Invalid config file at ${pc.red(configFilePath)}`)); const validationError = makeFriendlyError(err); console.error(validationError?.toString()); @@ -64,7 +64,7 @@ export function validateConfig( } } -export function parseConfig(configFile: string, configPath: string): T { +export function parseConfig(configFile: string, configFilePath: string): T { if (!configFile) { return {} as T; } @@ -72,7 +72,7 @@ export function parseConfig(configFile: string, configPath: string): T { try { return JSON.parse(configFile) as T; } catch (err) { - console.error(pc.redBright(`Failed parsing config file at ${pc.red(configPath)}`)); + console.error(pc.redBright(`Failed parsing config file at ${pc.red(configFilePath)}`)); const validationError = makeFriendlyError(err); console.error(validationError?.toString()); From 57fe2e3ecf68637e2190632c68438e8ecf362d32 Mon Sep 17 00:00:00 2001 From: Michael Marszalek Date: Wed, 25 Feb 2026 16:04:10 +0100 Subject: [PATCH 13/14] updated outdir resolving when config vs opt --- packages/cli/bin/designsystemet.ts | 37 +++++++++++++++------------- packages/cli/src/utils/filesystem.ts | 18 ++++++-------- 2 files changed, 27 insertions(+), 28 deletions(-) diff --git a/packages/cli/bin/designsystemet.ts b/packages/cli/bin/designsystemet.ts index 8d40b3c4e5..8246ddc265 100644 --- a/packages/cli/bin/designsystemet.ts +++ b/packages/cli/bin/designsystemet.ts @@ -54,14 +54,12 @@ function makeTokenCommands() { console.log(figletAscii); const { verbose, clean, dry, experimentalTailwind, tokens } = opts; - const configFilePath = opts.config; + const { configFile, configFilePath } = await getConfigFile(opts.config); + const config = await parseBuildConfig(configFile, { configFilePath }); - const { configFile, configPath } = await getConfigFile(configFilePath); - const config = await parseBuildConfig(configFile, { configPath }); + fs.init({ dry, configFilePath, outdir: opts.outDir }); - fs.init({ dry, configFilePath }); - - const outDir = fs.getOutdir(opts.outDir); // TODO read output directory from config or CLI options + const outDir = fs.outDir; if (clean) { await fs.cleanDir(outDir); @@ -111,18 +109,23 @@ function makeTokenCommands() { console.log(`Performing dry run, no files will be written`); } const themeName = opts.theme; - const configFilePath = opts.config; - const { configFile, configPath } = await getConfigFile(configFilePath); + const { configFile, configFilePath } = await getConfigFile(opts.config); const config = await parseCreateConfig(configFile, { theme: themeName, cmd, - configPath, + configFilePath, + }); + + fs.init({ + dry: opts.dry, + configFilePath, + outdir: opts.outDir !== DEFAULT_TOKENS_CREATE_DIR ? opts.outDir : config.outDir, }); - fs.init({ dry: opts.dry, configFilePath }); + console.log('initialized file system with config:', { workingDir: fs.workingDir, outDir: fs.outDir }); - const outDir = fs.getOutdir(config.outDir); + const outDir = fs.outDir; if (config.clean) { await fs.cleanDir(outDir); @@ -163,7 +166,7 @@ program const tokensDir = path.resolve(opts.dir); const configFilePath = path.resolve(opts.out); - fs.init({ dry, configFilePath }); + fs.init({ dry, configFilePath, outdir: path.dirname(configFilePath) }); try { const config = await generateConfigFromTokens({ @@ -232,10 +235,10 @@ function parseBoolean(value: string | boolean): boolean { return value === 'true' || value === true; } -async function getConfigFile(config: string | undefined) { - const allowFileNotFound = R.isNil(config) || config === DEFAULT_CONFIG_FILE; - const configPath = config ?? DEFAULT_CONFIG_FILE; - const configFile = await readConfigFile(configPath, allowFileNotFound); +async function getConfigFile(userConfigFilePath: string | undefined) { + const allowFileNotFound = R.isNil(userConfigFilePath) || userConfigFilePath === DEFAULT_CONFIG_FILE; + const configFilePath = userConfigFilePath ?? DEFAULT_CONFIG_FILE; + const configFile = await readConfigFile(configFilePath, allowFileNotFound); - return { configFile, configPath }; + return { configFile, configFilePath }; } diff --git a/packages/cli/src/utils/filesystem.ts b/packages/cli/src/utils/filesystem.ts index b0629a0d55..1c04e6fafc 100644 --- a/packages/cli/src/utils/filesystem.ts +++ b/packages/cli/src/utils/filesystem.ts @@ -7,11 +7,12 @@ import type { OutputFile } from '../tokens/types.js'; class FileSystem { private isInitialized = false; private dry = false; - /** Resolved write directory */ + /** Default working directory is where the process was started */ workingDir = process.cwd(); + outDir = this.workingDir; /** Initialize the file system */ - init({ dry, configFilePath }: { dry?: boolean; configFilePath?: string }) { + init({ dry, configFilePath, outdir }: { dry?: boolean; configFilePath?: string; outdir?: string }) { if (this.isInitialized) { console.warn(pc.yellow('FileSystem is already initialized. Ignoring subsequent init call.')); return; @@ -22,18 +23,13 @@ class FileSystem { } this.dry = dry ?? false; - this.workingDir = configFilePath ? path.dirname(configFilePath) : process.cwd(); + // If a config file path is provided, set the working directory to the config file's directory. This allows relative paths in the config file to be resolved correctly. If no config file path is provided, use the current working directory. + this.workingDir = configFilePath ? path.dirname(configFilePath) : this.workingDir; + // If an output directory is provided, resolve it relative to the working directory. Otherwise, use the working directory as the output directory. + this.outDir = outdir ? (path.isAbsolute(outdir) ? outdir : path.join(this.workingDir, outdir)) : this.workingDir; this.isInitialized = true; } - getOutdir(outDir?: string) { - if (outDir) { - return path.isAbsolute(outDir) ? outDir : path.join(this.workingDir, outDir); - } - - return this.workingDir; - } - /** * Creates a directory if it does not already exist. * From f52e97859398662e58b518c58eec69d19b918173 Mon Sep 17 00:00:00 2001 From: Michael Marszalek Date: Wed, 25 Feb 2026 16:15:37 +0100 Subject: [PATCH 14/14] no top limit for node engine support --- packages/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 78bd7ed7fe..970fd79481 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -4,7 +4,7 @@ "description": "CLI for Designsystemet", "author": "Designsystemet team", "engines": { - "node": ">=20 <25" + "node": ">=20" }, "repository": { "type": "git",