Skip to content
Open
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
5 changes: 5 additions & 0 deletions apps/desktop/src/cli/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
readDatabaseVaultLayout,
readNote,
readPrimaryNotesLocation,
readAsset,
readVaultFileTextOrNull,
renameFolder,
renameNote,
Expand Down Expand Up @@ -114,6 +115,8 @@ export interface VaultBackend {
describe(): Promise<VaultDescription>
listNotes(): Promise<NoteMeta[]>
listAssets(): Promise<VaultAssetMeta[]>
/** An asset's raw bytes (#716). */
readAsset(rel: string): Promise<Uint8Array>
listFolders(): Promise<{ folder: NoteFolder; subpath: string }[]>
readNote(rel: string): Promise<NoteContent>
writeNote(rel: string, body: string): Promise<NoteMeta>
Expand Down Expand Up @@ -222,6 +225,7 @@ class LocalBackend implements VaultBackend {
})
listNotes = (): Promise<NoteMeta[]> => listNotes(this.root)
listAssets = (): Promise<VaultAssetMeta[]> => listAssets(this.root)
readAsset = (rel: string): Promise<Uint8Array> => readAsset(this.root, rel)
listFolders = (): Promise<{ folder: NoteFolder; subpath: string }[]> => listFolders(this.root)
readNote = (rel: string): Promise<NoteContent> => readNote(this.root, rel)
writeNote = (rel: string, body: string): Promise<NoteMeta> => writeNote(this.root, rel, body)
Expand Down Expand Up @@ -328,6 +332,7 @@ class RemoteBackend implements VaultBackend {
size: asset.size,
updatedAt: asset.updatedAt
}))
readAsset = (rel: string): Promise<Uint8Array> => this.client.readAsset(rel)
listFolders = (): Promise<{ folder: NoteFolder; subpath: string }[]> =>
this.client.listFolders()
readNote = (rel: string): Promise<NoteContent> => this.client.readNote(rel)
Expand Down
84 changes: 84 additions & 0 deletions apps/desktop/src/cli/commands/assets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { promises as fsp } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { createBackend } from '../backend'
import type { ParsedArgs } from '../args'
import { cmdAssetGet, cmdAssetList } from './assets'

function makeArgs(positionals: string[], flags: Array<[string, string]> = []): ParsedArgs {
const map = new Map<string, string[]>()
for (const [k, v] of flags) map.set(k, [...(map.get(k) ?? []), v])
return { positionals, flags: map }
}

let tmpDir: string
let root: string
let out: string[]
let binChunks: Uint8Array[]

beforeAll(async () => {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'zen-assets-cli-'))
root = path.join(tmpDir, 'vault')
await fsp.mkdir(path.join(root, 'assets'), { recursive: true })
await fsp.writeFile(path.join(root, 'assets', 'pic.png'), 'PNGDATA')
await fsp.writeFile(path.join(root, 'assets', 'doc.pdf'), '%PDF-1.4')
})

afterAll(async () => {
await fsp.rm(tmpDir, { recursive: true, force: true })
})

beforeEach(() => {
out = []
binChunks = []
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
if (typeof chunk === 'string') out.push(chunk)
else binChunks.push(chunk)
return true
})
})

const backend = (): ReturnType<typeof createBackend> => createBackend({ kind: 'local', root })

describe('zn asset list', () => {
it('lists assets with size and path', async () => {
await cmdAssetList(backend(), makeArgs([]))
const text = out.join('')
expect(text).toContain('assets/pic.png')
expect(text).toContain('assets/doc.pdf')
})

it('emits JSON with --json', async () => {
await cmdAssetList(backend(), makeArgs([], [['json', 'true']]))
const rows = JSON.parse(out.join('')) as Array<{ path: string; size: number }>
expect(rows.map((r) => r.path)).toContain('assets/pic.png')
expect(rows.find((r) => r.path === 'assets/pic.png')?.size).toBe(7)
})
})

