Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token> # change the stored API token
markpost config set outputDirectory <path>
markpost config set outputDirectory '~/notes' # quote it: a leading ~, $HOME, or ${HOME} is expanded when writing
markpost config path # print the config file location
```

Expand Down Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion src/libs/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!`));
Expand Down
18 changes: 15 additions & 3 deletions src/libs/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions src/libs/paths.ts
Original file line number Diff line number Diff line change
@@ -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);
};
11 changes: 11 additions & 0 deletions tests/libs/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
39 changes: 39 additions & 0 deletions tests/libs/markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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', () => {
Expand Down
83 changes: 83 additions & 0 deletions tests/libs/paths.test.ts
Original file line number Diff line number Diff line change
@@ -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',
);
});
});