Typed JSON file storage for @parshjs/core handler ctx.
- Standard Schema v1 — bring your own validator (Zod, Valibot, ArkType, …).
- Typed handles — each file gets a
read()/write()/update()API typed from its schema. - Atomic writes — write-via-rename, never half-written JSON on disk.
- Defaults — declare
defaultson a spec andread()returns them when the file is missing. - Stateful handles —
await handle.load()for sync.valueaccess plus async partial writes. - Helpers —
osHomeDir()andosHomeConfigDir()for cross-platform default base paths.
npm install @parshjs/filesimport { join } from 'node:path';
import { createCli } from '@parshjs/core';
import { createFilesContext, osHomeConfigDir } from '@parshjs/files';
import { z } from 'zod';
import { commandTree } from './commandTree.gen.ts';
const cli = createCli({
programName: 'awslike',
tree: commandTree,
context: {
files: createFilesContext({
basePath: join(osHomeConfigDir(), 'awslike'),
files: {
credentials: {
filename: 'credentials.json',
schema: z.object({ accessKey: z.string(), secretKey: z.string() }),
},
},
}),
},
});
declare module '@parshjs/core' {
interface Register {
cli: typeof cli;
}
}
await cli.main();Inside any handler, ctx.context.files is typed from the file specs you declared (registered context lives under ctx.context):
defineCommand('login', {
options: {},
handler: async ({ context }) => {
const creds = await context.files.credentials.read(); // { accessKey: string; secretKey: string }
await context.files.credentials.write({ accessKey: 'AKIA…', secretKey: '…' });
await context.files.credentials.update({ accessKey: 'NEW' }); // shallow-merge + atomic write
context.files.credentials.write({ foo: 'bar' }); // throws a compile-time TypeScript error
context.files.unknown; // throws a compile-time TypeScript error
},
});Declare defaults on a spec and read() returns them when the file is missing — no more (await file.maybeRead()) ?? DEFAULTS at every call site. The defaults are returned in memory only; nothing is written to disk until something calls write(), update(), set(), or replace().
files: {
prefs: {
filename: 'prefs.json',
schema: z.object({ region: z.string(), color: z.boolean() }),
defaults: { region: 'us-east-1', color: true },
},
},For consumers that need synchronous field access (constructing clients at startup, reads inside a UI loop), call load() to materialize a stateful handle. It loads the file once into memory and exposes .value synchronously plus async partial writes. load() is idempotent — call it from as many beforeHandlers as you like; only the first call hits disk.
const config = await context.files.prefs.load();
config.value.region; // sync
await config.set({ region: 'eu-west-2' }); // shallow-merge + atomic write, updates `.value`
await config.replace({ region: 'us', color: false }); // full overwrite
await config.reload(); // re-read from disk (only needed if something else wrote to the file)set() and replace() keep .value in sync with disk automatically — reload() is only for the case where something outside this handle modified the file (another process, a hand-edit). The stateful handle assumes single-process ownership of the file.