describe('zn asset get', () => {
it('writes the raw bytes to stdout', async () => {
await cmdAssetGet(backend(), makeArgs(['assets/pic.png']))
// The mocked write records Uint8Array chunks untouched, so decode the
// first binary chunk before comparing.
expect(new TextDecoder().decode(binChunks[0])).toBe('PNGDATA')
})

it('saves to --output and reports the byte count', async () => {
const dest = path.join(tmpDir, 'out', 'copy.png')
await cmdAssetGet(backend(), makeArgs(['assets/pic.png'], [['output', dest]]))
expect(await fsp.readFile(dest, 'utf8')).toBe('PNGDATA')
expect(out.join('')).toContain(`Wrote 7 bytes to ${dest}`)
})

it('rejects paths that escape the vault', async () => {
await expect(
cmdAssetGet(backend(), makeArgs(['../../../etc/passwd']))
).rejects.toThrow(/escapes vault/)
})

it('rejects missing assets', async () => {
await expect(cmdAssetGet(backend(), makeArgs(['assets/nope.png']))).rejects.toThrow()
})
})
70 changes: 70 additions & 0 deletions apps/desktop/src/cli/commands/assets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Asset commands (#716): list and fetch the binary files embedded in notes.
* Works against a local folder or a self-hosted server, like every `zn`
* command — the VaultBackend seam picks the wire.
*
* `zn asset get` writes binary to stdout when no --output is given, so
* `zn asset get assets/pic.png > x.png` and piping into other tools work.
*/

import { promises as fsp } from 'node:fs'
import path from 'node:path'
import type { VaultBackend } from '../backend.js'
import { getBool, getString, type ParsedArgs } from '../args.js'
import { emitJson, emitLine, pad, truncate } from '../format.js'
import { formatRelativeAge } from '../format.js'

export async function cmdAssetList(vault: VaultBackend, args: ParsedArgs): Promise<void> {
const assets = await vault.listAssets()
if (getBool(args, 'json')) {
emitJson(assets)
return
}
if (assets.length === 0) {
emitLine('No assets in this vault.')
return
}
emitLine(`${pad('PATH', 48)} ${pad('SIZE', 10)} MODIFIED`)
for (const a of assets) {
emitLine(
`${pad(truncate(a.path, 47), 48)} ${pad(formatBytes(a.size), 10)} ${formatRelativeAge(a.updatedAt)}`
)
}
}

export async function cmdAssetGet(vault: VaultBackend, args: ParsedArgs): Promise<void> {
const rel = getString(args, 'path') ?? args.positionals[0]
if (!rel) throw new Error('zn asset get requires an asset path (see `zn asset list`).')
const output = getString(args, 'output')
const bytes = await vault.readAsset(rel)

if (output && output !== '-') {
await fsp.mkdir(path.dirname(path.resolve(output)), { recursive: true })
await fsp.writeFile(output, bytes)
if (!getBool(args, 'quiet')) {
emitLine(`Wrote ${bytes.length} bytes to ${output}.`)
}
return
}

// Binary to stdout. stdin/stdout are the streams `main()` returns through,
// so drain the write to keep the process from exiting early on Windows.
await new Promise<void>((resolve, reject) => {
const flushed = process.stdout.write(bytes, (err) => {
if (err) reject(err)
})
if (flushed) resolve()
})
}

function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
const units = ['KB', 'MB', 'GB']
let value = bytes / 1024
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024
unit++
}
return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)}${units[unit]}`
}
7 changes: 7 additions & 0 deletions apps/desktop/src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,13 @@ const SECTIONS: Array<{ heading: string; rows: CommandRow[] }> = [
{ name: 'folder delete <p>', description: 'Delete a subfolder and everything in it', flags: '--yes' }
]
},
{
heading: 'ASSETS',
rows: [
{ name: 'asset list', description: 'List images and attachments in the vault', flags: '--json' },
{ name: 'asset get <path>', description: 'Fetch an asset: binary to stdout, or saved via --output', flags: '--output <file> --quiet' }
]
},
{
heading: 'TAGS',
rows: [
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
cmdWrite
} from './commands/notes.js'
import { cmdBacklinks, cmdSearch, cmdSearchTitle } from './commands/search.js'
import { cmdAssetGet, cmdAssetList } from './commands/assets.js'
import {
cmdFolderCreate,
cmdFolderDelete,
Expand Down Expand Up @@ -128,6 +129,8 @@ async function main(argv: string[]): Promise<number> {
'folder create': cmdFolderCreate,
'folder rename': cmdFolderRename,
'folder delete': cmdFolderDelete,
'asset list': cmdAssetList,
'asset get': cmdAssetGet,
'tag list': cmdTagList,
'tag find': cmdTagFind,
'task list': cmdTaskList,
Expand Down Expand Up @@ -161,6 +164,7 @@ function peelSubcommand(
): { subcommand: string | null; parsed: ParsedArgs } {
const SUBCOMMANDS: Record<string, string[]> = {
folder: ['list', 'create', 'rename', 'delete'],
asset: ['list', 'get'],
tag: ['list', 'find'],
task: ['list', 'toggle'],
vault: ['info', 'list'],
Expand Down
33 changes: 32 additions & 1 deletion apps/desktop/src/cli/remote/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@
* commands keep printing exactly the fields they print for a local vault.
*/

import { remoteJsonRequest } from '../../main/remote/connection.js'
import {
RemoteRequestError,
connectionErrorMessage,
remoteJsonRequest,
requestErrorMessage
} from '../../main/remote/connection.js'
import type {
NoteContent,
NoteFolder,
Expand Down Expand Up @@ -97,6 +102,32 @@ export class CliRemoteClient {
return this.get<RemoteAssetMeta[]>('/api/assets')
}

/** An asset's raw bytes (#716). Binary on purpose — no JSON envelope —
* so `zn asset get` can stream exactly what the server serves. */
async readAsset(relPath: string): Promise<Uint8Array> {
const headers = new Headers()
if (this.authToken) {
headers.set('Authorization', `Bearer ${this.authToken}`)
}
let response: Response
try {
response = await fetch(
`${this.baseUrl}/api/assets/raw?path=${encodeURIComponent(relPath)}`,
{ headers }
)
} catch (error) {
throw new Error(connectionErrorMessage(this.baseUrl, error))
}
if (!response.ok) {
const text = await response.text().catch(() => '')
throw new RemoteRequestError(
requestErrorMessage(this.baseUrl, `/api/assets/raw?path=${relPath}`, response, text),
response.status
)
}
return new Uint8Array(await response.arrayBuffer())
}

scanTasksForPath(
relPath: string,
opts?: { includeExcluded?: boolean }
Expand Down
36 changes: 36 additions & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3228,6 +3228,42 @@ function registerIpc(): void {
return (await fsp.readFile(absolutePath(v.root, rel))).toString("base64");
});

// Save an asset out of the vault (#716). Local vaults copy the file;
// remote/self-hosted vaults stream the server's raw-asset response so the
// bytes never need to land in the renderer first.
handle(IPC.VAULT_DOWNLOAD_ASSET, async (e, assetPath: string) => {
const rel = String(assetPath ?? "").trim();
if (!rel) throw new Error("Asset path is required.");
const suggestedName = path.basename(rel);

const parentWindow = BrowserWindow.fromWebContents(e.sender);
const saveDialogOptions = {
title: "Save Asset",
defaultPath: path.join(app.getPath("documents"), suggestedName),
buttonLabel: "Save",
};
const result = parentWindow
? await dialog.showSaveDialog(parentWindow, saveDialogOptions)
: await dialog.showSaveDialog(saveDialogOptions);
if (result.canceled || !result.filePath) return;

if (isRemoteWorkspaceActive()) {
const response = await requireRemoteWorkspaceClient().fetchAssetResponse(rel);
if (!response.body) {
throw new Error("Remote asset response had no body.");
}
const { createWriteStream } = await import("node:fs");
const { Readable } = await import("node:stream");
const { pipeline } = await import("node:stream/promises");
const nodeStream = Readable.fromWeb(response.body as import("node:stream/web").ReadableStream);
await pipeline(nodeStream, createWriteStream(result.filePath));
return;
}

const v = requireVault();
await fsp.copyFile(absolutePath(v.root, rel), result.filePath);
});

handle(IPC.VAULT_HAS_ASSETS_DIR, async () => {
if (isRemoteWorkspaceActive())
return await requireRemoteWorkspaceClient().hasAssetsDir();
Expand Down
62 changes: 62 additions & 0 deletions apps/desktop/src/mcp/get-asset.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import type { VaultBackend } from '../cli/backend'
import { callTool, listToolNames } from './server'

// Only the members a given test reaches are implemented; the cast keeps the
// stubs honest about being partial.
function backend(partial: Partial<VaultBackend>): VaultBackend {
return partial as VaultBackend
}

describe('get_asset (#716)', () => {
it('is registered next to list_assets', () => {
const names = listToolNames()
expect(names).toContain('list_assets')
expect(names).toContain('get_asset')
})

it('returns base64, size, and a guessed MIME type', async () => {
const bytes = new TextEncoder().encode('PNGDATA')
const result = (await callTool(
'get_asset',
{ path: 'assets/pic.png' },
backend({ readAsset: async () => bytes })
)) as Record<string, unknown>
expect(result).toEqual({
path: 'assets/pic.png',
size: 7,
mimeType: 'image/png',
base64: Buffer.from(bytes).toString('base64')
})
})

it('falls back to application/octet-stream for unknown extensions', async () => {
const result = (await callTool(
'get_asset',
{ path: 'assets/blob.bin' },
backend({ readAsset: async () => new Uint8Array([1]) })
)) as Record<string, unknown>
expect(result.mimeType).toBe('application/octet-stream')
})

it('rejects assets over the 10 MB tool limit and points at the CLI', async () => {
const big = new Uint8Array(10 * 1024 * 1024 + 1)
await expect(
callTool(
'get_asset',
{ path: 'assets/huge.mp4' },
backend({ readAsset: async () => big })
)
).rejects.toThrow(/zn asset get/)
})

it('surfaces backend errors (missing asset, escaping path)', async () => {
await expect(
callTool(
'get_asset',
{ path: 'assets/nope.png' },
backend({ readAsset: async () => { throw new Error('Asset not found: assets/nope.png') } })
)
).rejects.toThrow(/not found/i)
})
})
1 change: 1 addition & 0 deletions apps/desktop/src/mcp/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ describe('tools run through the backend', () => {
'list_notes',
'list_folders',
'list_assets',
'get_asset',
'read_note',
'write_note',
'create_note',
Expand Down
Loading