From a21013c0bff993854b13d1ddb697365f3c030074 Mon Sep 17 00:00:00 2001 From: grimicorn-agent Date: Sun, 23 Aug 2026 16:16:44 -0500 Subject: [PATCH 1/4] Expand ~ and $HOME in configured outputDirectory before writing --- src/libs/markdown.ts | 18 ++++++++++-- src/libs/paths.ts | 51 ++++++++++++++++++++++++++++++++ tests/libs/markdown.test.ts | 13 +++++++++ tests/libs/paths.test.ts | 58 +++++++++++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 src/libs/paths.ts create mode 100644 tests/libs/paths.test.ts diff --git a/src/libs/markdown.ts b/src/libs/markdown.ts index c4db400..f31994f 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..29c8136 --- /dev/null +++ b/src/libs/paths.ts @@ -0,0 +1,51 @@ +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; + +// 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 catching `~foo`/`$HOMEfoo`, which are different tokens. +const isLeadingHomePrefix = (inputPath: string, prefix: string): boolean => { + if (inputPath === prefix) { + return true; + } + + return inputPath.startsWith(`${prefix}/`); +}; + +// Expand a leading home reference (`~`, `~/…`, `$HOME`, `${HOME}`) to an +// absolute path under `homeDirectory`. `homeDirectory` is injected (the caller +// passes `os.homedir()`) so this stays a pure string transform testable without +// touching the environment. A path with no leading home reference — already +// absolute, plainly relative, or carrying only a mid-string tilde — is returned +// unchanged. +export const expandHomeDirectory = ( + inputPath: string, + homeDirectory: string, +): string => { + const prefix = HOME_PREFIXES.find((candidate) => + isLeadingHomePrefix(inputPath, candidate), + ); + + if (!prefix) { + return inputPath; + } + + 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/markdown.test.ts b/tests/libs/markdown.test.ts index 81301ea..4122413 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,18 @@ 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('calls mkdirSync when the output directory does not exist', () => { writeMarkdown(mockRecord); expect(mkdirSync).toHaveBeenCalledWith(outputDirectory, { diff --git a/tests/libs/paths.test.ts b/tests/libs/paths.test.ts new file mode 100644 index 0000000..456bc59 --- /dev/null +++ b/tests/libs/paths.test.ts @@ -0,0 +1,58 @@ +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { expandHomeDirectory } from '@/libs/paths.js'; + +const HOME = '/home/user'; + +describe('expandHomeDirectory', () => { + it('expands a bare ~ to the home directory', () => { + expect(expandHomeDirectory('~', HOME)).toBe(HOME); + }); + + it('expands ~/sub to a path under the home directory', () => { + expect(expandHomeDirectory('~/notes', HOME)).toBe(join(HOME, 'notes')); + }); + + it('expands a nested ~/sub/dir path', () => { + expect(expandHomeDirectory('~/notes/work', HOME)).toBe( + join(HOME, 'notes', 'work'), + ); + }); + + it('expands a bare $HOME to the home directory', () => { + expect(expandHomeDirectory('$HOME', HOME)).toBe(HOME); + }); + + it('expands $HOME/sub to a path under the home directory', () => { + expect(expandHomeDirectory('$HOME/notes', HOME)).toBe(join(HOME, 'notes')); + }); + + it('expands a bare ${HOME} to the home directory', () => { + expect(expandHomeDirectory('${HOME}', HOME)).toBe(HOME); + }); + + it('expands ${HOME}/sub to a path under the home directory', () => { + expect(expandHomeDirectory('${HOME}/notes', HOME)).toBe(join(HOME, 'notes')); + }); + + it('leaves an already-absolute path unchanged', () => { + expect(expandHomeDirectory('/var/notes', HOME)).toBe('/var/notes'); + }); + + it('leaves a relative path with a literal tilde mid-string unchanged', () => { + expect(expandHomeDirectory('notes/~drafts', HOME)).toBe('notes/~drafts'); + }); + + it('leaves a plain relative path unchanged', () => { + expect(expandHomeDirectory('notes', HOME)).toBe('notes'); + }); + + it('does not expand ~user (another user’s home is not resolvable)', () => { + expect(expandHomeDirectory('~other/notes', HOME)).toBe('~other/notes'); + }); + + it('does not expand a $HOME prefix that is not a whole path token', () => { + expect(expandHomeDirectory('$HOMEwork', HOME)).toBe('$HOMEwork'); + }); +}); From c20250e37d62376d3d5ba4b23cc9469c997b1517 Mon Sep 17 00:00:00 2001 From: grimicorn-agent Date: Sun, 23 Aug 2026 16:22:39 -0500 Subject: [PATCH 2/4] Review round 1: add config-value expansion test, document ~/$HOME in README --- README.md | 4 ++-- tests/libs/markdown.test.ts | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 341274d..2c48731 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 # 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 | ### Scripts diff --git a/tests/libs/markdown.test.ts b/tests/libs/markdown.test.ts index 4122413..c6b9dab 100644 --- a/tests/libs/markdown.test.ts +++ b/tests/libs/markdown.test.ts @@ -189,6 +189,22 @@ describe('writeMarkdown', () => { ); }); + 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, { From c4639e3e07f1ac4016615f001b339a1948d9f9c6 Mon Sep 17 00:00:00 2001 From: grimicorn-agent Date: Sun, 23 Aug 2026 16:26:45 -0500 Subject: [PATCH 3/4] Review round 2: lazy home resolve, fail loud on empty home, preview + guard tests --- src/libs/markdown.ts | 2 +- src/libs/paths.ts | 36 ++++++++++++++++++++------ tests/libs/markdown.test.ts | 10 ++++++++ tests/libs/paths.test.ts | 51 +++++++++++++++++++++++++++---------- 4 files changed, 77 insertions(+), 22 deletions(-) diff --git a/src/libs/markdown.ts b/src/libs/markdown.ts index f31994f..3c4a169 100644 --- a/src/libs/markdown.ts +++ b/src/libs/markdown.ts @@ -80,7 +80,7 @@ const getOutputDirectory = () => { return configured; } - return expandHomeDirectory(configured, homedir()); + 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 index 29c8136..aabbea9 100644 --- a/src/libs/paths.ts +++ b/src/libs/paths.ts @@ -7,28 +7,37 @@ import { join } from 'node:path'; // 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 catching `~foo`/`$HOMEfoo`, which are different tokens. +// 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}/`); + return inputPath.startsWith(`${prefix}${PATH_SEPARATOR}`); }; // Expand a leading home reference (`~`, `~/…`, `$HOME`, `${HOME}`) to an -// absolute path under `homeDirectory`. `homeDirectory` is injected (the caller -// passes `os.homedir()`) so this stays a pure string transform testable without -// touching the environment. A path with no leading home reference — already -// absolute, plainly relative, or carrying only a mid-string tilde — is returned -// unchanged. +// 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 touches the environment — a +// fully absolute or plainly relative value is returned untouched even in a +// home-less context (bare container, CI) where `os.homedir()` would throw. A +// path carrying only a mid-string tilde is likewise returned unchanged. export const expandHomeDirectory = ( inputPath: string, - homeDirectory: string, + resolveHomeDirectory: () => string, ): string => { const prefix = HOME_PREFIXES.find((candidate) => isLeadingHomePrefix(inputPath, candidate), @@ -38,6 +47,17 @@ export const expandHomeDirectory = ( 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 === '') { diff --git a/tests/libs/markdown.test.ts b/tests/libs/markdown.test.ts index c6b9dab..6f93d06 100644 --- a/tests/libs/markdown.test.ts +++ b/tests/libs/markdown.test.ts @@ -1466,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 index 456bc59..6aec600 100644 --- a/tests/libs/paths.test.ts +++ b/tests/libs/paths.test.ts @@ -1,58 +1,83 @@ import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; +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('~', HOME)).toBe(HOME); + expect(expandHomeDirectory('~', resolveHome)).toBe(HOME); }); it('expands ~/sub to a path under the home directory', () => { - expect(expandHomeDirectory('~/notes', HOME)).toBe(join(HOME, 'notes')); + expect(expandHomeDirectory('~/notes', resolveHome)).toBe( + join(HOME, 'notes'), + ); }); it('expands a nested ~/sub/dir path', () => { - expect(expandHomeDirectory('~/notes/work', HOME)).toBe( + expect(expandHomeDirectory('~/notes/work', resolveHome)).toBe( join(HOME, 'notes', 'work'), ); }); it('expands a bare $HOME to the home directory', () => { - expect(expandHomeDirectory('$HOME', HOME)).toBe(HOME); + expect(expandHomeDirectory('$HOME', resolveHome)).toBe(HOME); }); it('expands $HOME/sub to a path under the home directory', () => { - expect(expandHomeDirectory('$HOME/notes', HOME)).toBe(join(HOME, 'notes')); + expect(expandHomeDirectory('$HOME/notes', resolveHome)).toBe( + join(HOME, 'notes'), + ); }); it('expands a bare ${HOME} to the home directory', () => { - expect(expandHomeDirectory('${HOME}', HOME)).toBe(HOME); + expect(expandHomeDirectory('${HOME}', resolveHome)).toBe(HOME); }); it('expands ${HOME}/sub to a path under the home directory', () => { - expect(expandHomeDirectory('${HOME}/notes', HOME)).toBe(join(HOME, 'notes')); + expect(expandHomeDirectory('${HOME}/notes', resolveHome)).toBe( + join(HOME, 'notes'), + ); }); it('leaves an already-absolute path unchanged', () => { - expect(expandHomeDirectory('/var/notes', HOME)).toBe('/var/notes'); + expect(expandHomeDirectory('/var/notes', resolveHome)).toBe('/var/notes'); }); it('leaves a relative path with a literal tilde mid-string unchanged', () => { - expect(expandHomeDirectory('notes/~drafts', HOME)).toBe('notes/~drafts'); + expect(expandHomeDirectory('notes/~drafts', resolveHome)).toBe( + 'notes/~drafts', + ); }); it('leaves a plain relative path unchanged', () => { - expect(expandHomeDirectory('notes', HOME)).toBe('notes'); + expect(expandHomeDirectory('notes', resolveHome)).toBe('notes'); }); it('does not expand ~user (another user’s home is not resolvable)', () => { - expect(expandHomeDirectory('~other/notes', HOME)).toBe('~other/notes'); + 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', HOME)).toBe('$HOMEwork'); + 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', + ); }); }); From 11c5c0a1a13c1d49de331c0a64debaf21bcff809 Mon Sep 17 00:00:00 2001 From: grimicorn-agent Date: Sun, 23 Aug 2026 16:32:13 -0500 Subject: [PATCH 4/4] Review round 3: trim prompt input, document quoting/relative-path, comment accuracy --- README.md | 4 ++-- src/libs/config.ts | 6 +++++- src/libs/paths.ts | 8 ++++---- tests/libs/config.test.ts | 11 +++++++++++ 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2c48731..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 # a leading ~, $HOME, or ${HOME} is expanded when writing +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` | Path to the directory where synced files are written; a leading `~`, `$HOME`, or `${HOME}` is expanded to your home directory | +| `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/paths.ts b/src/libs/paths.ts index aabbea9..5553b02 100644 --- a/src/libs/paths.ts +++ b/src/libs/paths.ts @@ -31,10 +31,10 @@ const isLeadingHomePrefix = (inputPath: string, prefix: string): boolean => { // 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 touches the environment — a -// fully absolute or plainly relative value is returned untouched even in a -// home-less context (bare container, CI) where `os.homedir()` would throw. A -// path carrying only a mid-string tilde is likewise returned unchanged. +// 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, 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');