diff --git a/README.md b/README.md index 341274d..8f43961 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ inspect or change those values afterwards without hand-editing the file: markpost config get # show all stored config markpost config get apiToken # show one value markpost config set apiToken # change the stored API token -markpost config set outputDirectory +markpost config set outputDirectory '~/notes' # quote it: a leading ~, $HOME, or ${HOME} is expanded when writing markpost config path # print the config file location ``` @@ -152,7 +152,7 @@ Copy [`.envrc`](.envrc) and populate your values. If you use [direnv](https://di |---|---| | `API_TOKEN` | API token for sync.danholloran.me | | `BASE_URL` | Base URL of the sync API (e.g. `http://localhost:8888` for local dev) | -| `OUTPUT_DIRECTORY` | Absolute path to the directory where synced files are written | +| `OUTPUT_DIRECTORY` | Path to the directory where synced files are written; a leading `~`, `$HOME`, or `${HOME}` is expanded to your home directory. A relative path is resolved against the current working directory, so prefer an absolute path or a `~` prefix for scheduled runs | ### Scripts diff --git a/src/libs/config.ts b/src/libs/config.ts index a6219e8..e47a69d 100644 --- a/src/libs/config.ts +++ b/src/libs/config.ts @@ -109,7 +109,11 @@ const ensureConfigValue = async ( return; } - const value = await input({ message: field.promptMessage }); + // Trim so a pasted answer with surrounding whitespace matches the `config + // set` path (which trims too) — a stray leading space would otherwise defeat + // the leading `~`/`$HOME` expansion and store an unusable token. The guard + // below then also rejects a whitespace-only answer. + const value = (await input({ message: field.promptMessage })).trim(); if (!value) { console.error(chalk.redBright(`${field.promptMessage} is required!`)); diff --git a/src/libs/markdown.ts b/src/libs/markdown.ts index c4db400..3c4a169 100644 --- a/src/libs/markdown.ts +++ b/src/libs/markdown.ts @@ -16,8 +16,10 @@ import { sep, } from 'node:path'; import { createHash } from 'node:crypto'; +import { homedir } from 'node:os'; import slugify from '@sindresorhus/slugify'; import { config } from '@/libs/config.js'; +import { expandHomeDirectory } from '@/libs/paths.js'; import { buildRecordDocument, stripFrontmatterDocument, @@ -65,10 +67,20 @@ const hashContent = (content: string): string => { return createHash(CONTENT_HASH_ALGORITHM).update(content).digest('hex'); }; +// The configured output directory can carry a leading `~`/`$HOME` that no shell +// expanded (a quoted `config set` value, or the interactive prompt), which +// existsSync/mkdirSync/resolve would otherwise treat as a literal folder in the +// cwd. Expand it here — the one read seam both ensureOutputDirectory and +// requireOutputDirectory go through — so every writer sees the real path. const getOutputDirectory = () => { - return ( - process.env.OUTPUT_DIRECTORY ?? (config.get('outputDirectory') as string) - ); + const configured = + process.env.OUTPUT_DIRECTORY ?? (config.get('outputDirectory') as string); + + if (!configured) { + return configured; + } + + return expandHomeDirectory(configured, homedir); }; // Slugify the title so it is always a single, safe path segment. Falls back diff --git a/src/libs/paths.ts b/src/libs/paths.ts new file mode 100644 index 0000000..5553b02 --- /dev/null +++ b/src/libs/paths.ts @@ -0,0 +1,71 @@ +import { join } from 'node:path'; + +// The home references a user can put at the start of a configured path. A shell +// won't expand these inside a quoted value (`config set outputDirectory +// '~/notes'`), so the CLI expands them itself at read time. `${HOME}` is listed +// before `$HOME` only for readability — the leading-prefix check below is exact, +// so the two never overlap. +const HOME_PREFIXES = ['~', '${HOME}', '$HOME'] as const; + +// The separator that must follow a prefix for it to count as a home reference. +// Deliberately POSIX `/` only: the config values and prompts this CLI reads use +// forward slashes, so a Windows-style `~\notes` is intentionally left literal +// rather than half-supported. (A directory named `~` is legal on POSIX, which is +// why a bare mid-path `~` must never expand — see isLeadingHomePrefix.) +const PATH_SEPARATOR = '/'; + +// True when `prefix` is a leading path token in `inputPath`: either the whole +// string is the prefix, or the prefix is immediately followed by a separator. +// This is what keeps a bare `~` or `$HOME` in the *middle* of a path literal — +// a directory can legitimately be named `~`, and `notes/~drafts` must stay as +// typed — while still leaving `~foo`/`$HOMEfoo` unexpanded: those are different +// tokens, not a home reference plus a path tail. +const isLeadingHomePrefix = (inputPath: string, prefix: string): boolean => { + if (inputPath === prefix) { + return true; + } + + return inputPath.startsWith(`${prefix}${PATH_SEPARATOR}`); +}; + +// Expand a leading home reference (`~`, `~/…`, `$HOME`, `${HOME}`) to an +// absolute path under the home directory. `resolveHomeDirectory` is injected +// (the caller passes `os.homedir`) and called *only* when a prefix actually +// matched, so a path with no home reference never depends on the environment at +// all — a fully absolute, plainly relative, or mid-string-tilde value is +// returned untouched. os.homedir() falls back to the passwd entry when `$HOME` +// is unset, so the empty-string guard below is defensive rather than routine. +export const expandHomeDirectory = ( + inputPath: string, + resolveHomeDirectory: () => string, +): string => { + const prefix = HOME_PREFIXES.find((candidate) => + isLeadingHomePrefix(inputPath, candidate), + ); + + if (!prefix) { + return inputPath; + } + + const homeDirectory = resolveHomeDirectory(); + + // Fail loud rather than let `join('', 'notes')` silently degrade `~/notes` + // into the relative `notes`, which would scatter files under the cwd — the + // exact footgun this expansion exists to close. + if (!homeDirectory) { + throw Error( + `Cannot expand "${prefix}" in "${inputPath}": no home directory is available.`, + ); + } + + const remainder = inputPath.slice(prefix.length); + + if (remainder === '') { + return homeDirectory; + } + + // `remainder` always begins with the separator that followed the prefix (the + // only way isLeadingHomePrefix matched a non-empty tail); join collapses it + // into a single path segment boundary under the home directory. + return join(homeDirectory, remainder); +}; diff --git a/tests/libs/config.test.ts b/tests/libs/config.test.ts index 90c90b5..077f5c8 100644 --- a/tests/libs/config.test.ts +++ b/tests/libs/config.test.ts @@ -118,6 +118,17 @@ describe('checkConfig', () => { expect(exitSpy).not.toHaveBeenCalled(); }); + it('trims surrounding whitespace from a prompted value before storing it', async () => { + mockGet.mockReturnValue(undefined); + process.env.API_TOKEN = 'env-token'; + vi.mocked(input).mockResolvedValue(' ~/notes '); + + await checkConfig(); + + expect(mockSet).toHaveBeenCalledWith('outputDirectory', '~/notes'); + expect(exitSpy).not.toHaveBeenCalled(); + }); + it('prompts for both values in order when neither is set', async () => { mockGet.mockReturnValue(undefined); vi.mocked(input).mockResolvedValueOnce('my-token').mockResolvedValueOnce('/my/dir'); diff --git a/tests/libs/markdown.test.ts b/tests/libs/markdown.test.ts index 81301ea..6f93d06 100644 --- a/tests/libs/markdown.test.ts +++ b/tests/libs/markdown.test.ts @@ -6,6 +6,7 @@ import { rmSync, writeFileSync, } from 'node:fs'; +import { homedir } from 'node:os'; import { resolve, sep } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import slugify from '@sindresorhus/slugify'; @@ -176,6 +177,34 @@ describe('writeMarkdown', () => { ); }); + it('expands a leading ~ in the configured output directory before writing', () => { + process.env.OUTPUT_DIRECTORY = '~/notes'; + + writeMarkdown(mockRecord); + + expect(writeFileSync).toHaveBeenCalledWith( + resolve(homedir(), 'notes', 'test-title.md'), + mockRecord.content, + EXCLUSIVE_WRITE_OPTIONS, + ); + }); + + it('expands $HOME in the persisted config value before creating the directory', () => { + delete process.env.OUTPUT_DIRECTORY; + vi.mocked(config.get).mockReturnValue('$HOME/notes'); + + writeMarkdown(mockRecord); + + expect(mkdirSync).toHaveBeenCalledWith(resolve(homedir(), 'notes'), { + recursive: true, + }); + expect(writeFileSync).toHaveBeenCalledWith( + resolve(homedir(), 'notes', 'test-title.md'), + mockRecord.content, + EXCLUSIVE_WRITE_OPTIONS, + ); + }); + it('calls mkdirSync when the output directory does not exist', () => { writeMarkdown(mockRecord); expect(mkdirSync).toHaveBeenCalledWith(outputDirectory, { @@ -1437,6 +1466,16 @@ describe('buildWritePreview', () => { }); }); + // The dry-run seam must expand the home reference too, so the previewed path + // matches where a real sync would write instead of a literal `~/` folder. + it('expands a leading ~ in the previewed output directory', () => { + process.env.OUTPUT_DIRECTORY = '~/notes'; + + const [preview] = buildWritePreview([mockRecord], 'suffix'); + + expect(preview.path).toBe(resolve(homedir(), 'notes', 'test-title.md')); + }); + // Two records slugging to the same base must preview distinct suffixed // targets, exactly as the suffix strategy would land them. it('suffixes a within-batch collision without touching disk', () => { diff --git a/tests/libs/paths.test.ts b/tests/libs/paths.test.ts new file mode 100644 index 0000000..6aec600 --- /dev/null +++ b/tests/libs/paths.test.ts @@ -0,0 +1,83 @@ +import { join } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; + +import { expandHomeDirectory } from '@/libs/paths.js'; + +const HOME = '/home/user'; +const resolveHome = () => HOME; + +describe('expandHomeDirectory', () => { + it('expands a bare ~ to the home directory', () => { + expect(expandHomeDirectory('~', resolveHome)).toBe(HOME); + }); + + it('expands ~/sub to a path under the home directory', () => { + expect(expandHomeDirectory('~/notes', resolveHome)).toBe( + join(HOME, 'notes'), + ); + }); + + it('expands a nested ~/sub/dir path', () => { + expect(expandHomeDirectory('~/notes/work', resolveHome)).toBe( + join(HOME, 'notes', 'work'), + ); + }); + + it('expands a bare $HOME to the home directory', () => { + expect(expandHomeDirectory('$HOME', resolveHome)).toBe(HOME); + }); + + it('expands $HOME/sub to a path under the home directory', () => { + expect(expandHomeDirectory('$HOME/notes', resolveHome)).toBe( + join(HOME, 'notes'), + ); + }); + + it('expands a bare ${HOME} to the home directory', () => { + expect(expandHomeDirectory('${HOME}', resolveHome)).toBe(HOME); + }); + + it('expands ${HOME}/sub to a path under the home directory', () => { + expect(expandHomeDirectory('${HOME}/notes', resolveHome)).toBe( + join(HOME, 'notes'), + ); + }); + + it('leaves an already-absolute path unchanged', () => { + expect(expandHomeDirectory('/var/notes', resolveHome)).toBe('/var/notes'); + }); + + it('leaves a relative path with a literal tilde mid-string unchanged', () => { + expect(expandHomeDirectory('notes/~drafts', resolveHome)).toBe( + 'notes/~drafts', + ); + }); + + it('leaves a plain relative path unchanged', () => { + expect(expandHomeDirectory('notes', resolveHome)).toBe('notes'); + }); + + it('does not expand ~user (another user’s home is not resolvable)', () => { + expect(expandHomeDirectory('~other/notes', resolveHome)).toBe( + '~other/notes', + ); + }); + + it('does not expand a $HOME prefix that is not a whole path token', () => { + expect(expandHomeDirectory('$HOMEwork', resolveHome)).toBe('$HOMEwork'); + }); + + it('does not resolve the home directory when no prefix matches', () => { + const resolver = vi.fn(() => HOME); + + expandHomeDirectory('/var/notes', resolver); + + expect(resolver).not.toHaveBeenCalled(); + }); + + it('throws instead of writing to the cwd when the home directory is empty', () => { + expect(() => expandHomeDirectory('~/notes', () => '')).toThrow( + 'no home directory is available', + ); + }); +});